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

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

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

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

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

Тема «fiber обработка ошибок» закрывает контракт, который подписан с самого второго урока: каждый хендлер Fiber — это func(c fiber.Ctx) error, и до сих пор мы почти всегда возвращали из него nil. Пора разобраться, что случается с ненулевым error: кто его ловит, куда он попадает и что увидит клиент. Механика одна на всё приложение: поле ErrorHandler в fiber.Config — функция вида func(Ctx, error) error, в которую стекает любая ошибка из любого хендлера и любого middleware. Дефолтная версия отвечает 500 и text/plain, но её место занимает ваша: с распознаванием *fiber.Error через fiber.NewError, со своими типами ошибок через errors.As и с единым форматом ответа — {error, code} для API и HTML-страница «что-то сломалось» в каркасе layouts/main для браузера. Вся настройка errorhandler fiber умещается в одном поле конфига, а каждый факт урока сверен с исходниками тега v3.5.0 — прежде всего с app.go и error.go. Канон продолжается: тот же инстанс на :8080, те же слои helmet, cors, logger, страница задач из прошлого урока и requireAPIKey("s3cret") из седьмого.

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

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, обёртывание

GO
package 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, страница ошибки для страниц

GO
package 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, чтение её кода и ключ единого формата.

func main() { app := fiber.New(fiber.Config{ // одна точка обработки всех ошибок стека : func(c fiber.Ctx, err error) error { code := fiber.StatusInternalServerError var fe *fiber.Error // распознать *fiber.Error даже под %w-обёрткой if (err, &fe) { code = fe. } // единый JSON-формат для /api return c.Status(code).JSON(fiber.Map{ "error": err.Error(), "": code, }) }, }) app.Post("/api/tasks", func(c fiber.Ctx) error { return fiber.NewError(fiber.StatusNotFound, "задача не найдена") }) log.Fatal(app.Listen(":8080")) }

Единый формат ответа: {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.Error404, тело — 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 — все ошибки проходят через него

Смонтированные приложения и чужие 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 он ляжет на готовый фундамент — вы уже знаете, куда попадут ошибки из каждой ветки.

Словарь урока

Термин

ErrorHandler

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

Поле fiber.Config, тип-алиас из app.go: func(Ctx, error) error. Док: process all errors returned from any handlers in the stack. Вызывается роутером, когда err из app.next не nil; контекст жив — можно отвечать клиенту. Возвращённая ошибка — сбой самого обработчика: роутер отправит голый SendStatus(500). По умолчанию заполнен DefaultErrorHandler, nil превращается в дефолт.

1 / 6

Запуск, пять сценариев 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, а процесс жив; почините и объясните себе каждый переход страховочного троса.

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

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

Чем 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.

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

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

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

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

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

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

Gin: самый популярный веб-фреймворк Go

Курс Gin для Go: маршрутизация, binding, валидация, middleware, JWT, WebSocket, тесты и деплой. 25 уроков с кодом и итоговым REST API.

  • Уверенно владеть gin.Engine: маршруты, группы, версии
  • Привязывать запросы к структурам (ShouldBindJSON и др.)
  • Писать middleware и использовать встроенные
  • Строить аутентификацию на JWT