Большинство разработчиков пишут API снаружи внутрь: сначала happy path — то, что работает, потом, если время остаётся, добавляют обработку ошибок. Это ошибка. Ошибки в API — не исключение, а часть контракта. Мобильное приложение, внешний сервис, фронтенд — все они полагаются на то, что API говорит понятно не только когда всё хорошо, но и когда что-то пошло не так.
Плохо спроектированные ошибки — это четыре часа отладки, когда в ответ приходит 200 OK с телом {"success": false} и никакого намёка, что именно сломалось. Или 500 Internal Server Error на валидацию формы. Или строка "error" в поле, которое должно быть числом.
HTTP-статусы — не просто цифры
Первый и самый базовый инструмент — HTTP-статус. Его назначение часто игнорируют: возвращают 200 на всё подряд и кладут информацию об ошибке в тело. Это удобно писать, но неудобно читать.
Короткая шпаргалка:
2xx — успех
200 OK — запрос выполнен, данные в ответе
201 Created — ресурс создан (после POST)
204 No Content — выполнено, данных нет (например, при DELETE)
4xx — ошибка клиента
400 Bad Request — некорректные данные в запросе
401 Unauthorized — нет или невалидный токен
403 Forbidden — токен есть, но доступа нет
404 Not Found — ресурс не существует
409 Conflict — конфликт состояния (например, email уже занят)
422 Unprocessable Entity — данные технически корректны, но не проходят валидацию
429 Too Many Requests — превышен лимит запросов
5xx — ошибка сервера
500 Internal Server Error — что-то сломалось на сервере
502 Bad Gateway — проблема с upstream-сервисом
503 Service Unavailable — сервис временно недоступен
Разница между 401 и 403 многих путает. Простое правило: 401 — «я тебя не знаю, покажи документы», 403 — «знаю тебя, но сюда нельзя».
Разница между 400 и 422 тоже нетривиальна. 400 — запрос синтаксически кривой (невалидный JSON, не тот Content-Type). 422 — JSON пришёл нормально, но поле email не похоже на email. Многие используют только 400 для обоих случаев — это допустимо, если вы консистентны внутри одного API.
Структура тела ошибки
Статус-кода недостаточно. Клиенту нужно понять: что именно пошло не так, почему, и что с этим делать. Для этого нужно тело ошибки.
Хорошая структура выглядит примерно так:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Некорректные данные запроса",
"details": [
{
"field": "email",
"message": "Некорректный формат email"
},
{
"field": "password",
"message": "Пароль должен содержать минимум 8 символов"
}
],
"request_id": "req_a1b2c3d4"
}
}
code — машиночитаемый код ошибки. Важно, чтобы он был постоянным и задокументированным. Фронтенд или мобильное приложение может показывать разные сообщения в зависимости от кода — переведённые, адаптированные под пользователя. message меняться может, code — нет.
message — человекочитаемое описание. Избегайте технических деталей вроде SQLException: duplicate key value violates unique constraint — это утечка внутренних деталей и дыра в безопасности.
details — список конкретных проблем, особенно при валидации. Если пришли пять некорректных полей, скажите об этом сразу. Не заставляйте пользователя исправлять по одному.
request_id — идентификатор запроса. Когда пользователь пишет в поддержку «у меня ошибка», этот id позволяет найти конкретный запрос в логах за секунды.
Антипаттерны, которые встречаются везде
200 OK с ошибкой внутри
HTTP 200 OK
{
"success": false,
"error": "User not found"
}
Мониторинг, логи и кеши ориентируются на HTTP-статус. Если всё возвращает 200, нельзя настроить алёрты на ошибки на уровне инфраструктуры. Nginx, CloudFront, Cloudflare — они не знают, что у вас внутри success: false. Метрики будут показывать зелёный цвет, пока пользователи получают ошибки.
Разные форматы ошибок в одном API
В одном эндпоинте {"error": "not found"}, в другом {"message": "error", "code": 404}, в третьем просто строка "Unauthorized". Это катастрофа для тех, кто пишет клиент. Приходится обрабатывать каждый случай отдельно.
Решение простое: один формат для всего API, зафиксированный в документации в первый день проекта.
Слишком мало информации
HTTP 400
{ "error": "Bad request" }
Что именно не так? Какое поле? Что ожидалось? Это отладка вслепую.
Слишком много информации
HTTP 500
{
"error": "NullPointerException at com.example.service.UserService.findById(UserService.java:142)"
}
Другая крайность. Стек-трейс в ответе API — утечка реализации. По нему можно определить стек технологий, найти уязвимости, составить карту сервисов. В продакшене стек-трейс должен уходить только в логи, не в ответ клиенту.
Игнорирование rate limiting
Если не возвращать 429 Too Many Requests с заголовком Retry-After, клиенты будут бомбардировать сервер в retry-цикле и усугублять ситуацию. Правильный ответ:
HTTP 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1714070400
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Превышен лимит запросов. Попробуйте через 30 секунд."
}
}
Как обрабатывать ошибки на стороне клиента
Проектирование ошибок — половина дела. Вторая половина — как клиент их обрабатывает.
Не глотайте ошибки. Самая частая проблема в JavaScript:
try {
const data = await fetchUser(id);
setUser(data);
} catch (e) {
// TODO: handle error
console.log(e);
}
console.log — это не обработка. Пользователь видит бесконечный спиннер. Как минимум показывайте сообщение об ошибке.
Разделяйте типы ошибок. Ошибка сети (нет интернета), ошибка клиента (400–499) и ошибка сервера (500+) — разные ситуации с разными реакциями. Для ошибок сети: «Проверьте подключение к интернету». Для 401 — редирект на страницу входа. Для 500 — «Что-то пошло не так, мы уже разбираемся».
Retry-логика только для нужных кодов. Повторять запрос имеет смысл при 429 (с задержкой из Retry-After), 502, 503, 504. Повторять 400 или 404 бессмысленно — ответ не изменится. Повторять 401 без обновления токена — тоже.
async function fetchWithRetry(url, options, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After') || 5;
await sleep(retryAfter * 1000);
continue;
}
if (response.status >= 500 && attempt < maxRetries - 1) {
await sleep(Math.pow(2, attempt) * 1000); // exponential backoff
continue;
}
return response;
}
}
Логируйте ошибки на фронтенде. Sentry или аналог. Важно логировать не только факт ошибки, но и контекст: что делал пользователь, какие данные отправлял (без персональных). Это сокращает время отладки в разы.
RFC 7807 — стандарт, о котором мало знают
Если хотите опереться на стандарт, есть RFC 7807 — «Problem Details for HTTP APIs». Он определяет формат тела ошибки:
{
"type": "https://example.com/errors/validation-error",
"title": "Ошибка валидации",
"status": 422,
"detail": "Поле email обязательно",
"instance": "/users/register"
}
type — URI, описывающий тип ошибки (может быть ссылкой на документацию). instance — конкретный URL, при обращении к которому возникла ошибка. RFC 7807 используют некоторые сервисы Microsoft, платёжные системы и банковские API. Если проектируете публичный API или работаете с внешними партнёрами — стоит рассмотреть.
Документация ошибок
Часто в документации API описывают только happy path. Хорошая документация описывает:
- Какие статус-коды возвращает каждый эндпоинт
- Какие коды ошибок (
code) возможны
- Что означает каждый код
- Как клиенту на него реагировать
Пример таблицы:
| Код |
Статус |
Описание |
Действие клиента |
VALIDATION_ERROR |
422 |
Некорректные данные |
Показать ошибки пользователю |
NOT_FOUND |
404 |
Ресурс не существует |
Страница 404 |
UNAUTHORIZED |
401 |
Невалидный токен |
Обновить токен или разлогинить |
RATE_LIMIT_EXCEEDED |
429 |
Превышен лимит |
Повторить через Retry-After секунд |
Коды ошибок — это публичный контракт. Никогда не удаляйте их и не меняйте их смысл без мажорного версионирования API. Добавлять новые поля в тело — безопасно, переименовывать существующие — нет.
Тестирование обработки ошибок
Ошибки нужно тестировать так же, как и успешные сценарии.
Юнит-тесты для каждого кода. Проверяйте, что при конкретных условиях возвращается правильный статус и правильная структура тела. Это занимает 10 минут сейчас и экономит часы потом.
Contract testing. Инструменты вроде Pact позволяют зафиксировать контракт между сервисами — включая формат ошибок. Если API меняет структуру ответа, тест ломается до деплоя, а не после.
Намеренно ломайте зависимости. Отключите базу данных — должен прийти 503, не 500. Сделайте так, чтобы внешний сервис отвечал с таймаутом — клиент должен получить понятный ответ, а не зависнуть на 30 секунд.
Смотрите на продакшен-ошибки регулярно. Если 400 сыплется тысячами на один эндпоинт — либо клиент делает что-то не так, либо API неправильно задокументирован. Оба варианта требуют реакции.
В REEXY при проектировании API для клиентских проектов формат ошибок фиксируется в самом начале — до написания первого эндпоинта. Это снимает целый класс вопросов при интеграции фронтенда и значительно упрощает дальнейшую поддержку.
Хорошо спроектированные ошибки — это не педантизм. Это уважение к тем, кто будет интегрироваться с вашим API: к команде фронтенда, к партнёрам, к себе через полгода. Когда ошибка говорит точно, что пошло не так и что с этим делать, отладка превращается из квеста в рутину.