Содержание урока · 13 разделов
- echo httperror: ошибка как значение, ответ — забота фреймворка
- Маршрут ошибки: от return до HTTPErrorHandler
- Анатомия HTTPError: Code, Message, Internal
- Error(), SetInternal, WithInternal и Unwrap
- DefaultHTTPErrorHandler построчно
- Debug-режим: поле error и цена откровенности
- Каталог Err*, 404/405 как обработчики и RouteNotFound
- Свой ErrorHandler: единый формат {error, code} для всего API
- Кто вернул ошибку — что увидит клиент
- Что дальше: группы и версии API
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
echo httperror: ошибка как значение, ответ — забота фреймворка#
Тема «echo httperror» — это обработка ошибок echo, построенная вокруг одной идеи: обработчик возвращает ошибку значением, а ответом на неё занимается фреймворк. В Gin вы пишете c.JSON(http.StatusBadRequest, ...) прямо в теле при каждом отказе; в Echo вы просто возвращаете error, и единственное место, которое решает, каким будет HTTP-ответ, — глобальный HTTPErrorHandler. Сигнатура объявлена в echo.go строкой type HTTPErrorHandler func(err error, c Context): на вход — ошибка и живой контекст запроса, через который можно и статус поставить, и JSON отдать, и в лог записать. Обработка ошибок echo — это, по сути, вся эта функция целиком. Поле HTTPErrorHandler — обычное поле структуры Echo, и echo.New() заполняет его штатной функцией: e.HTTPErrorHandler = e.DefaultHTTPErrorHandler.
Это третье обещание, которое мы исполняем: в восьмом уроке, строя bearerAuth, мы сказали «подробная анатомия HTTPError — тема десятого урока», в девятом — «разбор ошибок целиком — дело десятого урока». Сегодня разбираем весь вертикальный срез: что происходит между return err из вашего обработчика и байтами, уехавшими клиенту; из чего состоит echo.HTTPError; как дефолтный обработчик решает статус и тело; почему при Debug в ответе появляется лишнее поле; и как заменить весь конвейер своим — с единым форматом {error, code} для всего API.
Маршрут ошибки: от return до HTTPErrorHandler#
Проведём ошибку по цепочке. Первый узел — ServeHTTP: после того как стек middleware и обработчик исполнены, в echo.go стоит короткая проверка: if err := h(c); err != nil { e.HTTPErrorHandler(err, c) }. Всё, что всплыло из любого звена — обработчика, bearerAuth, валидации, — приходит в обработчик ровно один раз: middleware, который вернул next(c) без изменений, в передаче ошибки не участвует, он просто пропускает её наверх.
Второй узел — ручной вызов. У echo.Context есть метод c.Error(err), и его тело — одна строка: c.echo.HTTPErrorHandler(err, c). Это способ позвать глобальный обработчик из середины цепочки — так делает RequestLogger с HandleError: true из девятого урока: middleware сам оформляет ответ ошибкой ДО снятия значений access-записи, чтобы статус в записи был честным. Разница между return err и c.Error(err) в позиции звена: return передает ошибку выше, c.Error завершает историю на месте.
Третий узел — защита от двойного ответа. У Response из response.go есть флаг Committed bool: WriteHeader ставит его в true, и повторный вызов WriteHeader уже ничего не отправит — только запишет в e.Logger.Warn строку «response already committed». Именно поэтому первые строки DefaultHTTPErrorHandler — это guard: if c.Response().Committed { return } (echo.go, строка 428). Сценарий, ради которого guard написан, прямо описан в доккомментарии: хендлер уже ответил через c.JSON — заголовки ушли в сеть — и тут какое-то middleware на раскрутке вернуло ошибку. Обработчик получает её, видит Committed и молча выходит: перезаписать уже уехавший ответ невозможно физически.
Анатомия HTTPError: Code, Message, Internal#
Структура из echo.go крошечная — три поля, и каждое со своей ролью:
Internal error — исходная причина от внешней зависимости, с тегом json:"-": в дефолтный JSON она не попадает никогда, это канал для логов. Message interface{} — то, что увидит клиент; тег json:"message" намекает, что при маршалинге самой структуры поле уедет под этим ключом. Code int — HTTP-статус, тоже с json:"-": статус дублировать в тело незачем, он уже в строке ответа.
Тип Message — interface{}, и это главная гибкость и главная ловушка структуры. Туда можно положить строку — самый частый случай; error — дефолтный обработчик вызовет его Error(); структуру или echo.Map — она уедет клиенту как есть, со своими json-тегами; наконец, значение, реализующее json.Marshaler, — обработчик вообще не станет его трогать.
Обратите внимание на порядок полей: Internal объявлен первым, Code — последним. Позиционная инициализация &echo.HTTPError{401, "нет доступа", nil} по незнанию положит 401 в Internal — всегда пишите имена полей явно, как это делает штатный key_auth.go: return &echo.HTTPError{Code: http.StatusUnauthorized, Message: "Unauthorized", Internal: lastValidatorErr}. Это живой образец из каталога middleware: аутентификация кладёт в Message краткий наружный текст, а в Internal — реальную причину от валидатора токена.
HTTPError из исходников: три поля и их json-теги
GO// echo.go, v4.15.0 — вся структура целиком
// HTTPError represents an error that occurred while handling a request.
type HTTPError struct {
Internal error `json:"-"` // Stores the error returned by an external dependency
Message interface{} `json:"message"`
Code int `json:"-"`
}
// HTTPErrorHandler is a centralized HTTP error handler.
type HTTPErrorHandler func(err error, c Context)Читаем теги. Internal и Code помечены json:"-" — в дефолтный JSON-ответ они не попадают никогда: причина живёт в логах, статус — в строке ответа. Message уедет под ключом message, если вдруг обработчик отдаст саму HTTPError через c.JSON. Порядок объявления — Internal, Message, Code — не эстетика, а ловушка: позиционный литерал &echo.HTTPError{401, "нет", nil} положит 401 в Internal и съест строку — всегда именуйте поля. Доккомментарий Internal называет вещи своими именами: там живёт ошибка внешней зависимости. Рядом — тип обработчика: одна функция err error + живой контекст решает судьбу каждой ошибки приложения.
NewHTTPError: конструктор с вариадиком
GO// подпись из echo.go: func NewHTTPError(code int, message ...interface{}) *HTTPError
// без сообщения: Message = http.StatusText(404) = "Not Found"
he := echo.NewHTTPError(http.StatusNotFound)
// со строкой: самый частый случай
he = echo.NewHTTPError(http.StatusBadRequest, "id должен быть числом")
// с echo.Map: структурированные детали для клиента
he = echo.NewHTTPError(http.StatusUnprocessableEntity,
echo.Map{"field": "title", "problem": "поле пустое"})
// с ошибкой: дефолтный обработчик вызовет у неё Error()
he = echo.NewHTTPError(http.StatusConflict, errors.New("версия устарела"))
// литерал без Message: поле нулевое — тело ответа будет "null"
weird := &echo.HTTPError{Code: http.StatusTeapot}Конструктор делает ровно два действия: ставит Code и заполняет Message значением http.StatusText(code) — стандартной фразой из net/http («Not Found», «Gone», «Too Many Requests»). Если вариадик-сообщение передано, первый элемент заменяет текст целиком. Вариант с error-сообщением рабочий, но помните: в дефолтном ответе появится её Error() — текст ошибки становится публичным интерфейсом. Про песочницу — как и весь курс Echo, этот урок не исполняется браузерным движком сайта: runner-ов здесь нет, код запускается локально — go mod init, go get github.com/labstack/echo/v4, go run и curl по эндпоинтам ниже.
Проверка: конструктор без сообщения
Обработчик возвращает echo.NewHTTPError(http.StatusGone) — без второго аргумента. Что получит клиент при выключенном Debug?
Error(), SetInternal, WithInternal и Unwrap#
У *HTTPError есть метод Error(), который делает тип совместимым с error: fmt.Sprintf("code=%d, message=%v", he.Code, he.Message), а при непустом Internal — "code=%d, message=%v, internal=%v". Это формат для машин, а не для клиентов: именно его напечатают e.Logger.Error и slog, когда получат такую ошибку. Форматирование %v сквозное: строка напечатается как строка, error — через свой Error(), структура — Go-представлением. Разделение труда такое: Error() — для логов и терминала, switch по Message — для клиента, и это два разных представления одной ошибки.
Причина кладётся в Internal двумя методами, и разница между ними — источник самых тихих багов. SetInternal(err error) HTTPError мутирует приёмник и возвращает его же для цепочки: he := echo.NewHTTPError(500, "внутренняя ошибка").SetInternal(err). WithInternal(err error) HTTPError возвращает КЛОН с теми же Code и Message, но с новой причиной — исходное значение не трогает.
Почему клон важен, станет ясно в следующем блоке — Err* из echo — общие экземпляры. А пока зафиксируйте третий метод: Unwrap() error возвращает he.Internal и удовлетворяет интерфейс обёрток Go 1.13. Отсюда способность, которой мы будем пользоваться в своём ErrorHandler: errors.Is и errors.As видят сквозь HTTPError — errors.Is(err, sql.ErrNoRows) докопается до причины, даже если сверху три этажа обёрток.
DefaultHTTPErrorHandler построчно#
Читаем штатную функцию как конвейер из шести ступеней. Первая — guard Committed: ответ уже уехал — выходим молча. Вторая — распознавание: err.(HTTPError) — type assertion, не errors.As; HTTPError, завёрнутая в fmt.Errorf, здесь распознается как обычная ошибка и станет 500-й — обёртки над HTTPError дефолт не разворачивает. Если assertion сработал и у ошибки есть Internal, который сам является HTTPError, он заменяет текущую ошибку целиком: he = herr. Это разворот ровно на один этаж — сквозь два вложенных Internal дефолт уже не полезет.
Третья ступень — ошибка не HTTPError: собирается заглушка &HTTPError{Code: 500, Message: http.StatusText(500)}. Любая чужая ошибка — из базы, из парсера, из вашего кода — получает 500 «Internal Server Error»; в теле ответа её текста нет, и это правильно: текст ошибки не должен утекать клиенту без вашего решения. Над этой заглушкой в исходнике висит комментарий Issue #1426 — багфикс, благодаря которому нестроковый Message (структура, echo.Map) доезжает до ответа как есть, без пересборки. Четвёртая — switch по типу Message: строка превращается в Map{"message": m}, а при включённом e.Debug — в Map{"message": m, "error": err.Error()}; json.Marshaler отдаётся как есть; error — в Map{"message": m.Error()}; остальные значения (структура, echo.Map) остаются собой и маршалатся своими тегами. Пятая — отправка: для HEAD-запроса c.NoContent(he.Code) без тела — Issue #608; для остальных c.JSON(code, message). Шестая — если отправить не удалось, ошибка отправки уходит в e.Logger.Error(err) — тот самый логгер из девятого урока, на который влияет SetOutput.
Отдельно честность про Debug: в дефолтном обработчике нет стека вызовов — только текст ошибки в поле "error". Никакой магии трассировки Echo при Debug не добавляет, ни в ответ, ни в лог.
Что получит клиент на error-сообщении
Прочитайте код и выберите, что он выведет:
func handler(c echo.Context) error {
quota := checkQuota() // вернула ошибку
return echo.NewHTTPError(http.StatusTooManyRequests, quota)
// quota = errors.New("rate limit exceeded"), e.Debug = false
}Debug-режим: поле error и цена откровенности#
Поле e.Debug bool включает в дефолтном обработчике одну добавку: для строкового Message ответ собирается как Map{"message": m, "error": err.Error()}. В поле "error" уезжает полный текст he.Error() — то есть «code=404, message=нет задачи, internal=...». Смотрите, что это значит: если причина лежала в Internal, Debug вытащит её наружу через err.Error() — вместе с внутренностями вроде текстов SQL-ошибок. Второй эффект e.Debug вы уже знаете из девятого урока: configureServer поднимает уровень e.Logger до log.DEBUG. Стека вызовов нет ни там, ни там — только тексты.
Практический вывод двоякий. В разработке Debug включать стоит: поле "error" в ответе экономит поездки в терминал. В продакшене — выключать: err.Error() становится телом ответа, а значит, публичным интерфейсом. Это тот же урок, что мы разбирали в fiber: там дефолтный ErrorHandler отвечал text/plain с err.Error() напрямую, и мы строили конверт, который прячет причину за человекочитаемым текстом. Echo дефолтом честнее — err.Error() у клиента только при Debug, — но принцип один: наружу — Message, внутрь лога — Internal. И следите за текстами, которые кладёте в Message: они уезжают клиенту при любом режиме.
Проверка: Debug выворачивает Internal наружу
e.Debug = true. Обработчик вернул echo.NewHTTPError(http.StatusNotFound, "нет задачи").SetInternal(errors.New("sql: no rows in result set")). Какое тело получит клиент?
Каталог Err*, 404/405 как обработчики и RouteNotFound#
Echo держит наготове каталог готовых ошибок: echo.go определяет полсотни пакетных переменных от ErrBadRequest до ErrNetworkAuthenticationRequired — ErrNotFound, ErrUnauthorized, ErrTooManyRequests, ErrTeapot и так далее. Каждая — echo.NewHTTPError(соответствующий код) без сообщения, то есть с текстом StatusText. Ими удобно пользоваться как sentinel-значениями: return echo.ErrNotFound короче, чем конструктор, и сравнение ошибок по идентичности работает.
С каталогом связаны два штатных обработчика, о которых шестой урок сказал вскользь: «404 в Echo это тоже обработчик, просто штатный». Развяжем: NotFoundHandler — пакетная переменная, тело которой целиком return ErrNotFound, — подставляется роутером, когда путь не нашёлся; MethodNotAllowedHandler — return ErrMethodNotAllowed — когда путь есть, а метода среди его маршрутов нет: роутер в этом случае отдаёт 405, а не 404, и кладёт в контекст строку допустимых методов (ctx.Set(ContextKeyHeaderAllow, ...)), откуда обработчик достаёт её c.Get-ом и ставит заголовок Allow — так требует RFC 7231, раздел 7.4.1. Обе ошибки едут наверх как обычные и оформляются дефолтным обработчиком: клиент получает 404 {"message":"Not Found"} или 405 с заголовком Allow.
Хотите свой 404 — не патчьте обработчик, а зарегистрируйте маршрут-перехватчик: e.RouteNotFound("/*", func(c echo.Context) error { return c.NoContent(http.StatusNotFound) }). Внутри это обычный e.Add со специальным методом-маркером "echo_route_not_found"; роутер выполняет такой маршрут вместо штатного NotFoundHandler, и ваш брендированный JSON-конверт поедет и на несуществующие пути.
echo.ErrNotFound — не шаблон, а конкретное значение, один на весь процесс: каждая горутина, вернувшая echo.ErrNotFound, вернула один и тот же указатель. Строка echo.ErrNotFound.Message = "задача не найдена" или echo.ErrNotFound.SetInternal(err) изменит ответ ВСЕМ будущим запросам, которые вернут эту ошибку, — и добавит гонку данных при конкурентных запросах. Своё сообщение — всегда echo.NewHTTPError(http.StatusNotFound, "задача не найдена"): конструктор создаёт новый экземпляр на каждый вызов. Причину к пакетной ошибке прикрепляйте не SetInternal, а echo.ErrNotFound.WithInternal(err) — клонированием, которое ради этого и заведено: новый указатель, оригинал не тронут.
Канон урока: APIError и sentinel-ошибки домена
GO// apierror.go — доменные ошибки учебного API задач
var ErrTaskNotFound = errors.New("task not found")
// APIError — ошибка со статусом, машиночитаемым кодом и текстом наружу
type APIError struct {
Status int // HTTP-статус: 404, 409, 422...
Code string // "task_not_found" — для клиента и метрик
Message string // человекочитаемый текст в конверт {error, code}
cause error // исходная причина — только для лога
}
func (e *APIError) Error() string { return e.Message }
// Unwrap — errors.Is видит причину сквозь APIError
func (e *APIError) Unwrap() error { return e.cause }
func newAPIError(status int, code, msg string, cause error) *APIError {
return &APIError{Status: status, Code: code, Message: msg, cause: cause}
}
// обработчик канона: /api/tasks/:id
func getTask(c echo.Context) error {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "id должен быть числом")
}
t, err := tasks.Find(id)
if err != nil {
// sentinel из хранилища докатывается сюда через Unwrap
return newAPIError(http.StatusNotFound, "task_not_found",
fmt.Sprintf("задача %d не найдена", id), err)
}
return c.JSON(http.StatusOK, t)
}Зачем свой тип, если есть HTTPError? Разделение труда: APIError описывает домен («задачи не существует», «конфликт версий») и не зависит от фреймворка — его можно вернуть из сервиса, который ничего не знает про echo. Причина живёт в непубличном поле cause: наружу уйдёт Message, а err.Error() с текстами хранилища — только в лог через Unwrap. errors.Is(err, ErrTaskNotFound) работает и в тестах, и в ErrorHandler — sentinel докатывается сквозь cause. Импорты для файла: errors, fmt, net/http, strconv плюс echo.
Свой ErrorHandler: единый формат {error, code} для всего API#
Замена — присвоение в поле: e.HTTPErrorHandler = вашаФункция с той же подписью func(err error, c Context). Никаких конфигов и промежуточных режимов: поле перезаписано — дефолта больше нет, и вместе с ним исчезают его заботы. Их три, и повторить их обязан ваш обработчик. Первое — guard Committed: if c.Response().Committed { return } — без него двойной ответ не случится (net/http защитит сам), но e.Logger получит Warn «response already committed» на каждом таком случае. Второе — обязанность ответить ВСЕГДА: обработчик получает каждую ошибку приложения, и путь «молча выйти» есть только у Committed-ветки. Третье — логирование: дефолт пишет в e.Logger только неудачу отправки; вашу возможность логировать каждую ошибку с причиной никто, кроме вас, не реализует.
Зато взамен вы получаете главный приз: единый формат. Конверт {"error": текст, "code": статус} для всех ошибок API — тот же Envelope-стиль, что мы собирали в fiber: там ErrorHandler из конфига строил {error, code} и HTML-страницу для браузера, и аргументы там те же: фронтенд пишет один catch, в конверт добавляются новые поля без миграции клиентов, а обработчик ошибок — единственное место, где формат enforced. Дефолт echo говорит на ключе "message" — выбирая конверт, выбирайте один и держите его во всех ответах, включая 404 и 405 от роутера: они тоже приедут в ваш обработчик, потому что 404/405 — тоже обработчики, и их ошибки пойдут общим потоком.
Разделение API и HTML делается по пути запроса: strings.HasPrefix(c.Request().URL.Path, "/api") — JSON-конверт, иначе — HTML-страница «что-то сломалось». Тот же приём, что у fiber; путь берите из запроса, а не из c.Path(): на 404 шаблон маршрута пуст.
ErrorHandler канона: /api/tasks с конвертом и логом
GOfunc main() {
e := echo.New()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
e.Use(middleware.RequestID())
e.Use(middleware.Recover())
api := e.Group("/api", bearerAuth(AuthConfig{Token: "dev-token-42", Username: "alice"}))
api.GET("/tasks", listTasks)
api.GET("/tasks/:id", getTask)
// единая точка оформления ошибок всего приложения
e.HTTPErrorHandler = func(err error, c echo.Context) {
if c.Response().Committed {
return // ответ уже уехал — как в дефолте, echo.go:428
}
code, msg := http.StatusInternalServerError, "Internal Server Error"
var ae *APIError
var he *echo.HTTPError
switch {
case errors.As(err, &ae): // доменная ошибка — частная ветка первая
code, msg = ae.Status, ae.Message
case errors.As(err, &he): // фреймворк-ошибка — общая ветка вторая
code = he.Code
if m, ok := he.Message.(string); ok {
msg = m
}
}
// наружу — Message, в лог — причина: не перепутайте
logger.Error("request failed",
slog.String("request_id",
c.Request().Header.Get(echo.HeaderXRequestID)),
slog.Int("status", code),
slog.String("cause", err.Error()),
)
if strings.HasPrefix(c.Request().URL.Path, "/api") {
_ = c.JSON(code, echo.Map{"error": msg, "code": code})
return
}
_ = c.HTML(code,
fmt.Sprintf("<h1>%d</h1><p>%s</p>", code, msg))
}
e.Logger.Fatal(e.Start(":1323"))
}Лестница распознавания: errors.As на *APIError прежде общей ветки — ошибки не бывают двумя типами сразу только по договорённости, частные ветки идут первыми; errors.As, а не assertion, — чтобы видеть сквозь обёртки fmt.Errorf. В ветке HTTPError строка Message идёт в конверт, а нестроковые (Map, структура) в учебном конверте заменяются дефолтным текстом — если нужны структурированные детали, добавьте свою ветку, но один ключ "error" сохраняйте. В лог уходит err.Error() целиком — с Internal; request_id берётся из заголовка X-Request-Id, который RequestID из девятого урока ставит до next(c). Возвращаемое значение c.JSON игнорируем подчёркиванием: ошибка отправки внутри ErrorHandler уже некуда деть — можно logger.Error по месту. Debug-ветку добавляйте сами: if e.Debug { msg = err.Error() } — осознанно и только для dev.
Проверка: забыли guard Committed
В своём ErrorHandler guard Committed не написан. Хендлер ответил c.JSON(200, ...), затем middleware на раскрутке вернул ошибку, ErrorHandler зовёт c.JSON(500, конверт). Что произойдёт?
WithInternal: кто изменился
Прочитайте код и выберите, что он выведет:
he := echo.ErrForbidden.WithInternal(errors.New("подписка истекла"))
fmt.Println(he.Error())
fmt.Println(echo.ErrForbidden.Internal == nil)Кто вернул ошибку — что увидит клиент#
| Источник ошибки | Дефолтный ответ (Debug выключен) | Куда уходит причина |
|---|---|---|
| echo.NewHTTPError(404, "нет задачи") | 404 и {"message":"нет задачи"} | Message — наружу; Internal пуст |
| &HTTPError{Code: 500, Internal: err} | 500 и {"message":"Internal Server Error"} | Internal — в лог через err.Error() |
| обычная errors.New("dial tcp: refuse") | 500 и {"message":"Internal Server Error"} | текст ошибки — только в лог, клиенту не светит |
| echo.NewHTTPError(429, errors.New("квота исчерпана")) | 429 и {"message":"квота исчерпана"} | error-Message печатается — текст публичен |
| роутер: путь не нашёлся (NotFoundHandler) | 404 и {"message":"Not Found"} | или e.RouteNotFound("/*", ...) — свой ответ |
| роутер: путь есть, метод нет (MethodNotAllowedHandler) | 405 и {"message":"Method Not Allowed"} + заголовок Allow | список методов — из ContextKeyHeaderAllow |
Соберите ErrorHandler канона
Заполните пропуски: тип контекста, guard от двойного ответа, распознавание сквозь обёртки, поле статуса и префикс API-пути.
Соберите main() канона с ErrorHandler
Расставьте строки в правильном порядке: Echo создаётся первой, логгер — до ErrorHandler (замыкание его ловит), обработчик назначается до группы, запуск — последним.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: HTTPError и ErrorHandler
Что дальше: группы и версии API#
Мы закрыли вертикаль ошибок: HTTPError с трио Code/Message/Internal и конструктором NewHTTPError, дефолтный конвейер с guard-ом Committed и Debug-полем error, каталог Err* с правилом «не мутировать», 404/405 как штатные обработчики с Allow и RouteNotFound, и собственный ErrorHandler, который собирает единый формат {error, code} для /api, HTML для страниц и структурный лог с причиной и request_id. bearerAuth из восьмого урока в этом main по-прежнему кладёт пользователя под ctxUserKey — доменные APIError можно обогащать им в ErrorHandler, если контракт ошибки требует автора запроса.
Следующая тема прячет в себе то, чем мы пользовались четыре урока подряд: e.Group("/api", ...) был заглушкой, а на деле — целый слой организации маршрутов. Одиннадцатый урок соберёт группы и версии API: общие middleware на поддерево маршрутов, префиксы /v1 и /v2 рядом с одной базой обработчиков, изоляция конфигов между версиями и типичные ловушки сквозных зависимостей. Пока проверьте себя карточками, соберите fill и order ещё раз и проведите ErrorHandler через боевые сценарии: bearerAuth с неверным токеном должен дать в конверте "error": "Unauthorized" и code 401, /api/tasks/abc — 400 с текстом про числовой id, а /api/tasks/999 — 404 task_not_found с причиной от хранилища в логе. Навигация по курсу — на странице курса Echo, звенья и c.Set/c.Get — в уроке про кастомные middleware, HandleError и RequestID — в уроке про логирование, конверт {error, code} и его аргументы — в уроке fiber про ошибки.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Обработка ошибок echo — один глобальный HTTPErrorHandler: всё, что вернулось не-nil из хендлера или middleware, стекает в e.HTTPErrorHandler (по умолчанию DefaultHTTPErrorHandler), а c.Error(err) зовёт его вручную из середины цепочки.
- HTTPError — три поля: Code (статус, json:"-"), Message interface{} (json:"message") и Internal (причина, json:"-"). NewHTTPError(code, msg...) подставляет StatusText по умолчанию; литерал без Message даёт тело "null".
- Дефолтный обработчик: guard Committed → return, не-HTTPError → 500 «Internal Server Error», строка → {"message"}, error-Message → m.Error(), HEAD → NoContent, неудача отправки → e.Logger.Error. При e.Debug строковый Message получает поле "error": err.Error() — с Internal внутри; стека нет.
- SetInternal мутирует приёмник, WithInternal возвращает клон — единственный безопасный способ прикрепить причину к пакетным Err*; Unwrap отдаёт Internal, поэтому errors.Is/errors.As видят причину сквозь HTTPError.
- 404/405 — тоже обработчики: NotFoundHandler и MethodNotAllowedHandler возвращают ErrNotFound/ErrMethodNotAllowed, роутер кладёт список методов в ContextKeyHeaderAllow для заголовка Allow; свой 404 — e.RouteNotFound("/*", h).
- Свой ErrorHandler (e.HTTPErrorHandler = ...) обязан повторить guard Committed, отвечать всегда и логировать с причиной; там строится единый конверт {"error", "code"} для всего API — Envelope-стиль, как в fiber, с веткой HTML по префиксу пути.
- Доменные ошибки канона: sentinel ErrTaskNotFound + APIError{Status, Code, Message, cause} с Unwrap — причина докатывается в лог и в errors.Is, наружу уходит только человекочитаемый Message.
Домашнее задание
Перенесите ErrorHandler канона в учебный сервер восьмого урока. Первое: добейтесь, чтобы все три источника отказов — bearerAuth (неверный dev-token-42), getTask с нечисловым id и getTask с несуществующим id — возвращали конверт {"error", "code"} с разными статусами и кодами; проверьте curl-ом с заголовком Authorization и без. Второе: добавьте в конверт поле request_id из заголовка X-Request-Id и убедитесь, что оно совпадает с request_id в JSON-логе ErrorHandler. Третье: включите e.Debug = true и посмотрите, что изменилось в ответе на ошибку с Internal, — затем верните false и напишите себе правило, когда Debug можно включать. Четвёртое: зарегистрируйте e.RouteNotFound("/*", ...) с вашим конвертом и сравните ответ на /api/missing до и после.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем свой ErrorHandler отличается от дефолтного DefaultHTTPErrorHandler?
Подпись одна и та же — func(err error, c echo.Context); различие в контракте. Дефолт знает ровно три вещи: guard Committed, превращение HTTPError в {"message": ...} и 500 для всего остального; логирует только неудачу отправки. Ваш обработчик заменяет его целиком и сам решает: конверт {"error", "code"}, разделение API/HTML по пути, структурный лог с причиной и request_id, Debug-ветка. Обязаны повторить guard Committed первой строкой — иначе каждая ошибка после уехавшего ответа будет писать Warn «response already committed» и теряться.
Почему валидационная ошибка приходит клиенту как 500 Internal Server Error?
Потому что вы вернули обычный error (errors.New или fmt.Errorf), а дефолтный обработчик считает не-HTTPError внутренней аварией: собирает заглушку с Code 500 и текстом StatusText. Статус описывает положение клиента, поэтому предсказуемые ситуации оборачивайте в echo.NewHTTPError(http.StatusBadRequest, ...) или в доменный APIError со своим Status — ErrorHandler канона читает Status через errors.As. Обычная ошибка без обёртки — законный способ сказать «это сбой сервера», и текст её клиент не увидит.
Как логировать ошибки так, чтобы их можно было найти по запросу?
В ErrorHandler, потому что туда стекает каждая ошибка. Логируйте структурно: slog.Error с атрибутами status, cause (err.Error() — там приедет и Internal через метод Error()), request_id из заголовка X-Request-Id, который middleware.RequestID ставит до next(c). Причину и текст наружу не смешивайте: клиенту — Message из конверта, в лог — cause. Тот же request_id добавьте в access-запись через LogRequestID из девятого урока — и по одному значению склеится и ответ, и ошибка, и access-строка.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
GORM: ORM-библиотека для Go
Курс GORM: модели, CRUD, ассоциации, транзакции, хуки, пагинация, оптимизация запросов. 25 уроков с примерами и проектом слоя данных.
- Подключать БД и настраивать пул соединений
- Описывать модели и связи: 1:1, 1:N, M:N
- Писать CRUD и сложные выборки chainable-стилем
- Контролировать N+1 через Preload