Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 17 из 25 ~18 мин +50 XP

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

Пока всё шло по зелёной дорожке: конверт Envelope из [урока про JSON-ответы](/courses/gin/lessons/05-json-otvety/), respondBindError из [урока про привязку](/courses/gin/lessons/04-binding/), CustomRecovery из [урока про логирование](/courses/gin/lessons/08-logirovanie-recovery/). Но в бою ответы рождаются из десятков if err != nil, и каждый хендлер, решающий по-своему, — это новый вкус отказа для клиента. В этом уроке собираем систему целиком: gin обработка ошибок — это сентинел-ошибки домена и обёртки через %w, единый handleError, который превращает любой error в HTTP-ответ с кодом и статусом, копилка c.Errors для access-лога, второй этаж обороны — кастомный Recovery для паник — и errgroup, когда хендлер ходит в несколько бэкендов параллельно. Ошибки gin перестанут быть импровизацией: у них будет один формат, один маршрут до клиента и честный след в журнале.

Содержание урока · 13 разделов

gin обработка ошибок: четыре источника и один конверт#

В Go ошибка — это значение, а не исключение: её носят в руках, заворачивают и разбирают. Но у веб-сервера у каждой ошибки есть финал — HTTP-ответ: статус и тело. И здесь начинается главное: если каждый хендлер решает сам, клиент получает зоопарк — то gin.H с полем error, как в уроке про файлы, то честный конверт, то голый текст. У ошибок gin четыре источника, и все мы уже встречали. Первый — транспорт тела: ShouldBindJSON падает на EOF у пустого тела и на invalid character у битого JSON — ветка из урока про привязку. Второй — валидация: validator.ValidationErrors со своими Field() и Tag() из урока про validator. Третий — инфраструктура: диск не пишет, база лежит, upstream не отвечает. Четвёртый — доменная логика: задача не найдена, повторное создание запрещено. Отдельным классом идут паники — это не ошибка, а обрыв потока исполнения, и им нужен свой этаж обороны. Конверт у нас уже есть: Envelope с полем Error *APIError{Code, Message, Details}. Задача урока — протянуть от всех четырёх источников до этого конверта один маршрут: сентинелы в домене, обёртки между слоями, центральный handleError у хендлера и Recovery на самом дне.

Сентинелы и %w: ошибки, которые переживают слои#

Сентинел — заранее объявленная ошибка-эталон: var ErrNotFound = errors.New("todo not found"). Слой, который обнаружил проблему, возвращает её как есть; промежуточные слои добавляют контекст обёрткой fmt.Errorf("get todo %d: %w", id, ErrNotFound) — глагол %w заворачивает оригинал внутрь новой ошибки, сохраняя цепочку. Разматывать цепочку умеют два глагола из пакета errors. errors.Is(err, ErrNotFound) идёт по всей лестнице разворотов и отвечает «да», если внутри сидит именно этот сентинел: сравнение по идентичности, не по тексту. errors.As(err, &target) ищет не значение, а тип: поймает validator.ValidationErrors или свой доменный тип и положит в target. Семантическая граница проста: Is — «это та же проблема», As — «это проблема такого класса». Ошибка, обёрнутая через %v, цепочку рвёт: err.Error() ещё содержит текст, но Is уже не найдёт сентинел — самая частая дорогая опечатка в Go. Для пачки в Go 1.20 появился errors.Join: склеивает несколько в одну, и Is с As проходят по всем веткам дерева. Правило проекта: репозиторий возвращает сентинел, сервис оборачивает с контекстом операции, хендлер не разбирает тексты, а принимает решение по Is и As.

Три этажа: сентинел, обёртки, решение

GO
// Домен объявляет эталоны — их знают все слои.
var (
        ErrNotFound = errors.New("todo not found")
        ErrConflict = errors.New("todo already exists")
)

// Репозиторий: обнаружил — вернул сентинел.
func (s *MemStore) Get(id int64) (Todo, error) {
        todo, ok := s.items[id]
        if !ok {
                return Todo{}, fmt.Errorf("get todo %d: %w", id, ErrNotFound)
        }
        return todo, nil
}

// Сервис: обёртка с контекстом, сентинел жив внутри.
func (t *Tracker) Rename(id int64, title string) (Todo, error) {
        todo, err := t.store.Get(id)
        if err != nil {
                return Todo{}, fmt.Errorf("rename: %w", err)
        }
        if todo.Done {
                return Todo{}, ErrConflict
        }
        return t.store.Update(id, title)
}

// Хендлер: решение по Is, а не по тексту ошибки.
if todo, err := tracker.Rename(id, in.Title); err != nil {
        if errors.Is(err, ErrConflict) {
                respondError(c, http.StatusConflict, "CONFLICT",
                        "задача уже выполнена", nil)
                return
        }
        handleError(c, err) // всё остальное — по общей лестнице
        return
}

Цепочка: Get возвращает fmt.Errorf с %w, Rename добавляет свой контекст вторым %w, а хендлер спрашивает errors.Is — и тот находит ErrConflict через два разворота. Обратите внимание: ErrConflict вернулся без обёртки, и Is работает с ним напрямую. Единая точка handleError появится через два блока — там же станет ясно, зачем отдавать ему всё, что не совпало с узкими ветками.

Единый формат: статус решает транспорт, код — словарь#

Конверт готов, но без словаря он бесполезен: клиент должен уметь по коду принимать решения, а не по строке message, которую вы завтра переформулируете. Договор такой. Код — SCREAMING_SNAKE-константа, часть публичного контракта: VALIDATION_FAILED, NOT_FOUND, CONFLICT, INTERNAL; меняется только вместе с версией API. Статус — транспортная семантика: 4хх «виноват клиент», 5хх «виноват сервер»; мониторинг и кэши читают его первым, поэтому «ошибку со статусом 200» мы не делаем никогда — это ломает и метрики, и браузерные прокси. Message — человекочитаемый текст, на языке вашего фронтенда; его можно свободно править, на него нельзя опираться. Details — массив строк для полевых подсказок: какое поле и какой тег споткнулись. И железное правило безопасности: во внутренние ошибки клиент не видит ничего — ни SQL, ни путей, ни стектрейса; все подробности уходят в лог, наружу — обобщённая фраза. Коды 401 и 403 не путаем: первый — «не представился», второй — «представился, но прав нет»; эта пара уже была в звене-гварде из урока про кастомные middleware.

Словарь ошибок трекера#

КодСтатусКогда возникает
VALIDATION_FAILED400 Bad Requestне прошли теги binding — errors.As поймал validator.ValidationErrors
BAD_REQUEST400 Bad Requestбитое тело: EOF, invalid character, не тот Content-Type
UNAUTHORIZED401 Unauthorizedнет учётных данных или они просрочены
FORBIDDEN403 Forbiddenучётные данные верные, но прав не хватает
NOT_FOUND404 Not Foundресурса нет — errors.Is нашёл ErrNotFound
CONFLICT409 Conflictповторное создание, конфликт версий или состояния
TOO_LARGE413 Content Too Largeфайл или тело больше лимита из урока про загрузку
RATE_LIMITED429 Too Many Requestsсработал лимит — построим его в следующем уроке
BAD_GATEWAY502 Bad Gatewayupstream недоступен — errgroup вернул ErrUpstream
INTERNAL500 Internal Server Errorвсё остальное; деталей клиенту не показываем

handleError: один преобразователь вместо сотни if#

Теперь маршрут. Вместо того чтобы в каждом хендлере вручную перебирать сентинелы и типы валидатора, заводим одну функцию handleError(c *gin.Context, err error) — единый перевод error → HTTP-ответ. Внутри switch без выражения: каждая case — отдельная проверка errors.As или errors.Is, порядок — от частного к общему. Первой идёт валидация: errors.As на validator.ValidationErrors — самый узкий тип, который иначе потеряется среди прочих. Дальше сентинелы домена: NOT_FOUND, CONFLICT. Потом инфраструктурные: ErrUpstream для 502. И в конце default: то, что мы не классифицировали, — это всегда 500 INTERNAL, слог с полной ошибкой и c.Error в копилку, о которой поговорим ниже. Порядок веток — не вкус, а логика: errors.As на конкретный тип не сработает после broad-ветки по замыслу switch, но читаться код должен от частного к общему, как и в истории со SliceValidationError из урока про валидацию. Хендлер после этого вырождается: получил err — вызвал handleError и вернулся. Один вызов вместо пяти if — и ноль шансов, что два разработчика ответят на одну и ту же ошибку разными кодами.

handleError целиком

GO
// handleError — единая точка превращения error в HTTP-ответ.
func handleError(c *gin.Context, err error) {
        c.Error(err) // копилка: причина уедет в ErrorMessage access-лога

        var verrs validator.ValidationErrors
        switch {
        case errors.As(err, &verrs):
                respondError(c, http.StatusBadRequest, "VALIDATION_FAILED",
                        "проверьте поля формы", detailsFrom(verrs))

        case errors.Is(err, ErrNotFound):
                respondError(c, http.StatusNotFound, "NOT_FOUND",
                        "задача не найдена", nil)

        case errors.Is(err, ErrConflict):
                respondError(c, http.StatusConflict, "CONFLICT",
                        "задача уже выполнена", nil)

        case errors.Is(err, ErrUpstream):
                respondError(c, http.StatusBadGateway, "BAD_GATEWAY",
                        "вышестоящий сервис недоступен", nil)

        default:
                slog.Error("unhandled error", "err", err,
                        "path", c.Request.URL.Path)
                respondError(c, http.StatusInternalServerError, "INTERNAL",
                        "внутренняя ошибка сервера", nil)
        }
}

// detailsFrom — подсказки по полям для фронтенда.
func detailsFrom(verrs validator.ValidationErrors) []string {
        out := make([]string, 0, len(verrs))
        for _, e := range verrs {
                out = append(out, e.Field()+": "+e.Tag())
        }
        return out
}

respondError взят из урока про JSON-ответы: он кладёт APIError{Code, Message, Details} в Envelope и отвечает c.JSON. c.Error первым же действием — причина отказа всегда попадёт в [GIN]-строку, даже если дальше сработает самая общая ветка. default — единственное место с slog.Error: сюда долетает всё, что мы забыли классифицировать, поэтому запись здесь — рабочий сигнал дополнить словарь, а не мусор в журнале.

Проверка: цепочка из двух обёрток

handleError получил err, собранный как fmt.Errorf("rename: %w", fmt.Errorf("get todo %d: %w", 42, ErrNotFound)). Что вернёт клиенту?

Валидация в общем потоке: ValidationErrors и Details#

Где теперь живут respondBindError из урока про привязку и respondInvalid из урока про валидацию? Растворились в handleError: первая ветка errors.As — это их объединение. На выходе — тот же формат: 400, VALIDATION_FAILED, message общей фразой и details по полям. Зачем Details отдельным массивом, а не простынёй в message? Фронтенду нужно подсветить конкретные инпуты: строка вида "Title: required" парсится тривиально, а техническая простыня Key: 'TodoInput.Title' Error:Field validation... — нет; мы её видели в 04 и договорились клиенту её не показывать. Нюанс, который мы разобрали в валидационном уроке, остаётся: для массива верхнего уровня []TodoInput движок возвращает binding.SliceValidationError, а не validator.ValidationErrors, поэтому в handleError честная вторая As-ветка с *binding.SliceValidationError — идите по элементам и собирайте те же details с индексами. Тексты для каждого тега удобно выпускать из validationMessage: required — «поле обязательно», min — «минимум N», oneof — «допустимые значения». Важно и обратное: битый JSON до валидатора не доезжает, As на ValidationErrors промахнётся, и запрос уйдёт в ветку 400 BAD_REQUEST — не пытайтесь «спасти» невалидное тело валидационным кодом.

%v вместо %w: чем ответит handleError

Прочитайте код и выберите, что он выведет:

func (s *MemStore) Get(id int64) (Todo, error) {
        todo, ok := s.items[id]
        if !ok {
                // Ревью пропустило: %v вместо %w
                return Todo{}, fmt.Errorf("get todo %d: %v", id, ErrNotFound)
        }
        return todo, nil
}

// handleError из прошлого блока:
// case errors.Is(err, ErrNotFound): → 404 NOT_FOUND
// default: → 500 INTERNAL

c.Errors: копилка между звеньями#

Контекст — не только ответ, но и блокнот. c.Error(err) создаёт gin.Error{Err, Type, Meta} и дописывает в срез c.Errors; возврат — тот же gin.Error, так что можно проверить, что добавление случилось. Типы два: ErrorTypePrivate — по умолчанию, для ваших логов, и ErrorTypePublic — то, что не жалко показать клиенту, если захотите отдавать копилку в ответе. На практике копилка решает одну задачу — развязку «клиент получает конверт, лог получает причину». Помните LogFormatterParams из урока про логирование? Поле ErrorMessage там собирается ровно одной строкой: c.Errors.ByType(gin.ErrorTypePrivate).String() — приватные ошибки копилки склеиваются и печатаются в хвосте [GIN]-строки. Отсюда дисциплина: перед формированием ответа зовите c.Error(err) — и статус 400 в логе сразу будет с причиной рядом, без единого запроса в базу «а что там случилось в 15:42». Методы есть и удобные: c.Errors — весь срез, ByType — фильтр, а String() вызывает Error() каждого элемента через точку с запятой. В handleError мы положили c.Error первым действием — теперь каждая ошибка трекера оставляет след в access-логе автоматически.

Проверка: пустое поле в логе

Хендлер ответил c.JSON(http.StatusBadRequest, Envelope{...}) без единого вызова c.Error. Что увидит gin.Logger в поле ErrorMessage?

Кастомный Recovery: паники — не ошибки#

Паника не проходит через handleError — она ломает поток исполнения и раскручивает стек вверх, мимо всех if err != nil. Для неё второй этаж: звено Recovery, мы собирали его в уроке про логирование через gin.CustomRecovery(panicToEnvelope). Теперь три факта глубже. Первый: внутри CustomRecoveryWithWriter ядро gin само разбирает сломанные соединения — значение паники проверяется на net.OpError, внутри которого ищется os.SyscallError с «broken pipe» или «connection reset by peer»; для такого случая gin зовёт c.Error + c.Abort и не пытается писать статус — клиент уже ушёл, ответ он не прочитает. Второй: net/http имеет конвенцию http.ErrAbortHandler — паникуешь этим значением сам, и стандартный сервер глушит стектрейс; но Recovery в gin перехватывает панику раньше net/http, поэтому трассировка всё равно напечатается его средствами — не потеряется, но и «вежливого» глушения не ждите. Третий: стек собирает ядро — в вашей RecoveryFunc стека уже нет, есть только значение паники; поэтому функция занимается классификацией, метрикой и ответом, а трассировку печатает gin в DefaultErrorWriter. Повторная паника (re-panic) внутри обработчика — легальный ход, если платформа сама перезапускает процесс и вы хотите упасть по-настоящему.

RecoveryFunc с метрикой и классификацией

GO
// panics — счётчик для алертов: рост после релиза = баг в коде.
var panics atomic.Int64

// recoveryEnvelope — RecoveryFunc: классифицируем и отвечаем конвертом.
func recoveryEnvelope(c *gin.Context, v any) {
        panics.Add(1)

        var err error
        if e, ok := v.(error); ok {
                err = e // паниковали error-ом: nil map, index out of range
        } else {
                err = fmt.Errorf("%v", v) // паниковали строкой или структурой
        }

        slog.Error("паника в хендлере",
                "err", err,
                "path", c.Request.URL.Path,
                "total", panics.Load())

        // Стек в stderr уже напечатал CustomRecoveryWithWriter.
        c.AbortWithStatusJSON(http.StatusInternalServerError, Envelope{
                Error: &APIError{Code: "INTERNAL",
                        Message: "внутренняя ошибка сервера"},
        })
}

func main() {
        gin.SetMode(gin.ReleaseMode)
        r := gin.New()
        r.Use(gin.Logger())
        r.Use(gin.CustomRecoveryWithWriter(gin.DefaultErrorWriter, recoveryEnvelope))

        r.GET("/boom", func(c *gin.Context) {
                var m map[string]int
                m["x"] = 1 // runtime-паника: assignment to entry in nil map
        })
        _ = r.Run(":8080")
}

Сигнатура задана типом RecoveryFunc: func(c *Context, err any) — второй аргумент any, потому что panic() принимает любое значение. Счётчик atomic.Int64 беззаботно читается алертами, а слог кладёт тип паники и путь — по паре path+err локализуется мгновенно. В отличие от panicToEnvelope из урока 08, здесь классификация: если когда-нибудь паникуете своим доменным значением, ветка с type switch ляжет сюда же.

Паника после записи ответа

Прочитайте код и выберите, что он выведет:

r.GET("/flaky", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{"status": "ok"}) // ответ записан
        var m map[string]int
        m["x"] = 1 // паника уже после ответа
})

// Цепочка: Logger, CustomRecoveryWithWriter(..., recoveryEnvelope)

errgroup: параллельные ходы и первая ошибка#

Хендлер, который последовательно ходит в два сервиса, платит суммой задержек; параллельный — максимумом. Стандартная библиотека отвечает горутинами, но у неё нет словаря ошибок: кто вернёт первую, как отменить остальных? Ответ — golang.org/x/sync/errgroup, крошечный пакет, обзор которого обязателен для зрелой обработки ошибок. API — три слова. var g errgroup.Group — сама группа; g.Go(func() error) — запустить шаг в горутине; err := g.Wait() — дождаться всех и получить первую не-nil ошибку, остальные молча отбрасываются. g, ctx := errgroup.WithContext(parent) — расширенная пара: при первой ошибке ctx закрывается, и остальные горутины, уважающие ctx, выходят сами. g.SetLimit(n) — потолок параллелизма: Go сверх лимита блокируется, пока не освободится слот, — готовый троттлинг для пачки. В хендлере gin это выглядит так: ctx от c.Request.Context(), два g.Go к сервисам, Wait → handleError. Ошибки upstream заворачивайте в ErrUpstream — получите 502; ErrNotFound из профиля честно станет 404. И приятный штрих свежих версий x/sync: паника в горутине g.Go не теряется — Wait перепаникует тем же значением, и её подхватит Recovery, как будто паниковал сам хендлер.

Dashboard двумя ходами

GO
func dashboard(c *gin.Context) {
        // ctx отменится при первой ошибке или уходе клиента.
        g, ctx := errgroup.WithContext(c.Request.Context())

        var profile Profile
        var todos []Todo

        g.Go(func() error {
                p, err := userService.Profile(ctx, c.Param("id"))
                profile = p
                return err
        })
        g.Go(func() error {
                t, err := todoService.List(ctx, c.Param("id"))
                todos = t
                return err
        })

        if err := g.Wait(); err != nil {
                handleError(c, fmt.Errorf("dashboard: %w", err))
                return
        }
        c.JSON(http.StatusOK, Envelope{Data: gin.H{
                "profile": profile,
                "todos":   todos,
        }})
}

Две горутины идут параллельно: латентность — max(a, b), а не a + b. Запись в локальные переменные profile и todos из горутин безопасна: Wait устанавливает happens-before, читаем мы только после него. Возврат — через handleError: ошибка upstream даст 502 BAD_GATEWAY, «не найден» — 404, и всё это без единого if в самом хендлере. Ушли клиенты тоже учтены: Request.Context() закроется — и WAIT вернёт context.Canceled.

Проверка: кто победил в errgroup

Три g.Go: первый вернул ErrUpstream, второй ещё читает из базы, третий вернёт ErrNotFound. Что вернёт g.Wait()?

Семь правил обработки ошибок в gin#

  • Ошибка — значение: репозиторий возвращает сентинел, сервис оборачивает %w с контекстом, хендлер решает по errors.Is и errors.As, а не по подстрокам err.Error().
  • %v вместо %w — разорванная цепочка: Is промахнётся, и клиент получит 500 вместо 404; поиск fmt.Errorf без %w — обязательный пункт ревью.
  • Один handleError на проект: switch от частного к общему — сначала As на validator.ValidationErrors, потом Is на сентинелы, default — 500 INTERNAL со slog.Error.
  • Код ошибки — контракт: статус для транспорта и мониторинга, SCREAMING_SNAKE для словаря клиента, Details для полевых подсказок; внутренности — только в лог.
  • c.Error(err) перед ответом: ErrorMessage в [GIN]-строке — единственное место, где причина стоит рядом со статусом; конверт клиенту, причина — копилке.
  • Recovery — второй этаж, а не замена handleError: ловит обрывы потока, печатает стек в stderr, отдаёт конверт INTERNAL; уже записанный ответ он не перепишет.
  • errgroup для fan-out: WithContext от Request.Context(), SetLimit против шторма горутин, Wait возвращает первую ошибку прямо в handleError.

Соберите handleError

Допишите обёртку с сохранением цепочки и три ветки handleError: копилка, валидация и «не найдено».

// Репозиторий: сохраняем цепочку через глагол . func (s *MemStore) Get(id int64) (Todo, error) { todo, ok := s.items[id] if !ok { return Todo{}, fmt.Errorf("get todo %d: ", id, ErrNotFound) } return todo, nil } // handleError: единый перевод error -> HTTP. func handleError(c *gin.Context, err error) { c.(err) // причина уедет в ErrorMessage access-лога var verrs validator.ValidationErrors switch { case errors.(err, &verrs): respondError(c, http.StatusBadRequest, "VALIDATION_FAILED", "проверьте поля формы", detailsFrom(verrs)) case errors.(err, ErrNotFound): respondError(c, http., "NOT_FOUND", "задача не найдена", nil) default: slog.Error("unhandled error", "err", err) respondError(c, http.StatusInternalServerError, "INTERNAL", "внутренняя ошибка сервера", nil) } }

Соберите путь запроса с ошибкой валидации

POST /todos с пустым title: разложите события от curl до [GIN]-строки.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: обработка ошибок

Термин

Сентинел и %w

нажмите, чтобы перевернуть
Определение

var ErrNotFound = errors.New(...) — эталон домена. fmt.Errorf("...: %w", ErrNotFound) заворачивает с сохранением цепочки; %v цепочку рвёт: текст в err.Error() есть, а errors.Is промахивается. Норма: репозиторий возвращает сентинел, сервис оборачивает, хендлер решает.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • У ошибок gin четыре источника — тело, валидация, инфраструктура, домен — и один финал: Envelope с APIError{Code, Message, Details} из урока про JSON-ответы.
  • Сентинелы домена переживают слои только через %w: errors.Is идёт по цепочке разворотов, errors.As достаёт типы вроде validator.ValidationErrors.
  • handleError — один switch от частного к общему: As на валидацию, Is на сентинелы, default — 500 INTERNAL со slog; Details для полей, конверт для клиента.
  • c.Error кладёт *gin.Error в копилку: ErrorMessage в [GIN]-строке берётся именно оттуда — без c.Error лог молчит о причине отказа.
  • Паники — отдельный этаж: RecoveryFunc со счётчиком atomic и конвертом INTERNAL; broken pipe gin разбирает сам, а уже записанный ответ статус не перепишет.
  • errgroup: Go параллелит, Wait возвращает первую ошибку, WithContext отменяет остальных; в свежих x/sync паника из горутины перепаникуется в Wait.

Домашнее задание

Соберите этаж ошибок трекера целиком. Сентинелы ErrNotFound и ErrConflict, обёртки %w в репозитории и сервисе, handleError с ветками ValidationErrors (Details по полям через Field и Tag), NOT_FOUND, CONFLICT, BAD_GATEWAY и INTERNAL. Вставьте c.Error первым действием и убедитесь, что ErrorMessage появилась в [GIN]-строке. Замените встроенный Recovery на CustomRecoveryWithWriter со своим recoveryEnvelope: счётчик atomic.Int64, слог с path и total, конверт 500. Сломайте цепочку %v-обёрткой в репозитории и понаблюдайте деградацию 404 → 500, потом почините. Добавьте GET /dashboard/:id с errgroup: два параллельных источника данных, WithContext от Request.Context(), первая ошибка отменяет второго, Wait идёт в handleError. Прогоните curl-ом пять сценариев: пустой title, несуществующий id, битый JSON, /boom и dashboard по живому id.

Личные заметкиПусто
Хранятся только в вашем браузере (Gin · Обработка ошибок и паник)

Вопросы и ответы по уроку

Чем c.Error отличается от respondError?

c.Error только копит: *gin.Error ложится в c.Errors и попадает в ErrorMessage access-лога. respondError (и handleError) формирует ответ клиенту. Норма — c.Error(err) внутри handleError: лог получает причину, клиент — конверт, и одно другому не мешает.

Почему после паники клиент получил 200, а не 500?

Если хендлер успел записать ответ, WriteHeader(500) внутри Recovery игнорируется — статус отправляется один раз. Возможен и приписанный конверт в теле. Recovery страхует обрывы до записи; «записал и упал» ловится только логами и тестами.

errgroup или каналы для параллельных запросов?

Для «собрать N ответов, вернуть первую ошибку» — errgroup: Wait, отмена через WithContext, потолок SetLimit. Каналы берут, когда результаты нужно обрабатывать по мере готовности, как поток. В хендлере gin почти всегда достаточно errgroup.

Куда девать стектрейс паники?

Его печатает ядро Recovery в gin.DefaultErrorWriter — stderr или файл из урока про логирование. Внутри RecoveryFunc стека уже нет, только значение паники: логируйте путь, счётчик и значение, а трассировку доверяйте ядру.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Echo25 уроков~13 чпрактика25 уроков уже доступны

Echo: минималистичный быстрый фреймворк

Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.

  • Строить REST API на Echo
  • Использовать типизированные запросы/ответы (Request/Response)
  • Управлять ошибками через echo.HTTPError
  • Писать middleware, включая JWT-аутентификацию