коротко

Ошибка — это часть контракта, а не «как-нибудь упадёт». Если в спеке прописан только счастливый путь, разработчики придумают ошибки сами, и каждый по-своему. Базовая таксономия по коду ответа: 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с, чтобы не добивать сервер шквалом повторов. Джиттер — случайный разброс, добавляемый к каждой паузе, чтобы множество клиентов не повторяли строго синхронно и не били залпами. Вместе они дают серверу время восстановиться и размазывают повторы во времени, превращая «грозу повторов» в ровный фон.