Паттерны обработки ошибок в Go API, которые стандартизируют типизированные ошибки, сопоставление с HTTP-статусами, request ID и безопасные сообщения без утечки внутренних данных.

Когда каждый эндпоинт сообщает об ошибках по‑разному, клиенты перестают доверять вашему API. Один маршрут возвращает { \"error\": \"not found\" }, другой — { \"message\": \"missing\" }, а третий шлёт простой текст. Даже если смысл близок, клиентский код теперь вынужден угадывать, что произошло.
Цена проявляется быстро. Команды пишут хрупкую логику парсинга и добавляют специальные случаи под каждый эндпоинт. Повторы становятся рискованными, потому что клиент не может отличить «повторить позже» от «исправьте ввод». Количество запросов в поддержку растёт, потому что клиент видит расплывчатое сообщение, и ваша команда не может легко сопоставить его с записью в логе сервера.
Типичный сценарий: мобильное приложение вызывает три эндпоинта при регистрации. Первый возвращает HTTP 400 с картой ошибок по полям, второй отдаёт HTTP 500 со стектрэйсом, а третий возвращает HTTP 200 с { \"ok\": false }. Команда приложения выпускает три разных обработчика ошибок, а бэкенд‑команда продолжает получать отчёты вроде «регистрация иногда падает» без ясной точки старта.
Цель — один предсказуемый контракт. Клиенты должны надежно понимать, кто виноват (они или вы), стоит ли пробовать повтор и есть ли у них идентификатор запроса, который можно вставить в обращение в поддержку.
Примечание по области: это руководство про JSON HTTP API (не про gRPC), но те же идеи применимы везде, где вы возвращаете ошибки другим системам.
Выберите один понятный контракт для ошибок и заставьте все эндпоинты ему следовать. «Согласованность» означает одинаковую форму JSON, одинаковое значение полей и одинаковое поведение вне зависимости от того, какой обработчик упал. Как только это сделано, клиенты перестают угадывать и начинают корректно обрабатывать ошибки.
Полезный контракт помогает клиенту решить, что делать дальше. Для большинства приложений каждый ответ с ошибкой должен отвечать на три вопроса:
Практический набор правил:
Решите заранее, что никогда не должно появляться в ответах. Часто это: фрагменты SQL, трассировки стека, внутренние хостнеймы, секреты и сырые строки ошибок от зависимостей.
Держите чистое разделение: короткое, понятное пользователю сообщение (безопасное, вежливое, практическое) и внутренние детали (полная ошибка, стек и контекст) только в логах. Например: «Не удалось сохранить изменения. Пожалуйста, попробуйте ещё раз.» — безопасно. «pq: duplicate key value violates unique constraint users_email_key» — нельзя отдавать.
Когда все эндпоинты следуют одному контракту, клиенты могут написать один обработчик ошибок и переиспользовать его везде.
Клиенты могут корректно обрабатывать ошибки только если каждый эндпоинт отвечает одинаковой формой. Выберите один JSON‑конверт и сохраняйте его стабильным.
Практический дефолт — объект error плюс топ‑уровневый request_id:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields are invalid.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
},
"request_id": "req_01HV..."
}
HTTP‑статус даёт широкую категорию (400, 401, 409, 500). Машиночитаемый error.code определяет конкретный случай, по которому клиент может ветвиться. Это важно, потому что многие разные проблемы делят один и тот же статус. Мобильное приложение может показать разный UI для EMAIL_TAKEN и WEAK_PASSWORD, хотя оба — 400.
Держите error.message безопасным и понятным человеку. Оно должно помогать пользователю исправить проблему, но никогда не раскрывать внутренности (SQL, трассы стека, имена провайдеров, пути файлов).
Опциональные поля полезны, если они остаются предсказуемыми:
details.fields как карта поле → сообщение.details.retry_after_seconds.details.docs_hint как простой текст (не URL).Для обратной совместимости считайте значения error.code частью публичного контракта. Добавляйте новые коды, не меняя старые значения. Добавляйте только опциональные поля и предполагаете, что клиенты проигнорируют незнакомые поля.
Обработка ошибок путается, когда каждый обработчик придумывает свой способ сигнализировать о сбое. Небольшой набор типизированных ошибок исправляет это: обработчики возвращают известные типы ошибок, а один слой перевода превращает их в согласованные ответы.
Практический стартовый набор покрывает большинство эндпоинтов:
Ключ — стабильность на верхнем уровне, даже если корневая причина меняется. Вы можете оборачивать низкоуровневые ошибки (SQL, сеть, парсинг JSON), при этом возвращая тот же публичный тип, который поймёт middleware.
type NotFoundError struct {
Resource string
ID string
Err error // private cause
}
func (e NotFoundError) Error() string { return "not found" }
func (e NotFoundError) Unwrap() error { return e.Err }
В обработчике возвращайте NotFoundError{Resource: "user", ID: id, Err: err} вместо прямой утечки sql.ErrNoRows.
Для проверки ошибок предпочитайте errors.As для кастомных типов и errors.Is для sentinel‑ошибок. Сентинельные ошибки (например, var ErrUnauthorized = errors.New("unauthorized")) подходят для простых случаев, но пользовательские типы выигрывают, если нужно безопасно хранить контекст (например, какой ресурс отсутствует) без изменения публичного контракта ответа.
Будьте требовательны к тому, что вы добавляете:
Err, информация о стеке, сырые ошибки SQL, токены, данные пользователя.Это разделение помогает помогать клиентам, не раскрывая внутренности.
Когда у вас есть типизированные ошибки, следующая задача — скучная, но необходимая: одному и тому же типу ошибки всегда соответствовать один и тот же HTTP-статус. Клиенты строят логику вокруг этого.
Практическое сопоставление, подходящее для большинства API:
| Тип ошибки (пример) | Статус | Когда использовать |
|---|---|---|
| BadRequest (неправильный JSON, отсутствует обязательный query param) | 400 | Запрос некорректен на уровне протокола или формата. |
| Unauthenticated (отсутствует/некорректный токен) | 401 | Клиент должен аутентифицироваться. |
| Forbidden (нет прав) | 403 | Авторизация валидна, но доступ запрещён. |
| NotFound (ID ресурса не существует) | 404 | Запрошенный ресурс отсутствует (или вы решили скрыть существование). |
| Conflict (нарушение уникальности, несоответствие версии) | 409 | Запрос корректен, но конфликтует с текущим состоянием. |
| ValidationFailed (правила поля) | 422 | Форма запроса ок, но бизнес‑валидация провалилась (формат email, минимальная длина). |
| RateLimited | 429 | Слишком много запросов за окно времени. |
| Internal (неизвестная ошибка) | 500 | Баг или неожиданная ошибка. |
| Unavailable (зависимость упала, таймаут, обслуживание) | 503 | Временная проблема на стороне сервера. |
Две важные грани, которые предотвращают путаницу:
Рекомендации по повтору:
Request ID — короткое уникальное значение, идентифицирующее один API‑вызов «сквозь» систему. Если клиенты видят его во всех ответах, поддержка упрощается: «Пришлите request ID» часто достаточно, чтобы найти точный лог и причину ошибки.
Эта практика полезна как для успешных, так и для ошибочных ответов.
Используйте одно простое правило: если клиент прислал request ID — сохраните его. Если нет — создайте.
X-Request-Id).Кладите request ID в три места:
request_id в стандартной схеме)Для batch‑эндпоинтов или фоновых задач держите родительский request ID. Пример: клиент загружает 200 строк, 12 валидируются с ошибкой, вы ставите задачи в очередь. Верните один request_id для всего вызова и укажите parent_request_id в каждой задаче и в ошибках по элементам. Так вы сможете трассировать «одну загрузку», даже если она разойдётся на множество задач.
Клиенты нуждаются в понятном, стабильном ответе об ошибке. Ваши логи нуждаются в грязной правде. Разделяйте эти два мира: отдавайте клиенту безопасное сообщение и публичный код ошибки, а в логах храните внутреннюю причину, стек и контекст.
Логируйте одно структурированное событие для каждого ответного сообщения об ошибке, доступное для поиска по request_id.
Поля, которые стоит держать консистентными:
Храните внутренние детали только в серверных логах (или в внутреннем хранилище ошибок). Клиент никогда не должен видеть сырые ошибки БД, текст запросов, трассировки стека или сообщения провайдеров. В распределённой системе поле вроде source (api, db, auth, upstream) ускорит триаж.
Следите за «шумными» эндпоинтами и ошибками по ограничению частоты. Если эндпоинт генерирует 429 или 400 тысячи раз в минуту, избегайте спама в логах: семплируйте повторяющиеся события или снижайте уровень важности, при этом считая их в метриках.
Метрики ловят проблемы раньше, чем логи. Считайте количества по HTTP‑статусам и кодам ошибок и ставьте алерты на резкие всплески. Если RATE_LIMITED вырос в 10 раз после деплоя, вы увидите это быстро даже при семплинге логов.
Проще всего сделать ошибки согласованными, перестав обрабатывать их «везде», и пустив их через один небольшой pipeline. Этот pipeline решает, что видит клиент, а что остаётся в логах.
Начните с небольшого набора кодов ошибок, на который клиенты смогут опереться (например: INVALID_ARGUMENT, NOT_FOUND, UNAUTHORIZED, CONFLICT, INTERNAL). Оборачивайте их в типизированную ошибку, которая экспортирует только безопасные публичные поля (code, безопасное сообщение, опциональные details, например какое поле неверно). Внутренние причины держите приватными.
Затем реализуйте одну функцию‑транслятор, которая превращает любую ошибку в (statusCode, responseBody). Именно здесь типы ошибок мапятся на HTTP‑статусы, а неизвестные ошибки становятся безопасным 500.
Далее добавьте middleware, которое:
request_id у каждого запросаПаника никогда не должна сливать трассировки стека клиенту. Возвращайте обычный 500 с общим сообщением и логируйте полную панику с тем же request_id.
Наконец, поменяйте обработчики так, чтобы они возвращали error вместо непосредственной записи ответа. Один обёрточный слой вызовет обработчик, прогонит транслятор и запишет JSON в стандартном формате.
Короткий чеклист:
Golden‑тесты важны, потому что они фиксируют контракт. Если кто‑то позже поменяет сообщение или статус, тесты упадут до того, как клиенты получат неожиданные изменения.
Представьте эндпоинт: клиент создаёт запись customer.
POST /v1/customers с JSON { \"email\": \"[email protected]\", \"name\": \"Pat\" }. Сервер всегда возвращает одну и ту же форму ошибки и всегда включает request_id.
Email отсутствует или имеет некорректный формат. Клиент может подсветить поле.
{
"request_id": "req_01HV9N2K6Q7A3W1J9K8B",
"error": {
"code": "VALIDATION_FAILED",
"message": "Some fields need attention.",
"details": {
"fields": {
"email": "must be a valid email address"
}
}
}
}
Email уже существует. Клиент может предложить войти или выбрать другой email.
{
"request_id": "req_01HV9N3C2D0F0M3Q7Z9R",
"error": {
"code": "ALREADY_EXISTS",
"message": "A customer with this email already exists."
}
}
Зависимость недоступна. Клиент может повторить с экспоненциальным бэкоффом и показать спокойное сообщение.
{
"request_id": "req_01HV9N3X8P2J7T4N6C1D",
"error": {
"code": "TEMPORARILY_UNAVAILABLE",
"message": "We could not save your request right now. Please try again."
}
}
С одним контрактом клиент реагирует последовательно:
details.fieldsrequest_id как идентификатор для поддержкиДля поддержки этот же request_id — самый быстрый путь к реальной причине в внутренних логах, без раскрытия стек‑трейсов или ошибок базы данных.
Самый быстрый способ раздражать клиентов — заставить их угадывать. Если один эндпоинт возвращает { \"error\": \"...\" }, а другой — { \"message\": \"...\" }, каждый клиент превращается в груду специальных случаев, и баги скрываются неделями.
Типичные ошибки:
code, по которому клиенты могут ориентироваться.request_id только при ошибках, тогда нельзя сопоставить пользовательский отчёт с успешным вызовом, повлёкшим потом ошибку.Утечка внутренних данных — самая лёгкая ловушка. Обработчик возвращает err.Error() из удобства, и в продакшн попадает имя ограничения или сообщение третьей стороны. Держите сообщение для клиента безопасным и коротким, а подробную причину — в логах.
Ориентация только на текст — долгий пожар. Если клиент парсит английские предложения вроде «email already exists», вы не сможете менять формулировки без риска поломать логику. Стабильные коды ошибок позволяют менять сообщения, переводить их и сохранять поведение.
Относитесь к кодам ошибок как к части публичного контракта. Если нужно изменить код, добавьте новый и держите старый работающим некоторое время, даже если оба мапятся на один статус.
Наконец, включайте одно и то же поле request_id в каждый ответ, успешный или нет. Когда пользователь говорит «сначала работало, потом сломалось», этот один ID часто экономит час разбирательств.
Перед выпуском пройдитесь по простому чек‑листу на согласованность:
error.code, error.message, request_id).VALIDATION_FAILED, NOT_FOUND, CONFLICT, UNAUTHORIZED). Напишите тесты, чтобы обработчики не возвращали незнакомые коды по ошибке.request_id и логируйте его для каждого запроса, включая паники и таймауты.После этого прогоните пару ручных проверок. Вызовите ошибку валидации, обращение к несуществующей записи и неожиданную ошибку. Если ответы выглядят по‑разному на разных эндпоинтах (поля меняются, статус дрейфует, сообщения раскрывают лишнее) — исправьте общий pipeline прежде, чем добавлять новые фичи.
Правило: если сообщение поможет злоумышленнику или запутает обычного пользователя, оно должно оставаться в логах, а не в ответе.
Опишите контракт ошибок, которому должны следовать все эндпоинты, даже если API уже в продакшне. Общий контракт (статус, стабильный код ошибки, безопасное сообщение и request_id) — самый быстрый путь сделать ошибки предсказуемыми для клиентов.
Мигрируйте постепенно. Сохраните существующие обработчики, но пропустите их ошибки через один маппер, который превращает внутренние ошибки в публичную форму. Это повышает согласованность без рискованного большого рефактора и не позволит новым эндпоинтам придумывать формат.
Ведите небольшой каталог кодов ошибок и относитесь к нему как к части API. Если кто‑то хочет добавить новый код, быстро проверяйте: действительно ли он новый, понятное ли у него имя и соответствует ли он правильному HTTP‑статусу.
Добавьте несколько тестов, которые ловят дрейф:
request_id.error.code присутствует и берётся из каталога.error.message безопасно и не содержит внутренних деталей.Если вы строите backend на Go с нуля, полезно закрепить контракт на раннем этапе. Например, Koder.ai (koder.ai) включает режим планирования, где можно заранее прописать соглашения, такие как схема ошибок и каталог кодов, а затем держать обработчики в соответствии с ними по мере роста API.
Используйте одну JSON-форму для всех ответов с ошибкой на всех эндпоинтах. Практический стандарт — топ-уровневый request_id и объект error с полями code, message и опциональными details, чтобы клиенты могли надежно парсить и реагировать.
Возвращайте в error.message короткое, безопасное для пользователя сообщение и сохраняйте реальную причину в логах сервера. Не отдавайте сырые ошибки базы данных, трассировки стека, внутренние хостнеймы или сообщения внешних провайдеров — даже если это удобно во время разработки.
Используйте стабильный error.code для машинной логики, а HTTP-статус — для общей категории. Клиенты должны ориентироваться по error.code (например, ALREADY_EXISTS), а статус служит подсказкой (например, 409 — конфликт состояния).
Используйте 400, когда запрос нельзя надежно распарсить или интерпретировать (некорректный JSON, неправильные типы). Используйте 422, когда тело запроса валидно по форме, но нарушает бизнес-правила (неправильный формат email, слишком короткий пароль).
Используйте 409, когда ввод корректен, но не может быть применён из‑за конфликтующего состояния (email уже занят, расхождение версий). Используйте 422 для ошибок валидации полей, которые можно исправить, изменив значение без учёта состояния сервера.
Создайте небольшой набор типизированных ошибок (validation, not found, conflict, unauthorized, internal) и заставьте обработчики возвращать их. Затем используйте один общий транслятор, который будет сопоставлять эти типы со статусами и стандартной JSON-формой ответа.
Всегда возвращайте request_id в каждом ответе, успешном или ошибочном, и логируйте его в каждой строке логов сервера. Один такой ID обычно достаточно, чтобы по логам найти точный путь выполнения и причину ошибки, которую прислал клиент.
Возвращайте 200 только когда операция действительно удалась. Использование 200 с { ok: false } скрывает ошибки и заставляет клиентов парсить тело, что ведёт к непоследовательному поведению между эндпоинтами.
По умолчанию не пытайтесь повторять запросы для 400, 401, 403, 404, 409 и 422 — повтор не поможет без изменения запроса. Разрешайте повтор для 503 и иногда для 429 после ожидания; если вы поддерживаете idempotency key, повторы становятся безопаснее для POST при временных ошибках.
Фиксируйте контракт парой «золотых» тестов, которые проверяют статус, error.code и наличие request_id. Добавляйте новые коды ошибок, не меняя старое поведение, и добавляйте только опциональные поля, чтобы старые клиенты продолжали работать.