Зарегистрируйтесь, чтобы продолжить обучение

Обработка ошибок в веб-приложении Веб-разработка на Go

Представим, что мы запустили проект, в котором серверная часть написана на Golang. Все идет хорошо: количество пользователей растет и бизнес развивается. Но в какой-то момент мы замечаем, что наше веб-приложение выключается без причины. Пользователи сталкиваются с ошибками, спрашивают о проблеме, но мы не можем определить причину.

В этом уроке мы рассмотрим самые популярные проблемы Go веб-приложений и способы их решения. Также узнаем, можно ли заранее обезопасить себя от неожиданных сбоев.

Пишем устойчивое веб-приложение

Допустим, у нас есть веб-приложение, которое позволяет рассчитать время между двумя датами. Код приложения выглядит следующим образом:

package main

import (
    "github.com/gofiber/fiber/v2"
    "time"
)

type (
    // Структура HTTP-запроса на расчет диапазона дат
    DateRangeRequest struct {
        From Date `json:"from"`
        To   Date `json:"to"`
    }

    // Структура даты, которая хранит формат и значение
    Date struct {
        Value  string `json:"value"`
        Format string `json:"format"`
    }

    // Структура HTTP-ответа на расчет диапазона дат
    // Хранит значение в секундах
    DateRangeResponse struct {
        SecondsRange int64 `json:"seconds_range"`
    }
)

func main() {
    webApp := fiber.New()

    webApp.Post("/daterange", func(c *fiber.Ctx) error {
        var req *DateRangeRequest
        c.BodyParser(req)

        from, _ := time.Parse(req.From.Format, req.From.Value)
        to, _ := time.Parse(req.To.Format, req.To.Value)

        return c.JSON(DateRangeResponse{
            SecondsRange: int64(to.Sub(from).Seconds()),
        })
    })

    webApp.Listen(":80")
}

Все работает отлично, пока в один день мы не внесли изменение в код приложения, которое отправляет HTTP-запросы. Из-за небольшой ошибки в коде, приложение стало отправлять некорректный JSON-формат в теле HTTP-запроса.

Воспроизведем такую ситуацию. Запускаем веб-приложение и отправляем HTTP-запрос с некорректным JSON-форматом в теле:

curl --location --request POST 'http://localhost/daterange' \
--header 'Content-Type: application/json' \
--data-raw '{'

Получаем сообщение, что ответа не было:

curl: (52) Empty reply from server

Ошибка в формате тела HTTP-запроса привела к тому, что наше веб-приложение перестало работать. В логах веб-приложения мы видим сообщение о панике:

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x10 pc=0x104470750]

goroutine 34 [running]:
main.main.func1(0x10453dc40?)
        /Users/john.doe/go/src/error-handling/main.go:36 +0x30
github.com/gofiber/fiber/v2.(*App).next(0x1400010ec80, 0x140002b0000)
        /Users/john.doe/go/src/error-handling/vendor/github.com/gofiber/fiber/v2/router.go:132 +0x184
github.com/gofiber/fiber/v2.(*App).handler(0x1400010ec80, 0x104429338?)
        /Users/john.doe/go/src/error-handling/vendor/github.com/gofiber/fiber/v2/router.go:159 +0x3c
github.com/valyala/fasthttp.(*Server).serveConn(0x140001126c0, {0x10458e998?, 0x14000286000})
...

Восстановление после паники

Паника — это критическая ошибка, которая останавливает работу всего Go-приложения. Она возникает в запущенном приложении, поэтому ее нельзя обнаружить во время компиляции.

Из-за того, что паника возникает в работающем приложении и приводит к полной остановке, следует максимально подготовить наше приложение к таким ситуациям. Для этого в Go есть механизм восстановление от паники.

В веб-приложениях принято устанавливать посредника для восстановления после паник. Добавим такого посредника в наше веб-приложение:

package main

import (
    "github.com/gofiber/fiber/v2"
    "github.com/gofiber/fiber/v2/middleware/recover"
    "time"
)

type (
    // Структура HTTP-запроса на расчет диапазона дат
    DateRangeRequest struct {
        From Date `json:"from"`
        To   Date `json:"to"`
    }

    // Структура даты, которая хранит формат и значение
    Date struct {
        Value  string `json:"value"`
        Format string `json:"format"`
    }

    // Структура HTTP-ответа на расчет диапазона дат
    // Хранит значение в секундах
    DateRangeResponse struct {
        SecondsRange int64 `json:"seconds_range"`
    }
)

func main() {
    webApp := fiber.New()
    // Устанавливаем посредника, который будет
    // восстанавливать веб-приложение после паники
    webApp.Use(recover.New())

    webApp.Post("/daterange", func(c *fiber.Ctx) error {
        var req *DateRangeRequest
        c.BodyParser(req)

        from, _ := time.Parse(req.From.Format, req.From.Value)
        to, _ := time.Parse(req.To.Format, req.To.Value)

        return c.JSON(DateRangeResponse{
            SecondsRange: int64(to.Sub(from).Seconds()),
        })
    })

    webApp.Listen(":80")
}

Запускаем веб-приложение и пытаемся снова воспроизвести ситуацию с некорректным JSON-телом в HTTP-запросе. На этот раз получаем следующий ответ:

runtime error: invalid memory address or nil pointer dereference

Мы получили сообщение об ошибке, но веб-приложение продолжило работу. Это произошло благодаря посреднику recover.New(), который восстановил веб-приложение после паники. Если мы попробуем отправить еще десять таких запросов, то веб-приложение все равно продолжит работать.

Наше веб-приложение стало устойчиво к паникам в коде, однако пользователи до сих пор получают ошибки с непонятным текстом. При этом мы ничего не видим в своих логах. Исправим обработку ошибок в приложении.

Обработка ошибок

В любом работающем веб-приложении всегда могут возникнуть ошибки. Это обусловлено тем, что веб-приложения — это сложные системы, в которых невозможно учесть все возможные сценарии. Поэтому Go-разработчикам важно предусмотреть возникновение как можно большего количества ошибок.

В Go ошибки представлены явным типом error, и золотое правило Go-разработчика звучит как: Обрабатываем абсолютно все ошибки в коде приложения. Даже если кажется, что ошибка в какой-то части кода никогда не возникнет, мы все равно должны ее обработать. Код приложения будет часто меняться, и то, что невозможно сегодня, может произойти завтра.

Добавим обработку всех ошибок в нашем веб-приложении:

package main

import (
    "github.com/gofiber/fiber/v2"
    "github.com/gofiber/fiber/v2/middleware/recover"
    "github.com/sirupsen/logrus"
    "time"
)

type (
    // Структура HTTP-запроса на расчет диапазона дат
    DateRangeRequest struct {
        From Date `json:"from"`
        To   Date `json:"to"`
    }

    // Структура даты, которая хранит формат и значение
    Date struct {
        Value  string `json:"value"`
        Format string `json:"format"`
    }

    // Структура HTTP-ответа на расчет диапазона дат
    // Хранит значение в секундах
    DateRangeResponse struct {
        SecondsRange int64 `json:"seconds_range"`
    }
)

func main() {
    webApp := fiber.New()
    // Устанавливаем посредника, который будет
    // восстанавливать веб-приложение после паники
    webApp.Use(recover.New())

    webApp.Post("/daterange", func(c *fiber.Ctx) error {
        var req *DateRangeRequest
        if err := c.BodyParser(&req); err != nil {
            logrus.WithError(err).Info("body parser")
            return c.Status(fiber.StatusBadRequest).SendString("bad JSON")
        }

        from, err := time.Parse(req.From.Format, req.From.Value)
        if err != nil {
            logrus.WithError(err).Info("parse 'from' date")
            return c.Status(fiber.StatusUnprocessableEntity).SendString("bad 'from' date")
        }
        to, err := time.Parse(req.To.Format, req.To.Value)
        if err != nil {
            logrus.WithError(err).Info("parse 'to' date")
            return c.Status(fiber.StatusUnprocessableEntity).SendString("bad 'to' date")
        }

        return c.JSON(DateRangeResponse{
            SecondsRange: int64(to.Sub(from).Seconds()),
        })
    })

    lErr := webApp.Listen(":80")
    if lErr != nil {
        logrus.WithError(lErr).Fatal("listen port")
    }
}

Запускаем веб-приложение и отправляем HTTP-запрос с невалидным JSON в теле:

curl --location --request POST 'http://localhost/daterange' \
--header 'Content-Type: application/json' \
--data-raw '{'

В ответ получаем сообщение об ошибке:

HTTP/1.1 400 Bad Request

bad JSON

Теперь отправителю HTTP-запроса понятно, в чем заключается ошибка.

Попробуем отправить корректный JSON в теле HTTP-запроса, но передать невалидные даты:

curl --location --request POST 'http://localhost/daterange' \
--header 'Content-Type: application/json' \
--data-raw '{
    "from": {
        "format": "test",
        "value": "25"
    },
    "to": {
        "format": "test",
        "value": "25"
    }
}'

Получаем в ответ сообщение:

HTTP/1.1 422 Unprocessable Entity

bad 'from' date

Отправитель HTTP-запроса поймет, что неправильно передал значение в поле from.

Проверим, что наше веб-приложение работает при корректном запросе:

curl --location --request POST 'http://localhost/daterange' \
--header 'Content-Type: application/json' \
--data-raw '{
    "from": {
        "format": "Jan 2, 2006 at 3:04pm (MST)",
        "value": "Nov 1, 2022 at 12:59pm (PST)"
    },
    "to": {
        "format": "Jan 2, 2006 at 3:04pm (MST)",
        "value": "Nov 2, 2022 at 12:59pm (PST)"
    }
}'

В ответ приходит:

HTTP/1.1 200 OK

{"seconds_range":86400}

Мы сделали веб-приложение более устойчивым к ошибкам в коде и неправильным запросам. В случае паники, веб-приложение будет само восстанавливаться и продолжать работу. При неправильном HTTP-запросе отправитель получит понятное сообщение об ошибке, а мы увидим в логах подробную информацию о том, что произошло.

Мы уже сделали много работы, но осталась еще одна потенциальная проблема, которую стоит учитывать при разработке веб-приложений. У нас отсутствуют таймауты при обработке HTTP-запросов.

Таймаут при обработке HTTP-запроса

Представим, что наш конкурент решил воспользоваться противозаконным методом и нанял команду хакеров, чтобы они остановили наше веб-приложение. Допустим, они узнали, что у нас нет таймаутов на обработку HTTP-запросов. Тогда они написали программу, которая отправляла в веб-приложение HTTP-запрос, но делало это очень медленно — по одному байту в секунду.

Получилась ситуация, когда количество соединений к нашему веб-приложению бесконечно растет, а обработчики не могут сработать, потому что тело HTTP-запроса получено не полностью. Спустя какое-то время, наше приложение перестает отвечать на HTTP-запросы, и весь сервер может выйти из строя.

Чтобы избежать такой ситуации, нам стоит настроить таймауты при обработке HTTP-запросов. Эта настройка укажет веб-приложению закрывать соединения спустя заданное время, если оно не получило полностью тело HTTP-запроса.

Добавим настройку таймаутов в наш код:

func main() {
    // Создаем новый экземпляр Fiber веб-приложения
    // Указываем таймаут на чтение HTTP-запросов в 3 секунды
    // Указываем таймаут на запись HTTP-запросов в 3 секунды
    webApp := fiber.New(fiber.Config{
        ReadTimeout:  3 * time.Second,
        WriteTimeout: 3 * time.Second,
    })

    ...
}

Таймауты настраиваются через передачу параметров в конструктор fiber.New(). Мы указали таймаут на чтение HTTP-запросов и запись HTTP-ответов в три секунды. Если спустя заданное время не произойдет ни одного действия, то соединение будет закрыто.

С помощью настройки таймаутов на обработку HTTP-запросов мы обезопасили веб-приложение от еще одной уязвимости, которая может вывести веб-приложение из строя.

Выводы

  • В Go веб-приложениях может возникать состояние критической ошибки — паника, и важно всегда иметь посредника, который будет восстанавливать веб-приложение
  • Все ошибки, которые возвращаются из функций, должны быть обработаны. Так разработчики и пользователи будут понимать, что пошло не так
  • В Go веб-приложениях нужно настроить таймауты на обработку HTTP-запросов. Это поможет избежать уязвимости, которая может вывести веб-приложение из строя

Дополнительные материалы

  1. Go Panic Example
  2. Error Handling and Go
  3. Go Fiber App
  4. Go Fiber Timeout Middleware

Аватары экспертов Хекслета

Остались вопросы? Задайте их в разделе «Обсуждение»

Вам ответят команда поддержки Хекслета или другие студенты

Для полного доступа к курсу нужен базовый план

Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff

Используйте Хекслет по-максимуму!

  • Задавайте вопросы по уроку
  • Проверяйте знания в квизах
  • Проходите практику прямо в браузере
  • Отслеживайте свой прогресс

Зарегистрируйтесь или войдите в свой аккаунт

Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»