Веб-разработка на 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-запросов. Это поможет избежать уязвимости, которая может вывести веб-приложение из строя

Рекомендуемые программы