Содержание урока · 10 разделов
- Централизованные ошибки chi: error.code и error.message
- errorHandler: где ошибка превращается в ответ
- Статусы: 400, 422, 404, 405 и остальные
- NotFound и MethodNotAllowed: свои 404 и 405
- Ситуация → статус → code → кто вызывает
- Согласованность: клиент парсит один формат
- Гигиена централизованных ошибок
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Каркас заметок из урока про JSON API к концу спринта оброс ошибками в трёх стилях. POST /notes отвечал {"error": "битый JSON"} через writeErr, авторизация отдавала {"message": "нет токена", "status": 401}, а новый эндпоинт экспорта при пустом поле падал plain text-ом «title обязателен». Фронтенд-разработчик написал один обработчик на все ответы со статусом не 2xx — и сломался на первом же 422: res.json() бросил SyntaxError на тексте, тост остался пустым, поддержка получила три тикета про один баг. Час разбора показал главное: три формата — это три контракта, каждый надо поддерживать отдельно, и каждый рано или поздно изменится поодиночке. Лекарство — один формат на весь API, одна функция, которая его пишет, и один слой, который подхватывает то, до чего хендлер не добрался. Централизованные ошибки — это не украшение для ревью, а способ сделать поведение сервиса предсказуемым для клиента.
Централизованные ошибки chi: error.code и error.message#
Формат обязуется быть одним и тем же для любого провала: обёртка error, внутри машинный code и человеческий message. Обёртка — не украшение: однажды рядом появятся поля details для ошибок валидации или request_id для поддержки, и обёртка даст им место, не ломая существующих клиентов. code — строковый идентификатор вида not_found или validation_failed: его читает машина и ветвит логику — unauthorized ведёт на страницу логина, rate_limited показывает таймер. message — текст для человека, его показывают в тосте и парсить его нельзя: формулировки меняются вместе с локализацией. Content-Type: application/json обязателен и для ошибок: ошибка — такой же JSON-ответ, как и успех, и фронтенд вызывает res.json() без предварительного чтения заголовка. Правило error handling в go, которое здесь работает: формат фиксируется тестом, а не договорённостью в чате — тест на каждый статус проверяет и Content-Type тоже.
renderError: одна функция пишет все ошибки
GOfunc renderError(w http.ResponseWriter, r *http.Request, status int, code, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]any{
"error": map[string]string{
"code": code,
"message": msg,
},
})
}Три строки в жёстком порядке из урока про JSON API: Content-Type до WriteHeader, Encode последним. map[string]any нужен ради обёртки: вложенный объект не выразить map[string]string. Хендлер зовёт helper одной строкой и сразу return — ответ уже отправлен. Сигнатура с r оставлена не случайно: когда в ошибке появится request_id из middleware.GetReqID, менять все вызовы не придётся.
Проверка: error-канал
Хендлер updateNote должен сообщить об ошибке валидации наверх. Почему нельзя написать return err, как в Fiber?
errorHandler: где ошибка превращается в ответ#
Канала ошибки нет — значит, его эмулируют. Первый путь — renderError в месте и return: простой, но разбросан по хендлерам. Второй — свой тип apiError и middleware-обработчик: хендлер передаёт ошибку паникой значением этого типа, а errorHandler ловит её в defer через recover(). Паника как управление потоком — приём спорный, но у него есть честное оправдание: механизм уже есть, работает сквозь все слои и не требует менять сигнатуры хендлеров. Критично не перепутать свою ошибку с настоящей паникой: errorHandler перехватывает только *apiError, чужое значение обрабатывает отдельно — стек в журнал через debug.PrintStack и 500 с code internal. http.ErrAbortHandler — служебная паника, которой net/http рвёт соединение, — пропускается, ровно как это делает Recoverer из урока про встроенные middleware. Слой ставится в начале стека, сразу после RequestID и RealIP и до Logger: каждая его ошибка проходит через обёртку лога, и строка показывает честный 404, а не 200. Стандартный Recoverer при этом снимается со стека — его работу errorHandler берёт на себя, только с ответом в формате API.
apiError, fail и errorHandler со стеком
GOtype apiError struct {
status int // HTTP-статус ответа
code string // машинный код: "not_found"
message string // текст для человека
}
func fail(status int, code, msg string) *apiError {
return &apiError{status: status, code: code, message: msg}
}
func errorHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
rec := recover()
if rec == nil {
return
}
if apiErr, ok := rec.(*apiError); ok {
renderError(w, r, apiErr.status, apiErr.code, apiErr.message)
return
}
if rec != http.ErrAbortHandler {
debug.PrintStack()
renderError(w, r, 500, "internal", "внутренняя ошибка")
return
}
panic(rec)
}()
next.ServeHTTP(w, r)
})
}
// стек на роутере:
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(errorHandler)
r.Use(middleware.Logger)
// вызов в хендлере:
n, ok := notes[id]
if !ok {
panic(fail(http.StatusNotFound, "not_found", "заметки нет"))
}Тип apiError несёт всё для ответа: статус, машинный код и текст. fail — конструктор, чтобы вызов паники читался как предложение. errorHandler обёрнут в http.HandlerFunc по образцу любого middleware из урока про Use. recover() достаёт значение паники, type assertion в ok-форме различает свою ошибку и чужую. ErrAbortHandler пробрасывается дальше: net/http ждёт его для обрыва соединения. debug.PrintStack печатает стек в stderr — то, что Recoverer делал через PrintPrettyStack. chi.Use(errorHandler) стоит до Logger — его ответы попадают в строку лога с честным статусом.
Статусы: 400, 422, 404, 405 и остальные#
Статус-код и code — два уровня одного сообщения: код несёт класс, код-строка — точную причину. 400 Bad Request — синтаксический мусор: битый JSON, тело не декодируется. 401 Unauthorized — нет или просрочен токен; 403 Forbidden — токен есть, прав нет. 404 Not Found — ресурса не существует, и сюда же попадает роутинг через r.NotFound. 405 Method Not Allowed — путь есть, метод не тот. 409 Conflict — конфликт состояния: заметка уже существует, версия устарела. 422 Unprocessable Entity — тело синтаксически валидно, но поля не прошли проверку: пустой title, отрицательная цена. 500 — наша вина: паника или упавшая зависимость. Живое место из начала урока как раз про 422: статус был честный, а формат — нет. Споры «422 против 400 на валидации» в командах вечны — выбирайте один вариант один раз и записывайте его в таблицу-контракт ниже: клиент должен знать ответ заранее, а не угадывать по релизным заметкам.
Что увидит клиент?
Прочитайте код и выберите, что он выведет:
func broken(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
renderError(w, r, http.StatusUnauthorized, "unauthorized", "нет токена")
}NotFound и MethodNotAllowed: свои 404 и 405#
По умолчанию chi отвечает на несуществующий маршрут через http.NotFound: статус 404, тело «404 page not found», Content-Type text/plain; charset=utf-8 и заголовок X-Content-Type-Options: nosniff. Для curl терпимо, для фронтенда — тот же сломанный res.json(), что и в истории в начале урока. Не лучше выглядит 405: в исходниках chi v5 дефолтный хендлер — две строки, WriteHeader(405) и w.Write(nil), то есть пустое тело и даже без заголовка Allow, который RFC 7231 рекомендует заполнять списком разрешённых методов. Обе точки подменяются на роутере: r.NotFound(...) и r.MethodNotAllowed(...) принимают обычный func(w, r). Деталь из устройства chi: эти хендлеры вызываются внутри routeHTTP, а вся цепочка middleware оборачивает именно routeHTTP — значит, NotFound и MethodNotAllowed выполняются после всех слоёв: Logger залогирует статус, а в контексте лежит RequestID. Подмена — три строки, и системные ответы говорят тем же языком, что и ваши.
Подмена системных ответов роутера
GOr.NotFound(func(w http.ResponseWriter, r *http.Request) {
renderError(w, r, http.StatusNotFound, "not_found",
"маршрут не существует: "+r.URL.Path)
})
r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Allow", "GET, POST") // какие методы путь разрешает
renderError(w, r, http.StatusMethodNotAllowed, "method_not_allowed",
"метод не разрешён: "+r.Method)
})Оба хендлера вызывают тот же renderError — единый формат дотягивается до системных ответов chi. Allow в 405 — одна строка поверх: её читают клиентские библиотеки и генераторы документации. В message включены путь и метод — на отладке экономит минуты, парсить его по-прежнему нельзя.
Проверка: метод не разрешён
Маршрут GET /notes/{id} зарегистрирован. Клиент отправил DELETE /notes/5. В стеке нет r.MethodNotAllowed. Что ответит chi?
Ситуация → статус → code → кто вызывает#
| Ситуация | Статус | code | Кто вызывает |
|---|---|---|---|
| Битый JSON в теле запроса | 400 Bad Request | invalid_json | readJSON в хендлере |
| Поля не прошли валидацию | 422 Unprocessable Entity | validation_failed | валидация в хендлере |
| Заметки с таким id нет | 404 Not Found | not_found | хендлер: panic(fail(...)) |
| Маршрута не существует | 404 Not Found | not_found | r.NotFound |
| Метод не разрешён для пути | 405 Method Not Allowed | method_not_allowed | r.MethodNotAllowed |
| Паника в хендлере | 500 Internal Server Error | internal | errorHandler: recover |
Если в стеке остаётся стандартный Recoverer, помните его поведение из исходников: WriteHeader(500) без тела и без Content-Type. Клиент получит пустой ответ со статусом 500 — это всё ещё не ваш JSON, и фронтенд упадёт на res.json(). Два выхода: снять Recoverer и отдать паники errorHandler-у, как в этом уроке, или оставить его снаружи и поставить errorHandler ниже по цепочке — тогда *apiError перехватывается раньше, а настоящие паники всплывают к Recoverer. Проверьте ветку паники curl-ом на локальном Go: песочница сеть не поднимает.
Классический спор: ошибки строкой («заметки нет») против кодов (not_found). Тексты дешевле на старте и приятнее в логах, но они меняются, переводятся и не могут быть ключом логики — фронтенд, сравнивающий строку ошибки, ломается на первой же редактуре. Мой выбор: code для машины, message для человека, и никогда наоборот. Тексты оправданы, пока API только для вас и на месяц — ровно до появления второго потребителя ваших ошибок.
Согласованность: клиент парсит один формат#
Проверка согласованности выглядит со стороны фронтенда так: обработчик на все не-2xx читает resp.json(), ветвит по e.error.code — unauthorized отправляет на логин, validation_failed подсвечивает поле, rate_limited показывает таймер — и показывает e.error.message в тосте. Один обработчик, одна структура, ноль сюрпризов. Хрупкое место — любая ветка, отвечающая не в формате: текстовый вывод в body, пустое 405, text/plain 404 — res.json() падает до того, как код доберётся до ветвления. Поэтому контракт закрепляется тестом: на каждый статус проверяются код ответа, Content-Type и наличие error.code. Полезное дополнение — request_id из middleware.GetReqID(r.Context()) в теле ошибки: пользователь присылает id из тоста, вы находите запрос в логе за секунды. Связь «ошибка и строка лога» — то, за что поддержка скажет спасибо, а chi уже положил id в контекст: осталось только достать.
Гигиена централизованных ошибок#
- Формат один: {"error": {"code", "message"}} — для хендлеров, 404, 405 и 500; таблица «ситуация → статус → code» — часть контракта API.
- renderError ставит Content-Type до WriteHeader; вызов — в месте ошибки или в errorHandler, никогда после записи успеха.
- apiError + panic(fail(...)) + errorHandler с recover(): свой error-канал поверх http.HandlerFunc; ErrAbortHandler — пропускать.
- r.NotFound и r.MethodNotAllowed подменяют системные ответы chi: «404 page not found» и пустое 405 не проходят в продакшен.
- code — для машины и ветвления, message — для человека и тоста; request_id в теле сшивает ошибку с логом.
- Каждый статус закреплён тестом: статус-код, Content-Type и наличие error.code проверяются вместе.
Соберите renderError и подмену 404
Восстановите три опоры: заголовок, статус и подмену системного 404.
Соберите renderError по строкам
Расставьте строки helper в правильном порядке: от сигнатуры до закрытия функции.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: централизованные ошибки chi
Каркас заговорил одним языком: хендлеры возвращают ошибки через fail и panic, errorHandler рендерит их в единый JSON, системные 404 и 405 подменены на роутере, паники превращаются в честный 500 с code internal. Проверьте всё локальным Go: curl -i на несуществующий путь, на DELETE по GET-маршруту и на /boom с panic(fail(...)) — каждый ответ обязан быть application/json с code и message. Остался последний источник «сломанного» ответа — само выключение сервиса: на деплое kill обрывает обрабатываемые запросы, и graceful shutdown договаривается с сигналами о вежливом завершении. Полная карта уроков — на странице курса chi.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Ошибки API — один JSON-формат {"error": {"code", "message"}} на все хендлеры, 404, 405 и 500; клиент парсит одну структуру.
- renderError ставит Content-Type до WriteHeader и вызывается в месте ошибки — никогда после записи успеха.
- Канала error у http.HandlerFunc нет: helper на месте, паника fail(...) с перехватом в errorHandler или адаптер над хендлером.
- errorHandler в defer перехватывает *apiError и настоящие паники: стек в лог, ответ 500 в формате API, ErrAbortHandler — на пропуск.
- Дефолтный 404 chi — «404 page not found» text/plain, 405 — пустое тело; r.NotFound и r.MethodNotAllowed подменяют оба.
- Код ошибки — для машины, message — для человека; request_id в теле ошибки сшивает ответ с логом.
- 400 — битый синтаксис, 422 — валидация полей, 404/405 — роутинг, 500 — паника; таблица статусов — часть контракта API.
Домашнее задание
Переведите каркас заметок на централизованные ошибки: renderError, errorHandler со стеком RequestID → RealIP → errorHandler → Logger, r.NotFound и r.MethodNotAllowed. Пустой title отвечайте 422 с code validation_failed, несуществующую заметку — 404 через panic(fail). Прогоните curl -i все ветки: каждая ошибка — application/json с code и message.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему нельзя просто вернуть error из хендлера, как в Fiber?
У http.HandlerFunc сигнатура без возвращаемого значения — канала для ошибки нет. Варианты: renderError на месте и return, паника значением *apiError с перехватом в errorHandler, либо свой адаптер над функцией, возвращающей ошибку. Fiber-овский путь работает только потому, что там хендлеры другого типа.
Что вернёт chi на несуществующий маршрут по умолчанию?
http.NotFound: статус 404, тело «404 page not found», Content-Type text/plain и заголовок nosniff. Для API с JSON-контрактом это подменяется через r.NotFound — иначе фронтенд на 404 получит текст вместо ожидаемого JSON и упадёт на res.json() раньше ветвления по кодам.
Паника в хендлере тоже попадёт в единый формат?
Со стандартным Recoverer — нет: он отдаёт 500 с пустым телом и без Content-Type, что видно в исходниках middleware. В уроке errorHandler заменяет его: печатает стек через debug.PrintStack, отвечает 500 с code internal. Оставляете Recoverer — ставьте errorHandler ниже по цепочке, чтобы перехватывать первым.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Graceful shutdown: выключаемся вежливо
- Валидация параметров и тел запросов
- Порядок middleware: почему он важен
- Subrouters и структура большого сервиса
- CORS: чиним браузерные запросы
- Timeout и Throttle: защита от перегрузки
- Собственный middleware: таймер, лог, авторизация
- JSON API: парсим и отдаём JSON
- Все курсы по Go
Следующая ступень учебного пути
Redis в Go: кэш, сессии и очереди на go-redis
Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.
- Подключаться к Redis из Go и настраивать пул соединений
- Кэшировать результаты запросов и придумывать TTL
- Работать с хэшами, списками, множествами и ZSET
- Строить сессии, счётчики, лидерборды и rate limiter