---
title: "Обработка ошибок HTTPError: Code, Message, Internal и свой ErrorHandler"
description: "echo httperror: поля Code, Message и Internal, NewHTTPError и SetInternal, дефолтный обработчик, Debug-режим и свой ErrorHandler с единым JSON-форматом."
course: "Echo: минималистичный быстрый фреймворк"
url: "https://golangmaster.ru/courses/echo/lessons/10-httperror/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Обработка ошибок HTTPError: Code, Message, Internal и свой ErrorHandler

*Курс «Echo: минималистичный быстрый фреймворк», урок 10 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/echo/lessons/10-httperror/*

echo httperror закрывает контракт, подписанный ещё с первого урока: каждый обработчик — это func(c echo.Context) error, и всё, что вернулось не-nil, стекает в одно место — поле HTTPErrorHandler структуры Echo. Дефолтный DefaultHTTPErrorHandler превращает *echo.HTTPError в JSON {"message": ...} с честным статусом, а любую другую ошибку — в 500 «Internal Server Error». В [восьмом уроке](/courses/echo/lessons/08-kastomnye-middleware/) мы возвращали echo.NewHTTPError из bearerAuth-заглушки, в [девятом](/courses/echo/lessons/09-logirovanie/) — видели, как RequestLogger с HandleError: true отдаёт ошибку глобальному обработчику до access-записи. Теперь разбираем обещанное целиком: поле Internal для исходной причины и SetInternal/WithInternal, Message, принимающий не только строки, Debug-режим с полем error, ошибки 404/405 как обработчики и собственный ErrorHandler с единым форматом {error, code} — конвертом в духе [урока fiber про ошибки](/courses/fiber/lessons/14-oshibki/).

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

- ✅ Статус 410 и тело {"message":"Gone"}: конструктор подставил http.StatusText(410)
- Статус 410 и пустое тело: сообщение не передано — передавать нечего
- Статус 500 и тело {"message":"Internal Server Error"}: без сообщения ошибка неполноценна
- Статус 410 и тело {"error":"Gone"}: ключ error — дефолтный

NewHTTPError(code) без вариадика оставляет Message = http.StatusText(code) — строку «Gone». Дефолтный обработчик для строкового Message собирает Map{"message": m}, и клиент видит {"message":"Gone"}. Пустого тела не бывает: nil-сообщение дало бы тело "null", но конструктор без аргументов nil не оставляет. Ключ "error" в дефолтном формате не существует — это ключ нашего конверта из второй половины урока, а в дефолте "error" появляется только в Debug-режиме и только для строкового Message.

### 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-сообщении

```go
func handler(c echo.Context) error {
        quota := checkQuota() // вернула ошибку
        return echo.NewHTTPError(http.StatusTooManyRequests, quota)
        // quota = errors.New("rate limit exceeded"), e.Debug = false
}
```

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

- ✅ 429 и {"message":"rate limit exceeded"}: ветка case error вызывает m.Error()
- 429 и {"error":"rate limit exceeded"}: ключ error — штатный для ошибок
- 500 и {"message":"Internal Server Error"}: error-сообщение дефолт не понимает
- 429 и {"message":{}}: ошибка маршалится как пустой объект

В switch по типу Message ветка case error срабатывает и собирает Map{"message": m.Error()} — текст ошибки попадает под ключ message. Вариант с ключом "error" — ловушка на путаницу с нашим конвертом {error, code} из второй половины урока и с Debug-полем: в дефолтном формате без Debug ключа "error" не существует вовсе. Вариант с 500 — ловушка на ветку «не HTTPError»: здесь ошибка — полноценная *HTTPError со статусом 429, до заглушки дело не доходит. И мораль сюжета: текст из errors.New уехал клиенту как есть — тексты ошибок из недр вашего кода становятся публичным интерфейсом, поэтому для наружных ответов пишите человекочитаемый Message, а причину кладите в Internal.

### 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")). Какое тело получит клиент?

- ✅ {"message":"нет задачи","error":"code=404, message=нет задачи, internal=sql: no rows in result set"}
- {"message":"нет задачи"} — Debug добавляет поле error только для 500-х
- {"message":"нет задачи","internal":"sql: no rows in result set"} — Internal печатается под своим ключом
- Стек вызовов в поле "error" и сообщение в поле "message"

Debug-ветка для строкового Message собирает Map{"message": m, "error": err.Error()}, где err — вся HTTPError. Её метод Error() при непустом Internal печатает format «code=%d, message=%v, internal=%v» — поэтому текст SQL-ошибки, спрятанный в Internal, оказывается в теле ответа. Поле "error" добавляется при любом статусе, не только для 500-х; ключа "internal" в дефолтном ответе нет — Internal не имеет json-тега наружу и попадает в ответ только через err.Error(). Стека в дефолтном обработчике нет вообще — это ровно тот факт, на котором ловятся ожидающие panic-recovery-трассировки.

### Каталог 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-конверт поедет и на несуществующие пути.

> **Err* — общие экземпляры: не мутируйте их.** 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 с конвертом и логом

```go
func 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, конверт). Что произойдёт?

- ✅ e.Logger получит Warn «response already committed», клиент оставит свой 200 — второй ответ не уедет
- Клиент получит 500 поверх 200: заголовки перезапишутся
- Паника: ответ уже отправлен, второй c.JSON невозможен
- Тело 500-го конверта дозапишется в конец тела 200-го

WriteHeader в response.go при уже установленном Committed не отправляет ничего и пишет e.Logger.Warn("response already committed") — тот самый логгер девятого урока; c.JSON упрётся в тот же клапан. Клиент получит свой 200, конверт ошибки потеряется — и это самая коварная ветка: ошибка исчезла из ответа, но её ещё можно поймать в логах по Warn-строке. Дефолтный обработчик решает вопрос раньше — guard-ом Committed на входе; ваш ErrorHandler обязан повторить его первой же строкой. Паники нет, дозаписи в тело нет: заголовки с Content-Length ушли, природу HTTP-ответа после этого не изменить.

### WithInternal: кто изменился

```go
he := echo.ErrForbidden.WithInternal(errors.New("подписка истекла"))

fmt.Println(he.Error())
fmt.Println(echo.ErrForbidden.Internal == nil)
```

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

- ✅ code=403, message=Forbidden, internal=подписка истекла — и true: WithInternal вернул клон, пакетная ошибка не тронута
- code=403, message=Forbidden, internal=подписка истекла — и false: SetInternal внутри изменил пакетную ошибку
- code=403, message=Forbidden — и true: WithInternal не кладёт причину, только возвращает he
- Паника: пакетные Err* защищены от записи

WithInternal собирает новый экземпляр HTTPError с теми же Code и Message и новой причиной — «returns clone of HTTPError with err set to Internal field» по доккомментарию. Пакетная echo.ErrForbidden остаётся нетронутой, её поле Internal — nil, сравнение даёт true. У клона Internal непуст, и Error() печатает полный формат с internal=... — именно поэтому WithInternal безопасен для общих Err*, а SetInternal, мутирующий приёмник, для них запрещён: он изменил бы ошибку для всего процесса. И помните эффект из блока про Debug: если у клиента e.Debug = true, этот текст причины выедет и в тело ответа.

### Кто вернул ошибку — что увидит клиент

| Источник ошибки | Дефолтный ответ (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-пути.

```go
e.HTTPErrorHandler = func(err error, c echo.___) {
        if c.Response().___ {
                return // ответ уже уехал
        }
        code, msg := http.StatusInternalServerError, "Internal Server Error"
        var he *echo.HTTPError
        if errors.___(err, &he) {
                code = he.___
                if m, ok := he.Message.(string); ok {
                        msg = m
                }
        }
        if strings.HasPrefix(c.Request().URL.Path, "/___") {
                _ = c.JSON(code, echo.Map{"error": msg, "code": code})
                return
        }
        _ = c.HTML(code, msg)
}
```

Подсказка: Подпись HTTPErrorHandler принимает echo.Context; guard — поле Committed у Response; сквозь обёртки видит errors.As; статус — поле Code; API-путь начинается с /api.

Правильные ответы в пропусках: `Context`, `Committed`, `As`, `Code`, `api`

### Соберите main() канона с ErrorHandler

Расставьте строки в правильном порядке: Echo создаётся первой, логгер — до ErrorHandler (замыкание его ловит), обработчик назначается до группы, запуск — последним.

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

1. `func main() {`
2. `  e := echo.New()`
3. `  logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))`
4. `  e.HTTPErrorHandler = apiErrorHandler(logger)`
5. `  api := e.Group("/api", bearerAuth(AuthConfig{Token: "dev-token-42", Username: "alice"}))`
6. `  api.GET("/tasks/:id", getTask)`
7. `  e.Logger.Fatal(e.Start(":1323"))`
8. `}`

### Карточки: HTTPError и ErrorHandler

- **echo.HTTPError: три поля** — Internal error (json:"-") — причина для лога; Message interface{} (json:"message") — то, что видит клиент: строка, error, структура, Map, json.Marshaler; Code int (json:"-") — HTTP-статус. Порядок объявления Internal → Message → Code: позиционный литерал кладёт числа в Internal — только именованные поля. Штатный образец из key_auth.go: {Code: 401, Message: "Unauthorized", Internal: lastValidatorErr}.
- **NewHTTPError и литерал** — func NewHTTPError(code int, message ...interface{}): Message по умолчанию http.StatusText(code), вариадик заменяет текст первым элементом. &echo.HTTPError{Code: 418} без Message даёт тело "null" (c.JSON(code, nil)). Каталог Err* (ErrNotFound, ErrTeapot, ~50 штук) — общие экземпляры на процесс: не мутировать, для причины — WithInternal (клон), для текста — новый NewHTTPError.
- **DefaultHTTPErrorHandler: конвейер** — 1) Committed → return (echo.go:428); 2) assertion *HTTPError, Internal-*HTTPError заменяет he на один этаж; 3) не-HTTPError → 500 "Internal Server Error"; 4) switch Message: string → Map{"message"} (+Debug: "error": err.Error()), Marshaler как есть, error → m.Error(), остальное — как есть; 5) HEAD → c.NoContent (#608), иначе c.JSON; 6) ошибка отправки → e.Logger.Error. Стека при Debug нет — только текст.
- **SetInternal / WithInternal / Unwrap** — SetInternal(err) мутирует и возвращает he — для своих экземпляров из NewHTTPError; WithInternal(err) возвращает клон {Code, Message, Internal} — единственный безопасный путь прикрепить причину к пакетным Err*. Unwrap() → Internal (Go 1.13): errors.Is/As видят причину сквозь HTTPError. Error() печатает «code=%d, message=%v» и при Internal «..., internal=%v» — формат для логов, не для клиента.
- **404 и 405 — тоже обработчики** — Роутер подставляет штатные NotFoundHandler (return ErrNotFound) и MethodNotAllowedHandler (return ErrMethodNotAllowed): путь есть, метода нет → 405 и заголовок Allow из c.Get(ContextKeyHeaderAllow) (RFC 7231 7.4.1). Обе ошибки оформляются дефолтным обработчиком — и в ваш ErrorHandler тоже приедут. Свой 404: e.RouteNotFound("/*", h) — метод-маркер "echo_route_not_found".
- **Свой ErrorHandler** — e.HTTPErrorHandler = func(err error, c Context): заменяет дефолт целиком. Обязан: guard Committed первой строкой; ответить всегда; логировать с причиной (err.Error(), Internal) и request_id из X-Request-Id. Распознавание: errors.As на частный *APIError прежде общей *echo.HTTPError, фолбэк 500. Единый конверт {"error", "code"} для /api (Envelope-стиль курса), HTML-страница для остального — ветка по префиксу пути из запроса.

### Что дальше: группы и версии 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](/courses/echo/), звенья и c.Set/c.Get — в [уроке про кастомные middleware](/courses/echo/lessons/08-kastomnye-middleware/), HandleError и RequestID — в [уроке про логирование](/courses/echo/lessons/09-logirovanie/), конверт {error, code} и его аргументы — в [уроке fiber про ошибки](/courses/fiber/lessons/14-oshibki/).

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

- Обработка ошибок 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-строка.
