Обработка ошибок: коды, структура error response
Хорошая обработка ошибок, это уважение к разработчикам которые используют твой API. Когда что-то пошло не так, клиент должен понять: что случилось, почему, и что делать дальше.
Правильный HTTP статус-код, это уже половина дела
Первое что делает клиент, смотрит на статус-код. Если он правильный, категория проблемы понятна до чтения тела ответа.
Антипаттерн: 200 OK для всего
HTTP/1.1 200 OK
{ "success": false, "error": "User not found" }
Проблемы: мониторинг не видит ошибок, retry не сработает, клиентские библиотеки не знают что запрос упал.
Антипаттерн: 500 для всего
Разработчик видит 500 и думает сервер сломан, хотя просто забыл обязательное поле.
Структура error response
Минимальная:
HTTP/1.1 404 Not Found
{
"error": {
"code": "USER_NOT_FOUND",
"message": "Пользователь с id=42 не найден"
}
}
Полная для продакшна:
HTTP/1.1 422 Unprocessable Entity
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Запрос содержит ошибки валидации",
"details": [
{
"field": "email",
"code": "INVALID_FORMAT",
"message": "Неверный формат email"
},
{
"field": "age",
"code": "OUT_OF_RANGE",
"message": "Возраст должен быть от 18 до 120",
"context": { "min": 18, "max": 120, "provided": 15 }
}
],
"request_id": "req_abc123def456",
"docs_url": "https://docs.example.com/errors#VALIDATION_FAILED"
}
}
Зачем каждое поле
code, машиночитаемая константа. Клиент строит логику на ней, а не на тексте сообщения:
if (error.code === 'INSUFFICIENT_FUNDS') showTopUpModal();
if (error.code === 'CARD_EXPIRED') showUpdateCardModal();
message, человекочитаемо, может меняться без breaking change.
details, массив для валидационных ошибок. Один объект на каждое проблемное поле.
request_id, пользователь вставляет в тикет поддержки, ты находишь в логах за секунды. Так делает Stripe.
docs_url, ссылка на документацию конкретной ошибки. Сильно сокращает время решения проблем.
Реестр кодов ошибок
Заводи и документируй, это часть контракта API:
| Код | HTTP | Описание |
|---|---|---|
UNAUTHORIZED |
401 | Нет токена или неверный |
FORBIDDEN |
403 | Нет прав на операцию |
NOT_FOUND |
404 | Ресурс не существует |
VALIDATION_FAILED |
422 | Ошибки валидации полей |
DUPLICATE_ENTRY |
409 | Конфликт (дубликат email) |
RATE_LIMIT_EXCEEDED |
429 | Превышен лимит запросов |
PAYMENT_FAILED |
402 | Ошибка платежа |
INSUFFICIENT_FUNDS |
402 | Недостаточно средств |
INTERNAL_ERROR |
500 | Внутренняя ошибка сервера |
Глобальный error handler на бэкенде
Не обрабатывай ошибки в каждом роуте, сделай централизованный middleware:
// Express error middleware (4 параметра, обязательно!)
app.use((err, req, res, next) => {
const requestId = req.headers['x-request-id'] || crypto.randomUUID();
// Логируем всё на сервере
logger.error({
requestId,
error: err.message,
stack: err.stack,
url: req.url,
method: req.method,
userId: req.user?.id,
});
// Клиенту, минимум информации
if (err.isOperational) {
// Известные ошибки (валидация, not found), говорим что случилось
return res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
details: err.details || undefined,
request_id: requestId,
}
});
}
// Неизвестные ошибки, только request_id, никакого stack trace
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'Внутренняя ошибка сервера',
request_id: requestId,
}
});
});
// Кастомный класс ошибки
class AppError extends Error {
constructor(code, message, statusCode, details) {
super(message);
this.code = code;
this.statusCode = statusCode;
this.details = details;
this.isOperational = true;
}
}
// Использование в роутах, просто бросаем ошибку
router.get('/users/:id', async (req, res, next) => {
try {
const user = await findUser(req.params.id);
if (!user) throw new AppError('NOT_FOUND', 'Пользователь не найден', 404);
res.json(user);
} catch(e) { next(e); }
});
Заголовки для дополнительного контекста
Retry-After для 429 и 503
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1710498000
Клиент знает ровно когда повторить, реализует умный backoff.
X-Request-Id в каждом ответе
app.use((req, res, next) => {
const requestId = req.headers['x-request-id'] || crypto.randomUUID();
req.requestId = requestId;
res.setHeader('X-Request-Id', requestId);
next();
});
Что показывать в 500 ошибках
Никогда не возвращай:
{
"error": "TypeError: Cannot read property 'id' of undefined\n at /app/src/handlers/user.js:47:12"
}
Stack trace раскрывает структуру кода. Логируй на сервере, клиенту, только request_id.
Retry стратегии для клиента
| Статус | Повторять? | Стратегия |
|---|---|---|
| 400 | ❌ | Исправь запрос |
| 401 | Условно | Обнови токен, потом повтори |
| 403 | ❌ | Нет прав, повтор бессмысленен |
| 404 | ❌ | Ресурса нет |
| 429 | ✅ | После Retry-After |
| 500 | ✅ | Exponential backoff |
| 502/503/504 | ✅ | Exponential backoff |
Exponential backoff с jitter:
const retry = async (fn, maxAttempts = 5) => {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch(err) {
if (attempt === maxAttempts) throw err;
if (![429, 500, 502, 503, 504].includes(err.status)) throw err;
const baseDelay = Math.min(1000 * Math.pow(2, attempt - 1), 30000);
const jitter = Math.random() * 1000; // случайный сдвиг
await sleep(baseDelay + jitter);
}
}
};
Jitter нужен чтобы тысячи клиентов не ударили по серверу одновременно после восстановления, эффект «thundering herd».