Ошибки HTTP в проде: как строить понятный клиентский контракт (код, причина, поле и ретраи)
Покажем, как проектировать ошибки так, чтобы фронтенд и интеграции могли принимать решения: что ретраить, какие поля считать виноватыми, как различать 4xx/5xx и как стабилизировать формат ответа.
Содержание
Ошибки HTTP в проде: как строить понятный клиентский контракт (код, причина, поле и ретраи)
В продакшене HTTP-ошибки — это не «красные статусы в логах», а контракт между сервисами и фронтендом. Этот контракт определяет поведение клиента: нужно ли повторять запрос, какой компонент исправлять, как показать ошибку пользователю, и как логически маршрутизировать обработку внутри интеграций.
Проблема в том, что многие команды проектируют ошибки как побочный эффект реализации: где-то бросили 500, где-то забыли консистентность полей, где-то смешали бизнес-валидацию и инфраструктурные сбои, а где-то отдали «кривой» формат ответа. В результате клиенты вынуждены гадать — и начинают ретраить «всё подряд» или, наоборот, игнорировать случаи, когда повтор нужен.
Ниже — практический гайд, как строить понятный клиентский контракт для ошибок:
- какие статусы реально использовать (4xx vs 5xx),
- как передавать причину и поле (parameter-level blame),
- как сделать формат стабилизированным и расширяемым,
- как определить правила ретраев и не устроить ретрайн-шторм.
1) HTTP-статус: это не “код ошибки”, а сигнал классификации
1.1. Ключевой принцип: классифицируйте по вине и воспроизводимости
HTTP-статус должен отвечать на два вопроса:
- Кто виноват?
- клиент (запрос некорректен, отсутствуют обязательные данные, нарушены правила домена)
- сервер (не смог выполнить, сломалась зависимость, истёк таймаут, внутренняя ошибка)
- Можно ли исправить повтором?
- да (например, временная недоступность)
- нет (например, неверный формат JSON или невалидный идентификатор по формату/правилам)
Это напрямую влияет на ретраи.
1.2. Практическая схема: что отдавать в проде
Типовая модель:
- 400 Bad Request — запрос синтаксически неверен или не проходит базовую схему (например, JSON не распарсился).
- 401 Unauthorized — нет аутентификации (нет/плохой токен). Клиент может обновить токен.
- 403 Forbidden — аутентификация есть, но нет прав/доступа. Повтор обычно бесполезен.
- 404 Not Found — ресурс отсутствует (или вы намеренно скрываете наличие).
- 409 Conflict — конфликт состояния (версии, идемпотентность, бизнес-конфликт).
- 422 Unprocessable Entity — семантически невалидные данные (валидация домена). Это особенно полезно, когда вы хотите вернуть список проблем по полям.
- 429 Too Many Requests — rate limit. Здесь ретраи возможны, но только с учетом
Retry-After. - 500 Internal Server Error — внутренняя ошибка, которую клиент не обязан исправлять.
- 502 Bad Gateway — ошибка прокси/шлюза при обращении к upstream (зависимость от другого сервиса).
- 503 Service Unavailable — сервис временно недоступен/перегружен (часто лучше для “ретраить после паузы”).
- 504 Gateway Timeout — таймаут при обращении к upstream.
Почему это важно
Клиентская логика в интеграциях часто строится на статус-кодах:
- для 5xx — retry (с бэкоффом и ограничением),
- для части 4xx — немедленный fail,
- для 401 — refresh токена,
- для 422 — показать пользователю ошибки по полям.
Если размыть границы, клиент будет либо делать лишние повторные запросы (усугубляя падение), либо не повторит там, где нужно.
2) Ошибка как контракт: код, причина, поле, детали
HTTP-статус — верхнеуровневая классификация. Но клиенту нужно больше структуры. В проде обычно нужен JSON-объект, который:
- легко парсится,
- имеет стабильные поля,
- позволяет клиенту принять решение (retry? validate fields? show message?).
2.1. Минимальный “разумный” формат
Одна из рабочих схем для REST API:
{
"error": {
"type": "validation_error",
"code": "USER_EMAIL_INVALID",
"message": "Email не проходит валидацию",
"requestId": "req_01J3...",
"details": [
{
"field": "email",
"reason": "invalid_format"
}
],
"retryable": false
}
}
Где:
- type — машинно-читаемая категория (валидация, конфликт, недоступность и т.д.)
- code — стабильный код ошибки домена (не меняйте без осознанной версии)
- message — человекочитаемое (желательно для логов/в UI; но UI всё равно часто локализует по
code) - details — список проблем (особенно для 422)
- retryable — явный флаг (не обязательно, но полезно для клиентов)
- requestId — корреляция для дебага
Важно: “field” — это не просто имя параметра
Речь о blame: клиент должен понимать, что именно исправлять в запросе. Поэтому field лучше поддерживать:
- согласованным с API-контрактом (например,
email,password,profile.name), - соответствующим способу передачи (JSON body / query / path),
- безопасным (не раскрывать внутренние поля БД).
2.2. Уровни детализации: когда можно обойтись без details
- Для общих ошибок типа “не удалось выполнить” без привязки к полям можно ограничиться
codeиmessage. - Для 422 почти всегда нужны
details, иначе фронтенд не сможет связать ошибку с конкретными input-ами.
2.3. Ошибки домена vs инфраструктуры: разные type и code
Пример:
- Доменные:
- 422 +
type: validation_error+code: ORDER_ITEMS_OUT_OF_STOCK
- 422 +
- Инфраструктурные:
- 503 +
type: dependency_unavailable+code: INVENTORY_TIMEOUT
- 503 +
Это позволяет клиенту принимать решения даже при одинаковых статусах.
3) Правила ретраев: не только “если 5xx — повтори”
3.1. Ретраи зависят от операции и идемпотентности
Даже если статус 503 говорит “можно повторить”, клиент должен учитывать:
- метод: GET обычно безопаснее ретраить, POST — осторожно
- идемпотентность: если сервер поддерживает
Idempotency-Key, ретраи безопаснее - side effects: платёж, создание заявки, отправка письма
Если у вас нет идемпотентности, ретраи POST могут создавать дубликаты.
3.2. Мини-матрица для клиента
Рекомендуемая логика со стороны клиента/интеграции:
- 429: ретрай после
Retry-After(если он есть), иначе fail или с дефолтной паузой. - 5xx: ретрай с экспоненциальным backoff + jitter, максимум N попыток.
- 502/503/504: ретрай чаще, потому что обычно это “upstream временно”.
- 4xx: обычно не ретраить (кроме случаев, где это явно разрешено, например 409 может быть ретраен после получения актуального состояния, но это уже бизнес-логика).
3.3. Явный retryable и “почему”
Один из способов стабилизировать поведение — включать retryable и retryAfterMs (или Retry-After в заголовке).
{
"error": {
"type": "dependency_timeout",
"code": "PAYMENT_PROVIDER_TIMEOUT",
"message": "Платёжный провайдер не ответил вовремя",
"retryable": true,
"retryAfterMs": 800
}
}
Если retryable: true, клиенту легче договориться с контрактом без “угадываний” по статусу.
3.4. Почему важны лимиты и jitter
Без jitter и лимитов в микросервисах легко получить ретрай-шторм: один сбой → миллионы повторов → каскадное ухудшение.
Типовая стратегия:
- backoff:
base * 2^attempt - jitter: перемешать в диапазоне (например, 0.8–1.2)
- max attempts: 3–5 (зависит от SLA)
- общий cap по времени: например, 10–20 секунд суммарно
4) Отличать 4xx/5xx: что именно означает “вина клиента”
4.1. 422: место, где фронтенд получает управляемость
Статус 422 Unprocessable Entity полезен как “контракт валидации”: запрос распарсился, но бизнес-семантика не принята.
Пример ответа для “неверные поля”:
{
"error": {
"type": "validation_error",
"code": "REGISTRATION_DATA_INVALID",
"message": "Не удалось создать пользователя",
"retryable": false,
"details": [
{ "field": "email", "reason": "invalid_format" },
{ "field": "password", "reason": "too_short", "minLength": 8 },
{ "field": "termsAccepted", "reason": "required" }
]
}
}
Клиент:
- подсвечивает поля
email,password,termsAccepted - не ретраит
- может локализовать сообщения по
reasonиcode
4.2. 400 vs 422: где граница
- 400 — “я не понял запрос” (syntax, типы на уровне формата, например
Content-Typeне совпадает, JSON не парсится, обязательное поле отсутствует при схеме). - 422 — “запрос понял, но не согласен с содержимым домена”.
На практике граница может быть размытой, но важно держать её консистентной: если сегодня “не хватает поля” это 400, а завтра 422 — фронтенд начинает ветвиться по статусу и теряет простоту.
4.3. 409: конфликт — отдельная ветка решений
Конфликт — это “сервер понял запрос, но текущее состояние не позволяет”. Повторить без изменения обычно бессмысленно. Но иногда ретрай нужен после получения актуального состояния.
Пример:
- обновление ресурса по версии
- создание сущности, где возможен дедуп по внешнему ключу
{
"error": {
"type": "conflict",
"code": "RESOURCE_VERSION_MISMATCH",
"message": "Данные изменились, обновите перед повтором",
"retryable": false,
"details": [
{ "field": "version", "reason": "stale" }
]
}
}
Клиент может:
- перезагрузить ресурс,
- повторить запрос с новой версией,
- показать “обновите страницу” (или тихо пересинхронизировать).
5) Стабилизация формата ответа: как не ломать клиентов
5.1. Контракт должен быть версионируемым и расширяемым
Не стоит менять структуру “внезапно”. Хорошая практика:
- фиксировать базовые поля (
error.type,error.code,error.retryable) - добавлять поля только назад-совместимо
- не переименовывать
field→pathи т.п. без версионности
Если вы используете OpenAPI, зафиксируйте схему ошибки как компонент. Но даже без OAS — базовая дисциплина важна.
5.2. Пример “валидируемого” контракта (TypeScript)
export type ApiErrorDetail = {
field?: string;
reason: string;
[key: string]: unknown;
};
export type ApiErrorResponse = {
error: {
type: string;
code: string;
message: string;
retryable?: boolean;
retryAfterMs?: number;
requestId?: string;
details?: ApiErrorDetail[];
};
};
Клиент:
- может безопасно игнорировать поля, которые не знает
- строит UI по
details.fieldиreason
5.3. Нельзя “иногда возвращать массив”, “иногда объект”
Одна из самых частых причин поломок — нестабильная форма ответа. Например:
- в одном сервисе
details— массив, - в другом — объект,
- в третьем — строка.
Стабилизация — это не “красивый JSON”, а сохранение целостности интеграций.
6) Примеры: как серверу отдавать ошибки, клиенту — решать
6.1. Сервер: FastAPI (Python) пример стандартизированного ответа
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
import uuid
app = FastAPI()
class ErrorDetail(BaseModel):
field: str | None = None
reason: str
minLength: int | None = None
class ErrorBody(BaseModel):
type: str
code: str
message: str
retryable: bool | None = None
retryAfterMs: int | None = None
requestId: str | None = None
details: list[ErrorDetail] | None = None
def build_error_response(status_code: int, err: ErrorBody):
request_id = err.requestId or f"req_{uuid.uuid4().hex[:10]}"
payload = {"error": {**err.model_dump(exclude_none=True), "requestId": request_id}}
return JSONResponse(status_code=status_code, content=payload)
@app.post("/register")
async def register(payload: dict, request: Request):
email = payload.get("email")
password = payload.get("password")
details = []
if not email or "@" not in email:
details.append({"field": "email", "reason": "invalid_format"})
if not password or len(password) < 8:
details.append({"field": "password", "reason": "too_short", "minLength": 8})
if details:
err = ErrorBody(
type="validation_error",
code="REGISTRATION_DATA_INVALID",
message="Не удалось создать пользователя",
retryable=False,
details=details
)
return build_error_response(422, err)
return {"ok": True}
@app.get("/payments/status/{id}")
async def payment_status(id: str):
# допустим, upstream недоступен
err = ErrorBody(
type="dependency_unavailable",
code="PAYMENT_UPSTREAM_TIMEOUT",
message="Провайдер не ответил вовремя",
retryable=True,
retryAfterMs=800
)
return build_error_response(503, err)
Что это даёт:
- стабильная структура
{"error": {...}} retryableявно отвечает на вопрос “повторять или нет”detailsпозволяет фронтенду привязать ошибки к полям
6.2. Клиент: логика ретраев в TypeScript (fetch + backoff)
type ApiErrorResponse = {
error: {
code: string;
message: string;
retryable?: boolean;
retryAfterMs?: number;
details?: Array<{ field?: string; reason: string }>;
};
};
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function withJitter(ms: number) {
const jitter = 0.8 + Math.random() * 0.4; // 0.8..1.2
return Math.round(ms * jitter);
}
export async function requestWithRetry(
input: RequestInfo,
init: RequestInit & { maxAttempts?: number } = {}
) {
const maxAttempts = init.maxAttempts ?? 4;
let attempt = 0;
let lastError: unknown;
while (attempt < maxAttempts) {
attempt += 1;
try {
const res = await fetch(input, init);
if (!res.ok) {
let body: ApiErrorResponse | null = null;
try {
body = (await res.json()) as ApiErrorResponse;
} catch {
// не-JSON ответ — всё равно решаем по статусу
}
const retryable =
body?.error.retryable ??
(res.status >= 500 && res.status <= 599) ||
res.status === 429;
const retryAfterMs =
body?.error.retryAfterMs ??
(res.headers.get("Retry-After")
? Number(res.headers.get("Retry-After")) * 1000
: undefined);
if (retryable && attempt < maxAttempts) {
const base = retryAfterMs ?? 200 * 2 ** (attempt - 1);
await sleep(withJitter(base));
continue;
}
throw { status: res.status, body };
}
return await res.json();
} catch (e) {
lastError = e;
if (attempt >= maxAttempts) break;
}
}
throw lastError;
}
Важно: клиент учитывает retryable из тела ошибки, а не только статус. Это снижает количество неверных ретраев, если сервер отдал 4xx/5xx нетипично.
7) Типичные ошибки команд в проде (и как их лечить)
7.1. Смешение 400/422: фронтенд теряет семантику
Симптом:
- иногда валидация возвращает 400,
- иногда 422,
- иногда ошибки без
details.
Лечение:
- договориться о границе: 400 = синтаксис/формат,
Комментарии
Пока нет комментариев