---
title: "Централизованные ошибки: errorHandler и NotFound"
description: "Централизованные ошибки chi: JSON-формат error.code/message, renderError, errorHandler, свои NotFound и MethodNotAllowed, таблица «ситуация → статус → code»."
course: "Chi: роутер для Go HTTP-сервисов"
url: "https://golangmaster.ru/courses/chi/lessons/15-error-handling/"
published: "2026-09-19"
updated: "2026-09-27"
minutes: 18
---

# Централизованные ошибки: errorHandler и NotFound

*Курс «Chi: роутер для Go HTTP-сервисов», урок 15 из 25. ~18 минут. Обновлено 27.09.2026. Источник: https://golangmaster.ru/courses/chi/lessons/15-error-handling/*

Централизованные ошибки chi превращают разнобой хендлеров в один контракт: любой провал — от битого JSON до паники — отвечает {"error": {"code", "message"}}. Разберём error handling в go на практике: helper renderError, middleware errorHandler на defer/recover, подмену системных 404 и 405 и таблицу «ситуация → статус → code».

Каркас заметок из [урока про JSON API](/courses/chi/lessons/11-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: одна функция пишет все ошибки

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

- ✅ У http.HandlerFunc сигнатура без возвращаемого значения: канала для ошибки нет, её надо рендерить или передавать особым образом
- Можно: net/http сам поймает возвращённую ошибку и отправит 500
- Можно, но только в middleware: chi добавляет error-канал слоям
- Нельзя из-за defer: return err в хендлере запрещён

http.HandlerFunc — это func(http.ResponseWriter, *http.Request): ни error, ни любых других возвращаемых значений. Fiber-овский подход работает потому, что там хендлеры другого типа с error в сигнатуре. В chi ошибку отдают renderError-ом на месте, паникой значением *apiError с перехватом в middleware или адаптером-обёрткой над собственной функцией.

### errorHandler: где ошибка превращается в ответ

Канала ошибки нет — значит, его эмулируют. Первый путь — renderError в месте и return: простой, но разбросан по хендлерам. Второй — свой тип apiError и middleware-обработчик: хендлер передаёт ошибку паникой значением этого типа, а errorHandler ловит её в defer через recover(). Паника как управление потоком — приём спорный, но у него есть честное оправдание: механизм уже есть, работает сквозь все слои и не требует менять сигнатуры хендлеров. Критично не перепутать свою ошибку с настоящей паникой: errorHandler перехватывает только *apiError, чужое значение обрабатывает отдельно — стек в журнал через debug.PrintStack и 500 с code internal. http.ErrAbortHandler — служебная паника, которой net/http рвёт соединение, — пропускается, ровно как это делает Recoverer из [урока про встроенные middleware](/courses/chi/lessons/06-vstroennye-middleware/). Слой ставится в начале стека, сразу после RequestID и RealIP и до Logger: каждая его ошибка проходит через обёртку лога, и строка показывает честный 404, а не 200. Стандартный Recoverer при этом снимается со стека — его работу errorHandler берёт на себя, только с ответом в формате API.

### apiError, fail и errorHandler со стеком

```go
type 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 на валидации» в командах вечны — выбирайте один вариант один раз и записывайте его в таблицу-контракт ниже: клиент должен знать ответ заранее, а не угадывать по релизным заметкам.

### Что увидит клиент?

```go
func broken(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("ok"))
	renderError(w, r, http.StatusUnauthorized, "unauthorized", "нет токена")
}
```

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

- 401 Unauthorized и JSON ошибки: renderError перезапишет статус
- ✅ 200 OK, тело «ok», а следом допишется JSON ошибки — статус заморожен первой записью
- 500 Internal Server Error: двойной WriteHeader роняет запрос
- 200 OK и только «ok»: renderError не выполнится после первой записи

Первая запись в w уже отправила 200 и заголовки: с этого момента Header().Set и WriteHeader меняют карту, которую никто не отправит, а второй WriteHeader даёт строку superfluous response.WriteHeader в лог. Encode из renderError допишет JSON-объект следом за «ok» — документ из двух кусков. Отсюда дисциплина: renderError вызывается в начале ветки ошибки или сразу перед return — никогда после записи успеха.

### 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. Подмена — три строки, и системные ответы говорят тем же языком, что и ваши.

### Подмена системных ответов роутера

```go
r.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?

- 404 «page not found»: путь не найден для метода DELETE
- ✅ 405 Method Not Allowed с пустым телом
- 405 Method Not Allowed с телом «method not allowed»
- 501 Not Implemented: метод роутеру неизвестен

Путь совпал, метод не разрешён — chi отдаёт 405. Дефолтный хендлер из исходников v5.0.8 — WriteHeader(405) и w.Write(nil): тело пустое, Allow не выставляется. Поэтому 405 тоже подменяем: клиент должен получить один формат даже там, где 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 |

> **500 от Recoverer — вне формата.** Если в стеке остаётся стандартный 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.

```go
func renderError(w http.ResponseWriter, r *http.Request, status int, code, msg string) {
	w.Header().Set("___", "application/json")
	w.WriteHeader(___)
	json.NewEncoder(w).Encode(map[string]any{"error": map[string]string{"code": code, "message": msg}})
}

// роутер:
r.___(func(w http.ResponseWriter, r *http.Request) {
	renderError(w, r, http.StatusNotFound, "not_found", "маршрут не существует")
})
```

Подсказка: Заголовок для JSON — Content-Type, статус — параметр функции, а подменяет системный 404 метод r.NotFound.

Правильные ответы в пропусках: `Content-Type`, `status`, `NotFound`

### Соберите renderError по строкам

Расставьте строки helper в правильном порядке: от сигнатуры до закрытия функции.

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

1. `func renderError(w http.ResponseWriter, r *http.Request, status int, code, msg string) {`
2. `w.Header().Set("Content-Type", "application/json")`
3. `w.WriteHeader(status)`
4. `json.NewEncoder(w).Encode(map[string]any{"error": map[string]string{"code": code, "message": msg}})`
5. `log.Printf("%s %s -> %d %s", r.Method, r.URL.Path, status, code)`
6. `}`

### Карточки: централизованные ошибки chi

- **Единый формат ошибок** — {"error": {"code": "not_found", "message": "..."}} на весь API с Content-Type application/json. Обёртка error оставляет место для details и request_id без ломки клиентов.
- **renderError(w, r, status, code, msg)** — Helper: Set Content-Type → WriteHeader → Encode. Вызывается в месте ошибки перед return; после записи успеха статус уже не изменить — predict урока.
- **http.HandlerFunc не возвращает error** — Канала нет: renderError на месте, паника *apiError с перехватом в errorHandler, либо адаптер-обёртка над функцией, возвращающей ошибку.
- **r.NotFound / r.MethodNotAllowed** — Подмена системных ответов chi: дефолт 404 — «404 page not found» text/plain, дефолт 405 — WriteHeader(405) с пустым телом. Оба выполняются внутри всей цепочки middleware.
- **422 против 400** — 400 — синтаксический мусор: битый JSON, тело не декодируется. 422 — тело валидно, но поля не прошли проверку. Оба — в одном формате с разными code.
- **Код против message** — code ("not_found") — для машины: ветвит логику фронтенда. message — для человека: показывается в тосте. Парсить message нельзя — текст меняется вместе с формулировками.

Каркас заговорил одним языком: хендлеры возвращают ошибки через fail и panic, errorHandler рендерит их в единый JSON, системные 404 и 405 подменены на роутере, паники превращаются в честный 500 с code internal. Проверьте всё локальным Go: curl -i на несуществующий путь, на DELETE по GET-маршруту и на /boom с panic(fail(...)) — каждый ответ обязан быть application/json с code и message. Остался последний источник «сломанного» ответа — само выключение сервиса: на деплое kill обрывает обрабатываемые запросы, и [graceful shutdown](/courses/chi/lessons/16-graceful/) договаривается с сигналами о вежливом завершении. Полная карта уроков — на [странице курса chi](/courses/chi/).

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

- Ошибки 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 ниже по цепочке, чтобы перехватывать первым.
