---
title: "Обработка ошибок"
description: "Урок fiber обработка ошибок: ErrorHandler в fiber.Config, дефолтный 500, *fiber.Error и NewError, свои типы с errors.As и единый JSON-формат ответа."
course: "Fiber: экспресс-подобный веб-фреймворк на Fasthttp"
url: "https://golangmaster.ru/courses/fiber/lessons/14-oshibki/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Fiber: экспресс-подобный веб-фреймворк на Fasthttp», урок 14 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/fiber/lessons/14-oshibki/*

Тема «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") из седьмого.

### 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 в конфиге не настроен. Что именно получит клиент?

- ✅ Статус 500, тело «база недоступна», Content-Type: text/plain; charset=utf-8
- Статус 500, тело «Internal Server Error»: дефолт никогда не показывает текст ошибки
- Статус 500, тело {"error":"база недоступна"}: дефолт отвечает JSON-ом
- Статус 502: дефолт видит, что ошибка не *fiber.Error, и перекладывает ответственность на прокси

Проходим DefaultErrorHandler по шагам. asFiberError: type assertion на *Error не сработал, errors.As тоже — matched == false, значит поле code остаётся StatusInternalServerError (500). message := utils.StatusMessage(500) даёт "Internal Server Error", но следующая строка — if err != nil && (!matched || e != nil) — срабатывает, потому что matched == false, и message становится err.Error() = «база недоступна». В конец: Content-Type принудительно в text/plain; charset=utf-8, тело уходит через SendString. Вариант с «Internal Server Error» — самая популярная ловушка: этот текст показывается только когда ошибка — это *fiber.Error с кодом 500 и пустым Message либо когда err nil. JSON-а в дефолте нет от слова совсем: MIMETextPlainCharsetUTF8, а не MIMEApplicationJSON. И никаких 502: дефолт не анализирует природу ошибки, у него нет ни валидационных, ни прокси-кейсов — только 500 и код из *fiber.Error.

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

```go
// ErrorHandler в конфиге НЕ настроен — работает дефолт.
app.Get("/brew", func(c fiber.Ctx) error {
        return fiber.NewError(fiber.StatusTeapot, "Я чайник")
})
```

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

- ✅ 418 и тело «Я чайник» в text/plain: код из e.Code, сообщение из e.Message
- 418 и тело «I'm a teapot»: дефолт всегда берёт текст из таблицы StatusMessage
- 500 и тело «Я чайник»: код статусом не бывает, только тело
- 418 и тело {"message":"Я чайник","code":418}: дефолт сериализует *fiber.Error в JSON

Трассируем DefaultErrorHandler. asFiberError ловит *Error прямым type assertion — matched == true, e != nil, значит code = e.Code = 418 (StatusTeapot, RFC 9110: 15.5.19 (Unused) — комментарий в constants.go честно признаёт, что код шуточный). Дальше message := utils.StatusMessage(418) — но следующая строка перекрывает его, потому что matched && e != nil: message = err.Error() = e.Message = «Я чайник». Ответ: статус 418, тело «Я чайник», text/plain. Ловушка варианта с «I'm a teapot»: StatusMessage участвует в сборке сообщения, но для *fiber.Error с непустым Message всегда проигрывает err.Error(). Ловушка JSON-варианта: дефолт не маршалит ошибку — он делает c.Status(code).SendString(message); теги message/code в структуре ошибке пригодятся только когда вы сами отдадите её через c.JSON — а почему это плохая идея, разберём в блоке про единый формат.

### 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»](/courses/go-advanced/lessons/15-oshibki-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 не настроен — работает дефолт. Что получит клиент?

- ✅ 404 и тело «Not Found»: errors.As в asFiberError найдёт *fiber.Error в цепочке
- 500 и тело «получить задачу: Not Found»: обёртка прячет код от дефолта
- 404 и тело «получить задачу: Not Found»: текст цепочки целиком
- 500 и тело «Internal Server Error»: только прямой *fiber.Error распознаётся

Здесь проверяется знание asFiberError — и его двух путей. Прямой type assertion err.(*Error) не сработает: тип ошибки — *fmt.wrapError, обёртка. Но дальше идёт медленный путь: errors.As(err, &target), который разворачивает цепочку Unwrap — а %w в fmt.Errorf как раз и создаёт такую цепочку. matched == true, target == fiber.ErrNotFound (тот самый предопределённый экземпляр из constants.go), code = 404. Теперь сообщение: message := utils.StatusMessage(404) = "Not Found"; условие перекрытия — (!matched || e != nil) — ложно, ведь matched == true, а e != nil... значит message остаётся «Not Found»? Да — и вот почему: err.Error() у *fiber.Error возвращает Message, а у fiber.ErrNotFound Message == "Not Found" — так что какой бы путь ни победил, текст один. Ловушка варианта с 500: она была бы правдой, если бы asFiberError умел только type assertion — но комментарий в исходнике прямо обещает: wrapped or joined errors fall back to errors.As. Ловушка «текст цепочки целиком» — тоньше: для обёрнутой НЕ-fiber ошибки err.Error() действительно дал бы весь текст; но здесь found *fiber.Error, и дефолт берёт её Message, а не concat цепочки.

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

```go
// ErrorHandler — канонический из блока выше.
// Запрос:
// curl -X POST localhost:8080/api/tasks -d "title="
```

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

- ✅ 400 и {"error":"поле title заполнено неверно","code":400} в application/json
- 400 и {"message":"поле title заполнено неверно","code":400}: ErrorHandler отдаёт *fiber.Error как есть
- 400 и HTML-страницу ошибки: ValidationError всегда рисует страницу
- 500 и {"error":"поле title заполнено неверно","code":500}: валидация не знает своего статуса

Прогоняем цепочку. Хендлер возвращает &ValidationError{Field: "title"} — ненулевой error, роутер зовёт ErrorHandler. Лестница: errors.As на *fiber.Error не сработал (наш тип не fiber-овский), errors.Is на ErrTaskNotFound тоже, errors.As на *ValidationError — сработал: code = fiber.StatusBadRequest = 400. Развилка: c.Path() == "/api/tasks" начинается с /api — значит, JSON-ветка, c.Status(400).JSON(fiber.Map{"error": err.Error(), "code": code}). err.Error() вызывает наш метод Error() и даёт «поле title заполнено неверно». Ответ — вариант один, и все ловушки здесь про разные грабли. Вариант с message — это формат *fiber.Error из её json-тегов; наш конверт называет ключ error, и смешивать нельзя (подробнее — следующий блок). Вариант с HTML: признак — путь, а не тип ошибки; ValidationError пришла на /api и потому уехала JSON-ом. Вариант с 500: статус назначает ErrorHandler, а не хендлер — ветка As как раз и есть «валидация знает свой статус».

### Соберите ErrorHandler канона

Вставьте пропуски: поле конфига, распознавание *fiber.Error, чтение её кода и ключ единого формата.

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

Подсказка: Поле в fiber.Config называется ErrorHandler; *fiber.Error распознаёт errors.As(err, &fe); статус лежит в поле fe.Code; ключ конверта — code, тот же, что и HTTP-статус.

Правильные ответы в пропусках: `ErrorHandler`, `errors.As`, `Code`, `code`

### Единый формат ответа: {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 канонический. Что увидит клиент?

- ✅ 200 и {"message":"нет такой","code":404}: c.JSON отработал успешно и вернул nil
- 404 и {"error":"нет такой","code":404}: ErrorHandler поймал *fiber.Error
- 404 и {"message":"нет такой","code":404}: ErrorHandler перекодирует в json-теги ошибки
- Ошибку маршалинга: *fiber.Error нельзя передавать в c.JSON

Самая коварная ловушка темы. c.JSON — это не «вернуть ошибку», это «записать JSON в ответ»: он сериализует переданное значение — а *fiber.Error с json-тегами message/code прекрасно сериализуется — и возвращает nil, если маршалинг прошёл. Хендлер вернул nil → для роутера ошибки не было → ErrorHandler не вызван → статус остаётся дефолтным 200 (OK). Клиент получил успех с телом ошибки — и любой мониторинг по статус-кодам промолчит. Правильно — return fiber.NewError(...) без c.JSON: ошибка возвращается как ошибка, ErrorHandler назначает статус и сам решает формат. Мораль шире одного кейса: ErrorHandler срабатывает только на возвращённый error; всё, что хендлер записал в ответ до возврата nil, — уже неотменяемый ответ. Даже «на всякий случай» записав c.JSON(err), вы вернёте его результат — nil, и ErrorHandler по-прежнему не вызовется: ответ записан, статус остался 200. Один вызов — один ответ: либо хендлер пишет ответ и возвращает nil, либо возвращает ошибку — и тогда ответ строит ErrorHandler.

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

Расставьте шаги: битый POST /api/tasks от curl до JSON-конверта в ответе.

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

1. `curl -X POST localhost:8080/api/tasks -d "title="`
2. `helmet ставит защитные заголовки ответа и пропускает запрос дальше`
3. `cors добавляет CORS-заголовки`
4. `logger пишет строку запроса в консоль`
5. `хендлер зовёт validate и возвращает &ValidationError{Field: "title"}`
6. `ошибка всплывает по цепочке: app.next возвращает её роутеру`
7. `роутер зовёт ctx.App().ErrorHandler(ctx, err) — единую точку обработки`
8. `errors.As находит *ValidationError: code = 400, ветки *fiber.Error и sentinel не сработали`
9. `путь начинается с /api: c.Status(400).JSON({"error": ..., "code": 400})`
10. `если бы ErrorHandler вернул ошибку: роутер сделал бы SendStatus(500)`

### Откуда ошибка приходит в 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 — тоже код, который может упасть.** Возвращаемое значение 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 и страницу. И логируйте сам факт сбоя обработчика: он означает, что упали уже два слоя сразу.

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

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

- **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 превращается в дефолт.
- **DefaultErrorHandler** — Исходник app.go: код стартует с 500, asFiberError вытягивает *fiber.Error (type assertion, иначе errors.As), message = utils.StatusMessage(code), перекрывается err.Error(), Content-Type — text/plain; charset=utf-8, ответ — c.Status(code).SendString(message). Обычная ошибка → 500 + текст ошибки; NewError(404) → 404 + «Not Found»; NewError(404, msg) → 404 + msg.
- ***fiber.Error и fiber.NewError** — Структура Message string / Code int с json-тегами message и code; Error() возвращает Message. fiber.NewError(code, message ...string): без сообщения берёт StatusMessage(code), с сообщением — перекрывает; NewErrorf — форматный вариант v3. Предопределённые: ErrNotFound = NewError(StatusNotFound), ErrMethodNotAllowed, ErrTeapot (418). 404/405 роутера идут через ErrorHandler (emitSkip).
- **Своя ошибка + errors.As / errors.Is** — Домен не знает про HTTP: type ValidationError struct{Field string} с методом Error(); sentinel var ErrTaskNotFound = errors.New(...); обёртки fmt.Errorf("...: %w", ...). В ErrorHandler лестница: errors.As на *fiber.Error и свои типы, errors.Is на sentinel — каждая ветка назначает статус. Детали — из пятнадцатого урока go-advanced.
- **Единый формат {error, code}** — Конверт канона: error — человекочитаемый текст, code — число, дублирующее HTTP-статус. Envelope-стиль как в курсе gin (data/error). Не смешивать с json-тегами *fiber.Error ({message, code}) — и никогда не отдавать err через c.JSON(err): это 200 с телом ошибки, ErrorHandler не вызывается.
- **API против HTML** — Признак мира: strings.HasPrefix(c.Path(), "/api") — канон; Accept-заголовок (c.Get(fiber.HeaderAccept)) — запасной. /api → c.Status(code).JSON({error, code}); страницы → c.Status(code).Render("errors/server", fiber.Map{...}, "layouts/main") — страница ошибки в общем каркасе из прошлого урока. Сбой Render → ErrorHandler вернёт err → роутер SendStatus(500).

### Запуск, пять сценариев curl и что дальше

Соберите канон целиком: инстанс на :8080, helmet, cors и logger из [шестого урока](/courses/fiber/lessons/06-middleware/), страница задач с движком и каркасом layouts/main из [тринадцатого](/courses/fiber/lessons/13-shablony/), при желании страж 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](/courses/go-advanced/lessons/15-oshibki-wrapping/) и единый конверт {error, code}. Полный план курса — на странице [курса Fiber](/courses/fiber/).

Дальше — пятнадцатый урок «Группы маршрутов и версионирование»: app.Group, префиксы /api/v1 и /api/v2, общие middleware на ветки. Связь прямая: вы уже видели, что ErrorHandler ищется по префиксам смонтированных приложений — научившись группировать маршруты, вы получите обработчики ошибок на каждую версию API отдельно, а единый формат ответа останется одним и тем же для всех.

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

- Каждый хендлер — 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.
