Содержание урока · 15 разделов
- fiber обработка ошибок: третий выход из хендлера
- ErrorHandler: поле конфига и его тип
- Разбор дефолта: 500, text/plain и правило сообщения
- *fiber.Error: Message, Code и fiber.NewError
- err := c.Next(): откуда ошибки приходят в ErrorHandler
- Свои типы ошибок: ValidationError и sentinel
- API или HTML: единая схема на два мира
- Единый формат ответа: {error, code}
- Откуда ошибка приходит в ErrorHandler
- Ловушки обработки ошибок: симптом, причина, лечение
- Смонтированные приложения и чужие 404-е
- Запуск, пять сценариев curl и что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
fiber обработка ошибок: третий выход из хендлера#
В прошлом уроке был момент, который мы тогда честно отложили. Predict спрашивал, что ответит сервер на несуществующий layout, и правильным оказался вариант «500 с текстом failed to render: render: LayoutName ... does not exist» — голый машинный текст вместо страницы. В объяснении мы обещали: следующий урок научит рисовать вместо этого красивую страницу «что-то сломалось» в том же каркасе layouts/main и отдавать API честные JSON-ошибки. Обещание исполняется сегодня, и fiber обработка ошибок начинается с вопроса, который стоит задать себе прямо сейчас: куда вообще девается error, который я возвращаю из хендлера?
Вспомните подпись, которую вы пишете в каждом маршруте четырнадцатого урока не задумываясь: app.Get("/tasks", func(c fiber.Ctx) error). У хендлера два выхода. Первый — nil: ответ уже записан в контекст вызовами c.JSON, c.SendString, c.Render, и роутеру больше ничего не нужно. Второй — ненулевой error: ответ, возможно, ещё не записан, а объект ошибки несёт в себе всё, что известно о сбое. До сих пор наши хендлеры были оптимистами: в девятом уроке c.JSON возвращал ошибку маршалинга, и мы её честно возвращали дальше — и всё, дальше её забирал кто-то невидимый. Этот «кто-то» и есть герой урока: единая точка, в которую стекают ошибки всего приложения.
Почему единая точка — правильная архитектура, а не удобство? Потому что альтернативы хуже. Обрабатывать ошибки в каждом хендлере отдельно — значит копипастить один и тот же if в сорок мест, и в сорок первом забыть. Пропускать наверх «как есть» — значит показывать клиенту внутренности приложения: пути, SQL, версии. А одна точка входа для ошибок даёт одно место, где принимаются решения про статус, формат, логирование и то, сколько правды можно показать снаружи. В Fiber эта точка настраивается одним полем конфига, и к нему мы сейчас идём.
Канон курса не меняется: тот же инстанс на :8080, middleware helmet, cors и logger из шестого урока, сессии и страница задач из тринадцатого. Добавится один блок в fiber.Config — и поведение всего приложения при любом сбое поменяется разом.
ErrorHandler: поле конфига и его тип#
Откроем исходники Fiber v3.5.0, файл app.go. Тип объявлен там как алиас — со знаком равенства, а не как новый тип: type ErrorHandler = func(Ctx, error) error. Алиас значит, что ErrorHandler — это просто удобное имя для подписи «принимает контекст и ошибку, возвращает ошибку»; никакого нового типа с методами здесь нет, и функция с подходящей подписью влезает в поле напрямую. Комментарий в исходниках формулирует назначение одной строкой: ErrorHandler defines a function that will process all errors returned from any handlers in the stack — «обрабатывает все ошибки, возвращённые любыми хендлерами стека». Стек — это вся цепочка: middleware, которые вы повесили через app.Use, и хендлер конкретного маршрута; ошибка, всплывшая из любой точки этой лестницы, приходит сюда.
Поле в конфиге объявлено рядом с остальными настройками, и док-комментарий к нему обещает ровно то, что мы обсуждали: ErrorHandler is executed when an error is returned from fiber.Handler. Default: DefaultErrorHandler. То есть по умолчанию поле заполнено framework-овой функцией, а вы её подменяете целиком — промежуточных режимов нет: либо дефолт, либо ваш обработчик со всеми его обязанностями.
Разберём подпись по трём позициям, потому что каждая несёт смысл. Первый аргумент — c Ctx: контекст запроса в момент сбоя. Он жив, и через него можно писать ответ: поставить статус, отдать JSON, отрендерить страницу. Именно поэтому ErrorHandler умеет не только логировать, но и отвечать клиенту — контекст ему для этого передан. Второй аргумент — err error: та самая ошибка, что всплыла из стека; здесь она единственный источник информации о том, что случилось. Возвращаемое значение — тоже error, и это самая хитрая позиция: это ошибка самого обработчика ошибок. Если ваша функция упала — не смогла отрендерить страницу, отдала ошибку маршалинга, — Fiber сделает последнюю отчаянную попытку: отправит клиенту голый статус 500 без тела. Мы вернёмся к этому в блоке про ловушки.
И последняя деталь механики: при старте приложения, в init-проходе по конфигу, срабатывает guard в духе if app.config.ErrorHandler == nil { app.config.ErrorHandler = DefaultErrorHandler } — пустое поле заполняется дефолтом. Отсюда практический вывод: nil в этом поле нельзя использовать как «выключить обработку» — nil превращается в дефолт, а не в отсутствие поведения.
Дефолтный ErrorHandler: исходник из app.go
GO// DefaultErrorHandler that process return errors from handlers
// (исходник app.go, тег v3.5.0; проверка nilerror.IsNil опущена)
func DefaultErrorHandler(c Ctx, err error) error {
code := StatusInternalServerError
// прямой *Error ловится type assertion без аллокаций,
// обёрнутые и слитые (joined) ошибки — через errors.As
e, matched := asFiberError(err)
if matched && e != nil {
code = e.Code
}
// сообщение: сначала текст статуса, потом — сама ошибка
message := utils.StatusMessage(code)
if err != nil && (!matched || e != nil) {
message = err.Error()
}
c.Set(HeaderContentType, MIMETextPlainCharsetUTF8)
return c.Status(code).SendString(message)
}
// asFiberError: helper из app.go, который узнаёт *fiber.Error
// в цепочке обёрток (%w, errors.Join)
func asFiberError(err error) (*Error, bool) {
if e, ok := err.(*Error); ok { // быстрый путь
return e, true
}
var target *Error // медленный путь: разворачиваем цепочку
matched := errors.As(err, &target)
return target, matched
}Это не учебная имитация, а воспроизведение реального исходника — с одной честной оговоркой: проверка nilerror.IsNil(err) в начале функции здесь опущена, она гасит особый кейс «не-nil-обёртка вокруг nil». Вся логика помещается в четыре шага. Шаг первый: стартовый код — StatusInternalServerError, 500; ничего лучшего framework знать не может, пока ошибка сама не скажет свой статус. Шаг второй: asFiberError пытается вытянуть из ошибки *fiber.Error — сначала простым type assertion (дёшево, без аллокаций), а если ошибка обёрнута в fmt.Errorf с %w или склеена errors.Join — то errors.As, который разворачивает цепочку Unwrap. Шаг третий: сборка сообщения — про него отдельный блок ниже, потому что там спрятана самая важная строка дефолта. Шаг четвёртый: Content-Type ставится в MIMETextPlainCharsetUTF8 — это константа "text/plain; charset=utf-8" из constants.go, — и ответ уходит через c.Status(code).SendString(message). Заметьте, дефолт возвращает наружу nil в нормальном случае: роутер видит nil и больше ничего не делает. Асимметрия намеренная: ошибки, которые вернёт ваша собственная версия обработчика, обрабатываются хуже — вплоть до голого статуса без тела.
Разбор дефолта: 500, text/plain и правило сообщения#
Теперь медленно, потому что здесь три факта, которые студенты регулярно путают на собеседованиях.
Факт первый: статус. Дефолт стартует с 500 и меняет его только одним способом — если в ошибке найден *fiber.Error, берётся её поле Code. Обычная errors.New с любым текстом всегда даст 500: у обычной ошибки просто нет поля «статус», и framework не станет гадать, то ли это сбой базы (500), то ли клиент прислал мусор (400).
Факт второй: Content-Type — text/plain; charset=utf-8, и это важнее, чем кажется. Дефолтный ответ — не JSON. Клиент, который ждёт конверт {"error": ...} и наивно парсит тело как JSON, упадёт на синтаксисе при любой ошибке, пришедшей из дефолта. Единый формат — это не то, что framework даёт бесплатно; это то, что вы строите сами, и мы строим его ниже.
Факт третий: правило сообщения — самое тонкое место. Читаем строки исходника по порядку: message := utils.StatusMessage(code) — сначала сообщение берётся из таблицы статусных текстов, это функция пакета github.com/gofiber/utils/v2; для 500 она вернёт "Internal Server Error", для 404 — "Not Found", а для кода вне диапазона 100–599 — пустую строку. Затем if err != nil && (!matched || e != nil) { message = err.Error() } — и вот здесь сообщение перекрывается текстом самой ошибки. Логика читается так: если ошибка не является fiber.Error (matched == false) — берём err.Error() целиком; если является fiber.Error — тоже берём err.Error(), а он у этого типа возвращает поле Message (метод Error() из app.go: func (e Error) Error() string { return e.Message }). Пустое сообщение остаётся только в вырожденном случае, когда fiber.Error найдена, но нулевая. Итого — запомните как таблицу умножения: обычная ошибка → 500 и текст err.Error(); fiber.NewError(404, "задача не найдена") → 404 и тело "задача не найдена"; fiber.NewError(404) без сообщения → 404 и тело "Not Found".
Практическое следствие, о котором редко думают заранее: раз в дефолте err.Error() уезжает клиенту как есть, то текст ошибки — это публичный интерфейс. fmt.Errorf("dial tcp 10.0.0.5:5432: connect refused") из недр драйвера базы, попав в хендлер и вернувшись наверх, будет показан каждому пользователю с адресом внутреннего хоста. Дефолт честен, но не параноидален; вашу паранойю и добавляет собственный ErrorHandler.
Проверьте себя: дефолт против errors.New
В хендлере строка return errors.New("база недоступна"), ErrorHandler в конфиге не настроен. Что именно получит клиент?
*fiber.Error: Message, Code и fiber.NewError#
Тип, который дефолт умеет распознавать, объявлен в том же app.go и умещается в шесть строк: type Error struct { Message string; Code int } — с json-тегами message и code. Это обычная структура, которая реализует интерфейс error одним методом: Error() string возвращает e.Message. Запомните пару «сообщение впереди, код сзади» — и то, что в JSON она сериализуется как {"message":..., "code":...}; этот порядок слов нам ещё пригодится, когда будем строить единый формат.
Создавать такие ошибки руками — struct literal — можно, но незачем: в app.go есть конструктор fiber.NewError(code int, message ...string) Error. Variadic message — это синтаксический трюк с поведением: вызов без сообщения заполняет поле из таблицы статусных текстов, NewError(404).Message == "Not Found"; вызов с сообщением перекрывает его, NewError(404, "задача не найдена").Message == "задача не найдена". В v3 к нему добавился форматный собрат NewErrorf(code int, message ...any) — работает как fmt.Sprintf: NewErrorf(404, "задача %d не найдена", id). Оба возвращают Error, которую вы просто возвращаете из хендлера — return fiber.NewError(fiber.StatusNotFound, "задача не найдена"). fiber.NewError уже мелькал в шестом и седьмом уроках: в шестом middleware прерывал цепочку через fiber.NewError(fiber.StatusUnauthorized, ...), и ошибка улетала в ErrorHandler приложения; седьмой урок прямо обещал вернуться к ErrorHandler «в уроке про обработку ошибок» — мы и есть тот урок, а страж requireAPIKey("s3cret"), писавший ответ сам, был обходным путём мимо сегодняшней темы.
В constants.go лежит набор предопределённых ошибок, собранных тем же конструктором: ErrNotFound = NewError(StatusNotFound) с комментарием // 404, ErrMethodNotAllowed для 405, ErrBadRequest, ErrUnauthorized, ErrTeapot для 418 — да, RFC 9110 про «I'm a teapot» в Fiber помнят. Есть и прикладные: ErrRedirectBackNoFallback для редиректов и семейство range-ошибок; у ErrRangeMalformed док-комментарий — маленький учебник по дизайну статусов: carries a 400 Bad Request status so propagating it does not surface as a 500 — код выбран заранее, чтобы ошибка, всплывшая наверх, не превратилась в пятисотую.
И важный поворот, который меняет представление о 404: ошибки «маршрут не найден» и «метод не разрешён» тоже проходят через ErrorHandler. Когда роутер не нашёл совпадение, он не пишет ответ сам, а вызывает обработчик с ErrNotFound (для 405 — с ErrMethodNotAllowed и заголовком Allow, собранным из доступных методов). Док-комментарий конфига прямо говорит: Customize the 404/405 responses via ErrorHandler. То есть даже страница «404 — ничего нет» — это работа вашего кода: хотите красивую 404-ю в каркасе сайта — рисуйте её в ErrorHandler, отдельного хука для этого в Fiber нет.
Что увидит клиент после NewError(418)?
Прочитайте код и выберите, что он выведет:
// ErrorHandler в конфиге НЕ настроен — работает дефолт.
app.Get("/brew", func(c fiber.Ctx) error {
return fiber.NewError(fiber.StatusTeapot, "Я чайник")
})err := c.Next(): откуда ошибки приходят в ErrorHandler#
Мы знаем, что делает ErrorHandler, но пока не выяснили, откуда у него берётся err. Ответ в роутере — файл router.go: после того как цепочка отработала, там стоят четыре строки, которые и есть вся «магия»: _, err := app.next(ctx); if err != nil { if catch := ctx.App().ErrorHandler(ctx, err); catch != nil { _ = ctx.SendStatus(StatusInternalServerError) } }. Никакого отдельного канала, каналов событий или подписок нет: app.next — это проход по цепочке middleware и хендлера, и первая ненулевая ошибка разворачивает лестницу обратно. Как именно ошибка путешествует по цепочке, вы уже знаете из шестого урока: каждый middleware — это хендлер, который вызывает err := c.Next() и возвращает результат; вернуть ошибку наверх — единственный способ передать её дальше по стеку.
Из этого следует два неприятных и важных свойства. Первое: любой middleware может перехватить ошибку раньше ErrorHandler. Если ваш логгер-обёртка написал err в лог и вернул nil — для роутера ошибки не было, ErrorHandler не вызовется, а клиент получит что? Правильно: ничего — ответ не записан, статус нулевой. Перехватывать ошибку в middleware можно только с полной заменой ответа. Второе: ErrorHandler — не middleware. У него нет c.Next(), он не стоит в цепочке и не может «пропустить запрос дальше»: он вызывается роутером один раз, после того как цепочка закончилась ошибкой, и его работа — один раз сформировать ответ. Если вы попробуете вызвать c.Next() внутри ErrorHandler, вы получите ровно ничего полезного — цепочка к этому моменту уже пройдена.
Отдельная тема — паники, и с ними всё наоборот: в ErrorHandler они не попадают, потому что паника — не возвращаемое значение. Панику ловит recover-middleware из шестого урока — тот самый, который советуют ставить первым в цепочке. Его DefaultPanicHandler превращает пойманное значение в error: если под паникой был error, вернёт его, иначе — fmt.Errorf("%v", r), и дальше паника-в-ошибке идёт уже знакомой дорогой в ErrorHandler. Без recover-middleware паника долетает до fasthttp-сервера и гасится на уровне соединения: клиент получает голый 500, ваш ErrorHandler — в стороне. Отсюда порядок, который стоит выучить: recover первым, ErrorHandler последним — первый ловит катастрофы, второй оформляет отказы.
И последний приток: сетевые сбои самого сервера. Обёртка serverErrorHandler в app.go переводит несколько ошибок fasthttp и net — например *net.OpError — в fiber-ошибки до вызова ErrorHandler, чтобы и они попали в тот же конвейер. Говоря коротко: что бы ни упало — решение об ответе всегда принимает одна функция.
Свои типы ошибок: ValidationError и sentinel#
*fiber.Error хороша ровно двумя полями — строкой и числом, — и на этом её выразительность кончается. Реальным приложениям нужно больше: валидация хочет сказать, какие поля формы не прошли проверку; репозиторий хочет различать «нет такой записи» и «база лежит»; доменный слой хочет нести коды бизнес-правил. Стандартная библиотека Go даёт для этого всё необходимое, и этот материал вы уже видели в пятнадцатом уроке курса go-advanced — «Обработка ошибок: wrapping»; здесь мы его применяем к веб-серверу, так что повторим только рабочую часть.
Интерфейс error — это один метод Error() string. Любой ваш тип с этим методом — полноценная ошибка, которую можно вернуть из хендлера, и она дойдёт до ErrorHandler не изменённой. Дальше — два паттерна. Первый: sentinel, именованная ошибка-константа — var ErrTaskNotFound = errors.New("задача не найдена"). Её сравнивают через errors.Is: Is идёт по цепочке Unwrap и сверяет указатели, поэтому sentinel узнаётся даже под тремя слоями fmt.Errorf("...: %w", ...). Второй паттерн: типизированная ошибка — type ValidationError struct { Field string } с методом Error(). Её распознают через errors.As: As тоже разворачивает цепочку, но ищет не указатель, а тип, и, найдя, подкладывает значение в target — после чего вы читаете e.Field.
Почему в ErrorHandler нужны обе ветки, а не только fiber.Error? Потому что слой приложения не должен знать про HTTP. Функция findTask в домене возвращает fmt.Errorf("хранилище задач: %w", ErrTaskNotFound) — ни слова про 404, веб-сервера в этом коде нет. Перевод «нет записи → статус 404» — это работа транспортного слоя, и место этому переводу ровно одно: в ErrorHandler. Там вы собираете лестницу распознавания: errors.As на fiber.Error — framework-овые отказы; errors.Is на ErrTaskNotFound — доменный sentinel; errors.As на *ValidationError — валидация. Каждая ветка назначает статус, а дальше — единый формат ответа, который мы построим в следующем блоке.
И одно архитектурное правило, которое экономит недели: не возвращайте *fiber.Error из домена и репозиториев. Как только NewError появляется в слое ниже хендлеров, слой срастается с transport-ом: тесты домена требуют fiber, переиспользовать логику в воркере или CLI нельзя. Домен — свои типы и sentinel-ы; ErrorHandler — лестница As/Is и статусы. Граница чистая, и она проходит ровно там, где начинается HTTP.
Ошибки домена: тип, sentinel, обёртывание
GOpackage main
import (
"errors"
"fmt"
)
// ValidationError — типизированная ошибка валидации:
// несёт поле, которое не прошло проверку
type ValidationError struct {
Field string
}
// Error делает тип совместимым с интерфейсом error
func (e *ValidationError) Error() string {
return "поле " + e.Field + " заполнено неверно"
}
// ErrTaskNotFound — sentinel-ошибка домена: сравнивается через errors.Is
var ErrTaskNotFound = errors.New("задача не найдена")
// findTask — домен не знает про HTTP: только sentinel и обёртки
func findTask(id int) (string, error) {
// %w сохраняет цепочку: errors.Is и errors.As её развернут
return "", fmt.Errorf("хранилище задач: %w", ErrTaskNotFound)
}
// validate — валидация возвращает свой тип, не строку и не fiber.NewError
func validate(title string) error {
if title == "" {
return &ValidationError{Field: "title"}
}
return nil
}Три источника ошибок домена — и все три попадут в ErrorHandler в неизменном виде. ValidationError — тип с полем Field: когда errors.As найдёт его в ErrorHandler, вы сможете не только назначить статус 400, но и положить имя поля в ответ, чтобы фронтенд подсветил нужный input. ErrTaskNotFound — sentinel: единственный экземпляр, с которым errors.Is сверяет указатели; обратите внимание, что findTask возвращает не сам sentinel, а обёртку fmt.Errorf с %w — при этом Is всё равно найдёт его в цепочке, а err.Error() на выходе даст честный текст «хранилище задач: задача не найдена». Функция validate возвращает указатель на ValidationError — приём с указателем не случаен: errors.As работает с типами-указателями корректно и без лишних сюрпризов, а nil-указатель интерфейса тут не возникает, потому что возвращается конкретный &ValidationError{}. В fiber.NewError этот слой не написал ни одного упоминания — транспорт появится только в ErrorHandler, и это осознанная граница.
Проверьте себя: обёрнутый ErrNotFound
Хендлер вернул fmt.Errorf("получить задачу: %w", fiber.ErrNotFound). ErrorHandler не настроен — работает дефолт. Что получит клиент?
API или HTML: единая схема на два мира#
У любого веб-приложения два вида клиентов, и при сбое они хотят разного. Мобильное приложение и фронтенд на fetch ждут JSON с предсказуемыми ключами — им нужно показать тост с текстом ошибки и, возможно, подсветить поле формы. Браузер, открывший страницу, ждёт HTML: человек должен увидеть страницу «что-то сломалось, мы уже чиним» в дизайне сайта, а не сырую строку. Один ErrorHandler обслуживает обоих, и вопрос «как понять, кто стучится» решается двумя признаками.
Признак первый и главный в каноне курса — путь запроса: strings.HasPrefix(c.Path(), "/api"). Он детерминированный, его легко тестировать, он согласуется с тем, как мы строили приложение с девятого урока: всё, что под /api, — это контракт для программ, всё остальное — страницы для людей. Версионирование API, о котором пойдёт речь в пятнадцатом уроке, только укрепляет эту схему: /api/v1/tasks и /api/v2/tasks оба начинаются с /api, и один префикс-чек продолжает работать.
Признак второй — заголовок Accept: c.Get(fiber.HeaderAccept) возвращает то, что клиент заявил в контент-неготиации, и strings.Contains(accept, fiber.MIMEApplicationJSON) подскажет, ожидает ли он JSON. Это более «по-HTTP-шному» признак, но у него практические минусы: curl посылает Accept: / (то есть «что угодно»), браузеры перечисляют text/html первыми, а part-клиенты вообще не всегда честны. Поэтому канон: путь решает, Accept — запасной эвристикой в else-ветке, если вам нужна содержательная неготиация. Хуже всего вариант «по User-Agent» — он и хрупкий, и легко ломается прокси.
Теперь HTML-ветка — и здесь исполняется обещание из прошлого урока. В ErrorHandler доступен тот же c.Render, что и в обычных хендлерах: c.Status(code).Render("errors/server", fiber.Map{"Code": code, "Error": err.Error()}, "layouts/main") — страница ошибки отрисуется в том же каркасе layouts/main, с той же навигацией и футером, что и живые страницы. Заведите views/errors/server.html с человеческим текстом: «Ошибка {{.Code}}. Что-то сломалось — мы уже смотрим». Для 404-х можно завести отдельный шаблон errors/404 и разводить ветки по коду. И заметьте Content-Type: c.Render сам выставит text/html; charset=utf-8 — в отличие от дефолтного text/plain, и клиент получит ровно то, что ожидает.
Что если отрисовка страницы ошибки сама упадёт — шаблон переименовали, движок не настроен? ErrorHandler вернёт ошибку, а дальше в дело вступает страховка роутера из того же router.go: catch != nil → ctx.SendStatus(StatusInternalServerError). Деградация честная: вместо сломанной страницы клиент получит голый 500 без тела. Про этот финальный страховочный трос мы ещё поговорим в callout-е, а сейчас — соберём весь ErrorHandler целиком.
Канон: JSON для /api, страница ошибки для страниц
GOpackage main
import (
"errors"
"log"
"strings"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/cors"
"github.com/gofiber/fiber/v3/middleware/helmet"
"github.com/gofiber/fiber/v3/middleware/logger"
)
func main() {
app := fiber.New(fiber.Config{
// одна точка: все ошибки стека приходят сюда
ErrorHandler: func(c fiber.Ctx, err error) error {
// лестница распознавания: транспорт → sentinel → валидация
code := fiber.StatusInternalServerError
var fe *fiber.Error
if errors.As(err, &fe) { // fiber.NewError и его обёртки
code = fe.Code
}
if errors.Is(err, ErrTaskNotFound) { // sentinel домена
code = fiber.StatusNotFound
}
var ve *ValidationError
if errors.As(err, &ve) { // типизированная ошибка
code = fiber.StatusBadRequest
}
// API получает JSON в едином формате {error, code}
if strings.HasPrefix(c.Path(), "/api") {
return c.Status(code).JSON(fiber.Map{
"error": err.Error(),
"code": code,
})
}
// страницы — HTML в общем каркасе из прошлого урока
return c.Status(code).Render("errors/server", fiber.Map{
"Title": "Ошибка",
"Code": code,
"Error": err.Error(),
}, "layouts/main")
},
})
app.Use(helmet.New())
app.Use(cors.New())
app.Use(logger.New())
// API: ошибка валидации уходит наверх одной строкой
app.Post("/api/tasks", func(c fiber.Ctx) error {
if c.FormValue("title") == "" {
return &ValidationError{Field: "title"}
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"created": true})
})
// страницы: несуществующий маршрут тоже придёт сюда с ErrNotFound
app.Get("/tasks", func(c fiber.Ctx) error {
return c.Render("index", fiber.Map{"Title": "Задачи"})
})
log.Fatal(app.Listen(":8080"))
}Вот он, полный механизм — примерно тридцать строк, которые обслуживают ошибки всего приложения. Читаем по слоям. Конфиг: ErrorHandler занимает одно поле, подпись func(c fiber.Ctx, err error) error — та самая из app.go. Лестница распознавания: сначала errors.As на fiber.Error (она поймает и прямые NewError, и обёрнутые — как в quiz выше), затем errors.Is на sentinel ErrTaskNotFound из блока выше, затем errors.As на ValidationError; порядок важен — частные ветки после общей, потому что ошибки не бывают двумя типами сразу только по договорённости. Статус по умолчанию 500 — как в дефолте. Дальше развилка по миру: путь начинается с /api — отдаём c.Status(code).JSON с конвертом {error, code}; иначе — c.Status(code).Render страницы errors/server в каркасе layouts/main, тот же движок и те же шаблоны, что у живых страниц. Хендлеры при этом остались наивными: Post возвращает &ValidationError{} как есть, не зная про 400; Get /tasks вернёт ошибку рендера, и она станет 500-й страницей в том же каркасе. Несуществующий маршрут — вроде GET /missing — придёт сюда же с fiber.ErrNotFound и получит не голый текст, а ту же страницу ошибки с кодом 404. Один обработчик, ноль дублирования, два формата ответа.
Что ответит канон на битый POST /api/tasks?
Прочитайте код и выберите, что он выведет:
// ErrorHandler — канонический из блока выше.
// Запрос:
// curl -X POST localhost:8080/api/tasks -d "title="Соберите ErrorHandler канона
Вставьте пропуски: поле конфига, распознавание *fiber.Error, чтение её кода и ключ единого формата.
Единый формат ответа: {error, code}#
Зачем вообще городить конверт, когда HTTP-статус уже есть в строке ответа? Аргументы практические. Первый: клиенту часто удобнее читать тело — fetch-обработчик и мобильные SDK дают доступ к распарсенному JSON без ручного разора заголовков, а код в теле грепается в логах, попадает в скриншоты баг-репортов и в метрики. Второй: единый формат — это контракт на все ошибки разом: фронтенд пишет один обработчик catch, который знает ключи, и не подстраивается под каждый эндпоинт. Третий: конверт — место для будущих полей: добавить поле request_id или trace_id — обратно совместимое расширение, а не миграция всех клиентов.
Форма конверта в каноне минимальная: {"error": "человекочитаемый текст", "code": 404}. Ключ error — строка для показа человеку, code — число, дублирующее HTTP-статус. Это тот самый envelope-стиль, который мы собирали в пятом уроке курса gin — там каждый ответ оборачивался в data/error; здесь для ошибочной половины конверта та же идея, только ключ data не нужен: при ошибке полезной нагрузки нет. Если ваш API уже живёт с таким конвертом, ErrorHandler — законное место, где он принудительно соблюдается: любая ошибка, дошедшая до конца стека, получит одинаковую форму, откуда бы она ни выпала.
Теперь про честность к деталям Fiber. Структура fiber.Error несёт json-теги message и code — если отдать её через c.JSON, клиент получит {"message": ..., "code": ...}. Это формат framework-а, а не наш конверт: ключ message вместо error — и два формата в одном API. Выбор один из двух — и везде одинаковый: либо вы везде отдаёте {error, code} (и тогда в ErrorHandler вы никогда не отдаёте саму fiber.Error, а только её поля через свою карту), либо принимаете {message, code} как стандарт и строите конверт вокруг него. Смешивать — худшее: каждый новый клиент и каждый тест будут гадать, какой из двух форматов придёт.
И правило безопасности, которое мы уже называли, но теперь оно встало на своё место: err.Error() уезжает в тело. Всё, что вы положили в текст ошибки, увидит клиент — значит, в ошибки транспорта и домена не должно попадать ничего лишнего: SQL-запросы, адреса хостов, версии библиотек, содержимое токенов. Разделение простое: наружу — человекочитаемое «задача не найдена», внутрь лога — подробности: ErrorHandler — идеальное место и для того, и для другого, ведь к нему приходят все ошибки, и он один может сделать и ответ, и запись в лог с одинаковым request_id.
Проверьте себя: c.JSON от *fiber.Error
Хендлер написан так: return c.JSON(fiber.NewError(fiber.StatusNotFound, "нет такой")). ErrorHandler канонический. Что увидит клиент?
Соберите путь запроса с ошибкой
Расставьте шаги: битый POST /api/tasks от curl до JSON-конверта в ответе.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Откуда ошибка приходит в ErrorHandler#
| Источник | Что лежит в err | Что ответит дефолт |
|---|---|---|
| хендлер вернул errors.New("...") | обычный *errors.errorString, без кода | 500, text/plain, тело — текст ошибки |
| хендлер вернул fiber.NewError(404, "...") | *fiber.Error: Message и Code заполнены | 404, text/plain, тело — Message |
| fmt.Errorf("...: %w", fiber.ErrNotFound) | обёртка fmt.wrapError, внутри fiber.Error | 404, тело — Message внутренней ошибки (errors.As) |
| домен вернул свой тип (*ValidationError) | ваш тип с методом Error() | 500: дефолт не знает ваших типов — ветка As только в вашем ErrorHandler |
| несуществующий маршрут (404/405) | fiber.ErrNotFound / ErrMethodNotAllowed (+ заголовок Allow) | 404/405, тело — «Not Found» из StatusMessage |
| паника в хендлере | в ErrorHandler не попадает: ловит recover-п middleware из 06 | без recover — голый 500 от fasthttp, с recover — err от panic-хендлера |
| сам ErrorHandler вернул ошибку | роутер: catch != nil → SendStatus(500) | голый статус 500 без тела — страховочный трос |
Ловушки обработки ошибок: симптом, причина, лечение#
| Симптом | Причина | Лечение |
|---|---|---|
| клиент получает 200 с телом ошибки | хендлер сделал c.JSON(err) и вернул его результат (nil) | возвращайте ошибку как error: return fiber.NewError(...), ответ строит ErrorHandler |
| при любом сбое приходит text/plain «внутренняя ошибка» | дефолт не знает ваших типов ошибок | лестница errors.As/errors.Is в своём ErrorHandler |
| ErrorHandler не вызывается для ошибок одного маршрута | middleware выше перехватил ошибку и вернул nil | перехват в middleware — только с полной заменой ответа |
| в ответе утекли SQL и адреса хостов | текст err.Error() уезжает клиенту как есть | в ErrorHandler наружу — sanitized-сообщение, детали — только в лог |
| в API два формата: {message,code} и {error,code} | часть хендлеров отдаёт *fiber.Error через c.JSON | один конверт на весь проект, c.JSON(err) под запретом |
| страница 404 голая, без дизайна сайта | дефолт отвечает текстом — вы не настроили ErrorHandler | ветка ErrNotFound: Render errors/404 в каркасе layouts/main |
| после ошибок хендлера не видно деталей в логах | ErrorHandler отвечает клиенту, но никуда не пишет | логируйте err со stack-ом и request_id внутри ErrorHandler — все ошибки проходят через него |
Возвращаемое значение ErrorHandler — error не для галочки. Роутер после вызова стоит с проверкой: if catch := ctx.App().ErrorHandler(ctx, err); catch != nil { _ = ctx.SendStatus(StatusInternalServerError) } — то есть при сбое вашего обработчика клиент получит голый 500 без тела, и молча. Самый вероятный источник такого сбоя — HTML-ветка: c.Render вернёт ошибку, если шаблона errors/server нет или движок не настроен. Поэтому ErrorHandler тестируйте как обычный код: юнит-тест на каждую ветку лестницы (обычная ошибка, *fiber.Error, обёрнутая, sentinel, свой тип) плюс интеграционный на оба мира — /api и страницу. И логируйте сам факт сбоя обработчика: он означает, что упали уже два слоя сразу.
Пока ErrorHandler не настроен, дефолт показывает клиенту текст любой ошибки целиком — и обёртки fmt.Errorf с деталями вроде dial tcp 10.0.0.5:5432: connect refused уезжают в браузер пользователя. Это не только некрасиво: адреса хостов, имена таблиц и версии драйверов — разведданные для атакующего. Правило гигиены: домен и инфраструктура возвращают ошибки для лога, а ErrorHandler решает, какая их часть публична. Практичный приём — белые списки: *ValidationError и sentinel-ы проходят в ответ как есть, всё остальное заменяется на «внутренняя ошибка, мы уже чиним» со статусом 500 и полным текстом в логе.
Смонтированные приложения и чужие 404-е#
Последний слой механики — для случаев, когда приложение составное. Метод App.ErrorHandler в app.go устроен хитрее простого вызова: прежде чем браться за свой конфиг, он ищет ErrorHandler среди смонтированных sub-приложений. Док-комментарий описывает алгоритм: It searches any mounted sub fibers by their prefixes and if it finds a match, it uses that error handler. Поиск идёт по префиксам путей, из совпавших побеждает самый специфичный — с бо́льшим числом слэшей; если у sub-приложения свой обработчик не настроен, работает конфиг корня. Практический смысл: смонтировали админку под /admin — она может иметь свою страницу ошибки и свой формат JSON, не мешая основному приложению. Для простых приложений всё это невидимо: без смонтированных sub-app-ов работает быстрый путь — сразу app.config.ErrorHandler.
Второй сюрприз мы уже упоминали, но теперь он ложится на место: 404 и 405 — тоже через ErrorHandler. Роутер, не нашедший совпадение, вызывает emitSkip с fiber.ErrNotFound (для 405 — ErrMethodNotAllowed, к которому добавлен заголовок Allow со списком допустимых методов), и дальше работает ваш обработчик: для /api — JSON с кодом 404, для страниц — отрисованная в каркасе errors/404. В документации конфига это зафиксировано словами Customize the 404/405 responses via ErrorHandler — единственный способ сделать красивую 404-ю в Fiber v3.
Префиксы и смонтированные приложения — тема, которая заслуживает отдельного разговора: там появляются app.Group, версии /api/v1 и /api/v2, общие middleware на ветки маршрутов и правила, по которым префиксы склеиваются. Всё это — материал пятнадцатого урока «Группы маршрутов и версионирование»: после сегодняшнего ErrorHandler он ляжет на готовый фундамент — вы уже знаете, куда попадут ошибки из каждой ветки.
Словарь урока
Запуск, пять сценариев curl и что дальше#
Соберите канон целиком: инстанс на :8080, helmet, cors и logger из шестого урока, страница задач с движком и каркасом layouts/main из тринадцатого, при желании страж requireAPIKey("s3cret") из седьмого — и новый ErrorHandler из блока выше, плюс шаблон views/errors/server.html. Прогоните пять сценариев.
Первый: curl -X POST localhost:8080/api/tasks -d "title=" — в ответе 400 и JSON {"error":"поле title заполнено неверно","code":400}: валидация домена стала честным статусом без единого if про HTTP в хендлере. Второй: вызовите findTask(999) на /api/tasks/999 — 404 и {"error":"хранилище задач: задача не найдена","code":404}: sentinel распознан errors.Is под обёрткой %w. Третий: curl localhost:8080/missing — несуществующий маршрут, и вместо голого «Cannot GET /missing» вы увидите страницу ошибки в каркасе сайта с кодом 404: emitSkip передал ErrNotFound в ваш ErrorHandler. Четвёртый: переименуйте views/errors/server.html и откройте любой HTML-путь с ошибкой — ErrorHandler упадёт на Render, роутер сделает SendStatus(500): страховочный трос работает; верните шаблон. Пятый: закомментируйте ErrorHandler из конфига и повторите первый сценарий — дефолт ответит 400? Нет: ValidationError для дефолта — неизвестный тип, и будет 500 с телом «поле title заполнено неверно» в text/plain. Вот разница, которую делает один блок конфига.
Пара слов о том, чего в уроке нет и почему. Песочница сайта сетевые сценарии не исполняет — runner-ов в уроках Fiber нет с первого урока, интерактивы здесь quiz, predict, fill, order и флешкарты; сервер с ErrorHandler собирайте локально в терминале. И паники мы не разбирали заново — это recover-п middleware из шестого урока: паника превращается в error и приходит в ErrorHandler уже как ошибка.
Материал держится на четырёх опорах: механика дефолта из app.go, тип *fiber.Error и NewError, лестница errors.As/errors.Is из пятнадцатого урока go-advanced и единый конверт {error, code}. Полный план курса — на странице курса Fiber.
Дальше — пятнадцатый урок «Группы маршрутов и версионирование»: app.Group, префиксы /api/v1 и /api/v2, общие middleware на ветки. Связь прямая: вы уже видели, что ErrorHandler ищется по префиксам смонтированных приложений — научившись группировать маршруты, вы получите обработчики ошибок на каждую версию API отдельно, а единый формат ответа останется одним и тем же для всех.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Каждый хендлер — func(c fiber.Ctx) error: nil означает «ответ записан», ненулевой error всплывает по цепочке c.Next() и попадает в ErrorHandler — единую точку обработки ошибок всего приложения.
- ErrorHandler — поле fiber.Config с типом-алиасом func(Ctx, error) error (app.go): process all errors returned from any handlers in the stack. При старте nil превращается в DefaultErrorHandler. ErrorHandler — не middleware: c.Next() в нём нет.
- DefaultErrorHandler: стартовый код 500, asFiberError находит *fiber.Error (type assertion, иначе errors.As — обёрнутые ошибки тоже), message = StatusMessage(code), перекрытый err.Error(); Content-Type text/plain; charset=utf-8. Обычная ошибка → 500 + текст; NewError(404, msg) → 404 + msg; NewError(404) → 404 + «Not Found».
- *fiber.Error — Message string / Code int с json-тегами message и code; fiber.NewError(code, message...): без сообщения — StatusMessage(code), NewErrorf — форматный вариант. Предопределённые ErrNotFound, ErrMethodNotAllowed, ErrTeapot; 404/405 роутера идут через ErrorHandler (emitSkip, Customize the 404/405 responses via ErrorHandler).
- Свои ошибки домена — типы (ValidationError с полем Field) и sentinel-ы (ErrTaskNotFound), обёртки fmt.Errorf("%w"); в ErrorHandler лестница errors.As/errors.Is назначает статусы — связка с go-advanced 15. Домен не знает про HTTP.
- Единый формат {error, code} — envelope-стиль как в курсе gin: API получает JSON по префиксу /api, страницы — c.Render ошибки в каркасе layouts/main; Accept — запасной признак. c.JSON(err) — ловушка: 200 с телом ошибки, ErrorHandler не вызывается.
- Паники — не ошибки: их ловит recover из шестого урока, превращая в error. Если ErrorHandler сам вернул ошибку, роутер делает SendStatus(500) — голый статус без тела. App.ErrorHandler ищет обработчики по префиксам смонтированных sub-app — фундамент для пятнадцатого урока про группы и версии.
Домашнее задание
Дособерите обработку ошибок канона до продакшн-уровня. Во-первых, разделите шаблоны: errors/404.html для ветки ErrNotFound и errors/server.html для остальных — и разведите их в ErrorHandler по коду. Во-вторых, добавьте в конверт поле request_id: сгенерируйте его в middleware, положите в c.Locals и в лог, а в ErrorHandler вставляйте в JSON-ответ — пользователь с этим номером придёт в поддержку, а вы найдёте запись в логе. В-третьих, постройте белые списки: *ValidationError и sentinel-ы проходят в ответ как есть, любая другая не-fiber ошибка уходит клиенту как «внутренняя ошибка» со статусом 500 и полным текстом в логе. В-четвёртых, напишите тест ErrorHandler-а: табличный тест с пятью входами — errors.New, fiber.NewError, обёрнутый ErrNotFound, *ValidationError, nil — и проверкой статуса и тела для /api и для страницы. Наконец, сломайте нарочно шаблон ошибки и убедитесь, что клиент получает голый 500, а процесс жив; почините и объясните себе каждый переход страховочного троса.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем ErrorHandler отличается от middleware с перехватом ошибок — и можно ли обойтись только им?
Middleware с err := c.Next() перехватывает ошибку раньше — и может решить: залогировать и вернуть err дальше, заменить ответ и вернуть nil. ErrorHandler вызывается роутером только когда ошибка дошла до конца цепочки; он не в цепочке, у него нет c.Next(), и его контракт — один раз построить ответ. Обойтись только ErrorHandler обычно можно и нужно: один формат, одно место логирования. Свой перехватывающий middleware оправдан, когда решение зависит от места в цепочке — например, recovery-лог с трейсом до того, как ответ уйдёт, или метрики по веткам ошибок.
Как показать клиенту красивую 404-ю в дизайне сайта?
Никакого отдельного хука для 404 в Fiber v3 нет — роутер вызывает ErrorHandler с fiber.ErrNotFound (это NewError(StatusNotFound) из constants.go), а для 405 — с ErrMethodNotAllowed и заголовком Allow. В ErrorHandler проверяйте errors.As(err, &fe) и fe.Code == fiber.StatusNotFound: для не-/api путей рисуйте c.Status(404).Render("errors/404", fiber.Map{...}, "layouts/main") — страница получит общий каркас сайта из прошлого урока. Док-комментарий конфига прямо фиксирует: Customize the 404/405 responses via ErrorHandler.
Почему после c.JSON(err) клиент получил 200 — и как правильно вернуть ошибку из хендлера?
c.JSON(v) сериализует v в тело ответа и возвращает error только самого маршалинга; *fiber.Error сериализуется успешно, значит c.JSON вернул nil, хендлер вернул nil — роутер счёл запрос обработанным, ErrorHandler не вызывался, статус остался 200. Правильно — return fiber.NewError(fiber.StatusNotFound, "нет такой"): ошибка возвращается как error, ErrorHandler назначает статус 404 и сам решает формат — JSON для /api или страницу для HTML-мира. Общее правило: хендлер либо записывает ответ и возвращает nil, либо возвращает ошибку и не пишет ничего — за ответ тогда отвечает ErrorHandler.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Gin: самый популярный веб-фреймворк Go
Курс Gin для Go: маршрутизация, binding, валидация, middleware, JWT, WebSocket, тесты и деплой. 25 уроков с кодом и итоговым REST API.
- Уверенно владеть gin.Engine: маршруты, группы, версии
- Привязывать запросы к структурам (ShouldBindJSON и др.)
- Писать middleware и использовать встроенные
- Строить аутентификацию на JWT