---
title: "Обработка ошибок и паник"
description: "Обработка ошибок в gin: сентинелы и %w, единый handleError с errors.Is и As, копилка c.Errors, свой Recovery для паник и errgroup для параллельных запросов."
course: "Gin: самый популярный веб-фреймворк Go"
url: "https://golangmaster.ru/courses/gin/lessons/17-oshibki/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Gin: самый популярный веб-фреймворк Go», урок 17 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/gin/lessons/17-oshibki/*

Пока всё шло по зелёной дорожке: конверт 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 перестанут быть импровизацией: у них будет один формат, один маршрут до клиента и честный след в журнале.

### 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_FAILED | 400 Bad Request | не прошли теги binding — errors.As поймал validator.ValidationErrors |
| BAD_REQUEST | 400 Bad Request | битое тело: EOF, invalid character, не тот Content-Type |
| UNAUTHORIZED | 401 Unauthorized | нет учётных данных или они просрочены |
| FORBIDDEN | 403 Forbidden | учётные данные верные, но прав не хватает |
| NOT_FOUND | 404 Not Found | ресурса нет — errors.Is нашёл ErrNotFound |
| CONFLICT | 409 Conflict | повторное создание, конфликт версий или состояния |
| TOO_LARGE | 413 Content Too Large | файл или тело больше лимита из урока про загрузку |
| RATE_LIMITED | 429 Too Many Requests | сработал лимит — построим его в следующем уроке |
| BAD_GATEWAY | 502 Bad Gateway | upstream недоступен — errgroup вернул ErrUpstream |
| INTERNAL | 500 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)). Что вернёт клиенту?

- 500 INTERNAL: два уровня обёртки сбивают errors.Is, он проверяет только верхний слой
- ✅ 404 NOT_FOUND: errors.Is проходит через всю цепочку разворотов %w
- 400 BAD_REQUEST: ошибка не распознана как доменная
- 409 CONFLICT: rename — операция изменения, gin приоритизирует её

errors.Is разматывает цепочку целиком: rename → get todo 42 → ErrNotFound. Глубина обёрток не имеет значения, пока каждый слой использует %w. Именно так летят баги с %v: текст ошибки по-прежнему печатается в err.Error(), клиент получает 500 вместо 404, и выглядит это как «сервер сломался», хотя сломана одна буква формат-глагола.

### Валидация в общем потоке: 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

```go
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
```

**Что выведет программа?**

- 404 NOT_FOUND: текст «todo not found» виден в err.Error(), Is сравнивает по нему
- 404 NOT_FOUND: gin сам распаковывает %v-обёртки перед handleError
- ✅ 500 INTERNAL: %v не заворачивает ошибку, Is не найдёт сентинел — ветка default
- 409 CONFLICT: неопознанные ошибки в репозитории считаются конфликтами

%v вставляет текст сентинела в новую строку, но связь между значениями теряется: наверху обычный *errors.errorString, внутри ничего нет. errors.Is сравнивает идентичность, а не подстроки, — промах, и выполняется default: 500 INTERNAL плюс slog.Error. Диагноз в логе будет честным, а вот контракт с клиентом — сломанным: фронт покажет «внутренняя ошибка» там, где надо рисовать «не найдено».

### 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?

- Текст конверта APIError — Logger читает тело ответа после c.JSON
- ✅ Пустую строку: ErrorMessage собирается только из приватных ошибок копилки c.Errors
- Статус 400 с путём запроса — Logger сам формирует сообщение из статуса
- Панику: ErrorMessage обязательно должен быть заполнен к моменту логирования

Logger не заглядывает в тело ответа и не выдумывает причин — он берёт c.Errors.ByType(gin.ErrorTypePrivate).String(). Копилка пуста — поле пустое. Это и есть контракт из урока 08: ErrorMessage — зеркало того, что хендлер сообщил о себе через c.Error. handleError зовёт c.Error(err) первым действием, поэтому у всех ошибок, идущих через него, причина в логе есть всегда.

### Кастомный 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 ляжет сюда же.

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

```go
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)
```

**Что выведет программа?**

- 500 с конвертом INTERNAL: Recovery перепишет статус на свой
- ✅ 200 с телом {"status":"ok"} и, возможно, приписанным конвертом: статус после записи не меняется
- Соединение оборвётся без ответа: net/http сбросит сокет при панике
- 200 с чистым JSON: Recovery увидит Written() и выйдет, не предприняв ничего

Статус уходит клиентом один раз: после WriteHeader попытка назначить 500 игнорируется. Recovery всё же выполнится — его slog и счётчик сработают, стек напечатается, а попытка дописать конверт в уже закрывающийся ответ либо прилипнет к телу (буфер позволил), либо тихо отвергнется с ошибкой Content-Length. Мораль двойная: Recovery страхует обрывы до записи, а «записал и упал» ловится только логами и тестами.

### 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()?

- errors.Join из всех ошибок — они склеятся через запятую
- Пустую ошибку: группа ждёт всех, а две из трёх завершились ошибкой
- ✅ Первую не-nil ошибку — ErrUpstream; ctx группы закроется, остальные должны уважать Done
- ErrNotFound: последняя ошибка затирает предыдущие в поле группы

errgroup хранит ровно одну ошибку — первую записанную; последующие присваивания не перезаписывают её. WithContext при этом закрывает ctx — второй горутине следует проверять ctx.Done и выходить рано, иначе Wait будет ждать её до конца. Если нужны все ошибки разом — это уже задача errors.Join внутри каждого шага: соберите свой срез и верните Join-ошибку.

### Семь правил обработки ошибок в 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: копилка, валидация и «не найдено».

```go
// Репозиторий: сохраняем цепочку через глагол ___.
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)
        }
}
```

Подсказка: Цепочку хранит %w; в копилку кладёт c.Error; тип достаёт errors.As, сентинел находит errors.Is; статус «не найдено» — http.StatusNotFound.

Правильные ответы в пропусках: `%w`, `Error`, `As`, `Is`, `StatusNotFound`

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

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

Правильный порядок строк:

1. `curl -d '{"title":""}' -H "Content-Type: application/json" localhost:8080/todos`
2. `Цепочка Logger, Recovery: оба запомнили ход и пропустили его к хендлеру`
3. `createTodo зовёт ShouldBindJSON(&in): JSON разобран, title пуст`
4. `binding.Validator исполняет тег required — движок возвращает validator.ValidationErrors`
5. `Хендлер передаёт err в handleError`
6. `c.Error(err) кладёт *gin.Error приватного типа в копилку c.Errors`
7. `errors.As находит validator.ValidationErrors — первая, самая узкая ветка`
8. `detailsFrom собирает массив "Title: required" из Field() и Tag()`
9. `respondError отвечает 400 с Envelope и APIError{Code: VALIDATION_FAILED}`
10. `Logger печатает [GIN]-строку: 400 и ErrorMessage из приватных ошибок копилки`
11. `Фронтенд подсвечивает поле title по details, не парся message`

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

- **Сентинел и %w** — var ErrNotFound = errors.New(...) — эталон домена. fmt.Errorf("...: %w", ErrNotFound) заворачивает с сохранением цепочки; %v цепочку рвёт: текст в err.Error() есть, а errors.Is промахивается. Норма: репозиторий возвращает сентинел, сервис оборачивает, хендлер решает.
- **errors.Is vs errors.As** — Is — сравнение по идентичности через всю цепочку разворотов: «это тот же сентинел». As — поиск по типу с распаковкой в target: validator.ValidationErrors, SliceValidationError. errors.Join (Go 1.20) склеивает несколько ошибок — Is и As проходят по всему дереву.
- **APIError и словарь кодов** — Code — SCREAMING_SNAKE-контракт, Message — человекочитаемый текст, Details — подсказки по полям. Статусы: VALIDATION_FAILED 400, NOT_FOUND 404, CONFLICT 409, TOO_LARGE 413, RATE_LIMITED 429, INTERNAL 500, BAD_GATEWAY 502. 500 наружу — обобщённая фраза, подробности в лог.
- **c.Errors** — c.Error(err) дописывает *gin.Error{Err, Type, Meta}; типы ErrorTypePrivate (дефолт, для логов) и ErrorTypePublic. ErrorMessage в [GIN]-строке — c.Errors.ByType(ErrorTypePrivate).String(). Без c.Error лог молчит о причине, даже если клиент получил честный 400.
- **Recovery и broken pipe** — CustomRecoveryWithWriter печатает стек в writer и зовёт RecoveryFunc func(c *Context, err any). Сломанный pipe (*net.OpError → os.SyscallError «broken pipe»/«connection reset by peer») ядро разбирает само: c.Error + c.Abort без статуса. http.ErrAbortHandler gin перехватит раньше net/http — трассировка всё равно будет.
- **errgroup** — g.Go(func() error), g.Wait() → первая не-nil ошибка; errgroup.WithContext даёт ctx, который закрывается при первой ошибке; SetLimit(n) ограничивает параллелизм. В свежих x/sync паника в горутине перепаникуется в Wait — подхватит Recovery. ctx берите от Request.Context().

> **Прогоните ошибочные сценарии curl-ом.** Соберите handleError и recoveryEnvelope и устройте разгром: curl -d '{"title":""}' -H "Content-Type: application/json" localhost:8080/todos — 400 с VALIDATION_FAILED и details по полям; curl localhost:8080/todos/999 — 404 с NOT_FOUND; замените %w на %v в Get — и 404 превратится в 500: Is больше не находит сентинел; добавьте GET /boom с записью в nil-карту — 500 с конвертом INTERNAL, а в stderr уйдёт [Recovery]-трассировка и slog-строка с total. Посмотрите [GIN]-строку: ErrorMessage теперь называет причину, а не молчит. В браузерной песочнице сайта сетевой код не исполняется, поэтому интерактивы здесь quiz, predict, fill, order и флешкарты, а примеры живут в локальном go run, как и весь [курс gin](/courses/gin/). Дальше — 18-rate-limit: токенное ведро, x/time/rate и per-IP звено с 429 и Retry-After — обработка ошибок готова, пора ограничивать и тех, кто не ошибается, а просто стучится слишком часто.

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

- У ошибок 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.

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

### Чем 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 стека уже нет, только значение паники: логируйте путь, счётчик и значение, а трассировку доверяйте ядру.
