Ошибка — это часть контракта, а не «как-нибудь упадёт». Если в спеке прописан только счастливый путь, разработчики придумают ошибки сами, и каждый по-своему. Базовая таксономия по коду ответа: 4xx — виноват клиент (кривой запрос, нет прав) — ретраить бесполезно, ничего не изменится; 5xx — виноват сервер и 429 — слишком много запросов — ретраить можно, проблема временная. Тело ошибки должно быть единым на весь API: машиночитаемый code (для кода), человекочитаемый message (для людей), details (что именно не так) и traceId (для поиска в логах). Ретраи делают не подряд, а с экспоненциальной задержкой и джиттером, и обязательно идемпотентно — иначе ретрай платежа спишет дважды.
Однажды я разбирал инцидент, где мобильное приложение показывало пользователям «Что-то пошло не так» на любой сбой — и на «нет товара на складе», и на «упал сервис оплаты». Поддержка тонула: люди звонили с проблемой, которую мог бы решить понятный текст, а в логах не было ничего, чтобы отличить один случай от другого. Корень был не в коде — в контракте. Никто не описал, какие ошибки бывают, как они выглядят и что с ними делать. С тех пор я отношусь к ошибкам как к полноправной части API: их проектируют так же тщательно, как успех. Коды ответов в целом я обзорно разбирал в записи про REST API — здесь копаю именно в ошибки.
Почему ошибки — часть контракта
Контракт API — это обещание «попросишь так — получишь так». Но запрос не всегда успешен, и что приходит в неуспешном случае — такое же обещание. Если оно не зафиксировано, происходит ровно то, что в моей истории: фронтенд не знает, отличать ли «нет прав» от «сервис лежит», поддержка не знает, что сказать пользователю, а ретраи делаются наугад.
В записи про REST я показывал группы кодов обзорно. Здесь важна мысль: код ответа — это только заголовок ошибки. Полноценный контракт ошибки описывает ещё и тело (что именно пошло не так, в едином формате) и поведение (можно ли это ретраить). Все три части — work аналитика.
Таксономия: что ретраить, а что нет
Главный практический вопрос про любую ошибку: повтор поможет или нет? Ответ почти полностью определяется группой кода.
4xx — виноват клиент. Запрос сформулирован неправильно: не хватает поля (400), не представился (401), нет прав (403), ресурса нет (404), конфликт состояния (409). Повторять бессмысленно — пока клиент не изменит сам запрос, результат будет тем же. Ретраить 4xx — это упорно стучаться в запертую дверь.
5xx — виноват сервер. Запрос был нормальный, но на той стороне что-то сломалось (500) или сервис временно недоступен (503). Проблема обычно временная — сервер перезапустится, нагрузка спадёт. Такое можно и нужно ретраить, но с паузами.
429 — особый случай. Формально это 4xx, но смысл другой: «запрос нормальный, просто их слишком много». Это сигнал притормозить, и его ретраят — после паузы, которую сервер часто подсказывает заголовком. Подробно про лимиты — в записи про rate limiting и throttling.
| Код | Смысл | Ретраить? |
|---|---|---|
| 400 Bad Request | кривой запрос | нет — чинить запрос |
| 401 Unauthorized | не представился | нет — сначала авторизоваться |
| 403 Forbidden | нет прав | нет |
| 404 Not Found | ресурса нет | нет |
| 409 Conflict | конфликт состояния | нет — разрешить конфликт |
| 422 Unprocessable | данные не прошли валидацию | нет |
| 429 Too Many Requests | превышен лимит | да — после паузы |
| 500 Internal Error | сломалось на сервере | да — с задержкой |
| 503 Service Unavailable | сервис недоступен | да — с задержкой |
Единый формат тела ошибки
Код — это группа. Конкретику несёт тело. И ключевое требование: тело ошибки должно быть одинаковым по структуре на весь API, чтобы клиент написал один обработчик, а не по парсеру на каждую ручку. Рабочий минимум — четыре поля:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"code": "ORDER_ITEM_OUT_OF_STOCK",
"message": "Товара A-100 нет в наличии",
"details": [
{ "field": "items[0].sku", "issue": "out_of_stock", "available": 0 }
],
"traceId": "b3f1c2-9981-4a2e"
}
code — машиночитаемый строковый идентификатор (ORDER_ITEM_OUT_OF_STOCK). По нему клиент-программа ветвит логику: показать «нет на складе» иначе, чем «нет прав». Он стабилен и не зависит от языка.
message — человекочитаемое описание. Для разработчика в логах или для показа пользователю. Менять его текст можно — на него не завязывают код.
details — что именно не так, по полям. Какое поле, в чём проблема. Незаменимо для форм: фронт подсветит конкретное поле.
traceId — идентификатор запроса, по которому его находят в логах и трейсах. Пользователь называет traceId поддержке — инженер мгновенно находит этот конкретный запрос. Про сквозную трассировку — в записи про логи, метрики и трейсы.
code для машин, message для людей
Не заставляйте клиента парсить текст message, чтобы понять тип ошибки, — текст меняется, переводится, переписывается, и логика на нём ломается. Ветвление кода — всегда по полю code. message — только для глаз. Это разделение нужно проставить в контракте явно, иначе фронтендер начнёт сравнивать строки вроде «нет в наличии», и первый же рерайт текста уронит логику.
Что показать пользователю, а что залогировать
Одна ошибка живёт в двух мирах, и путать их опасно. Пользователю — короткий понятный текст и, по возможности, действие: «Товара нет в наличии, уберите его из корзины». В лог — всё остальное: стек, внутренние причины, traceId, контекст.
Граница тут не только про удобство — про безопасность. Во внешнее тело ошибки нельзя протаскивать внутренности: SQL-запросы, пути файлов, имена внутренних сервисов, и тем более персональные данные. Сообщение «user mironov@example.com not found» уже сливает и факт регистрации, и сам email. Что считать чувствительным и почему его нельзя светить — в записи про данные и PII. Правило простое: наружу — нейтральный текст и code, всё подробное — в лог под traceId.
Ретраи: экспоненциальная задержка и джиттер
Допустим, пришёл 503 — ретраим. Но как? Наивный вариант «повторять сразу и подряд» — худший: сервис и так на коленях, а сотни клиентов добивают его шквалом повторов. Это называют «грозой повторов» (retry storm), и так временный сбой превращают в полноценный отказ.
Правильно — экспоненциальная задержка (exponential backoff): пауза удваивается с каждой попыткой. Подождали 1 секунду, не вышло — 2, потом 4, потом 8. Клиент быстро отступает, давая серверу прийти в себя.
Но и этого мало. Если тысяча клиентов упали одновременно, они и повторят одновременно — через 1с, через 2с, через 4с, синхронным залпом. Лекарство — джиттер (jitter): к каждой паузе добавляют случайный разброс. Не «ровно 2 секунды», а «2 секунды плюс-минус случайные доли». Тогда повторы размазываются во времени, и сервер не получает синхронных ударов. На пальцах: backoff отводит толпу подальше, джиттер не даёт ей вернуться строем.
Ретрай без идемпотентности — это двойное списание
Ретраить безопасно только то, что идемпотентно. Повторили запрос на 503, а первый-то на самом деле прошёл (упал только ответ) — и вот вместо одного действия два. Для платежей это прямой путь к двойному списанию. Поэтому ретраи и идемпотентный ключ — пара: ретраят с тем же ключом, чтобы повтор был безопасен. Детально — в записи про идемпотентные платежи.
Откуда это взялось
Долго каждый API изобретал свой формат тела ошибки — у одного error_code, у другого errorMessage, у третьего просто строка. В 2016 году вышел RFC 7807 «Problem Details for HTTP APIs» — попытка стандартизировать тело ошибки: поля type, title, status, detail, instance и тип application/problem+json. В 2023-м его обновил RFC 9457. На практике многие API берут не букву стандарта, а его идею — единое предсказуемое тело с машинным кодом и человеческим текстом, — что и важно: договориться о структуре внутри своего API ценнее, чем формально сослаться на RFC.
Как это спрашивают на собесе
Типичные формулировки: «Как бы вы спроектировали ошибки в API?», «Какие коды ретраить, а какие нет?», «Зачем нужен и code, и message?», «Что нельзя класть в тело ошибки?», «Что такое exponential backoff и зачем джиттер?». Сильный ответ: ошибки — часть контракта, тело единое (code/message/details/traceId), 4xx не ретраят, 5xx и 429 ретраят с экспоненциальной задержкой и джиттером, ретрай обязан быть идемпотентным, а наружу нельзя светить внутренности и PII. Связка «ретрай + идемпотентность» и упоминание retry storm — маркеры, что вы думали об этом не по учебнику.
Частые вопросы
Какие HTTP-ошибки можно ретраить, а какие нет?
Ретраят то, что временно. 5xx (500, 503) — сбой на сервере, обычно проходящий, повтор уместен. 429 (слишком много запросов) — ретраят после паузы, часто указанной сервером. А 4xx (400, 401, 403, 404, 409, 422) ретраить бесполезно: проблема в самом запросе, и пока клиент его не исправит, ответ будет тем же. Ретраить 4xx — лишняя нагрузка без шанса на успех.
Зачем в теле ошибки и code, и message, если можно одно?
Они для разных потребителей. code — машиночитаемый стабильный идентификатор (например ORDER_ITEM_OUT_OF_STOCK), по нему программа-клиент ветвит логику, и он не зависит от языка и формулировок. message — человекочитаемый текст для разработчика в логах или для пользователя; его можно переписывать и переводить. Если ветвить логику по тексту message, первый же рерайт фразы сломает обработку — поэтому код смотрит на code, человек на message.
Что такое exponential backoff с джиттером простыми словами?
Это правило, как повторять неудавшийся запрос. Exponential backoff — пауза между попытками удваивается: 1с, 2с, 4с, 8с, чтобы не добивать сервер шквалом повторов. Джиттер — случайный разброс, добавляемый к каждой паузе, чтобы множество клиентов не повторяли строго синхронно и не били залпами. Вместе они дают серверу время восстановиться и размазывают повторы во времени, превращая «грозу повторов» в ровный фон.