Прямой ответ
Обработка ошибок в сервисе решает три разные задачи, которые часто смешивают:
- Что вернуть клиенту — понятный и безопасный ответ.
- Что записать в журнал — достаточно для расследования, без лишнего.
- Что делать дальше — повторить, деградировать, остановить операцию.
Первое правило — классифицировать ошибку по тому, кто может её исправить:
| Класс | Пример | Ответ клиенту | Повтор имеет смысл |
|---|---|---|---|
| Ошибка клиента | Неверный формат, нет прав, не найдено | 4xx с описанием | Нет |
| Ошибка состояния | Конфликт, предварительное условие | 409 / 412 | После изменения состояния |
| Временный сбой | Таймаут, недоступна зависимость | 503 / 504 | Да |
| Внутренняя ошибка | Необработанное исключение | 500 без деталей | Нет, нужен разбор |
Соответствие кодов HTTP задаёт RFC 9110; смешение классов приводит к тому, что клиент повторяет запрос, который никогда не выполнится, или не повторяет тот, который выполнился бы через секунду.
Второе правило — обрабатывать ошибки на границе слоёв, а не везде. Исключение, поднятое в слое доступа к данным, не должно обрабатываться в каждом промежуточном вызове: его перехватывают там, где есть контекст для принятия решения.
class DomainError(Exception):
"""Базовый класс для ожидаемых отказов предметной области."""
class InsufficientFunds(DomainError):
def __init__(self, account_id: int, required: Decimal, available: Decimal):
self.account_id, self.required, self.available = account_id, required, available
super().__init__(f"недостаточно средств на счёте {account_id}")
@app.exception_handler(DomainError)
async def domain_error_handler(request, exc):
status = {"InsufficientFunds": 409, "NotFound": 404}.get(type(exc).__name__, 400)
return JSONResponse(status_code=status, content={
"error": {"code": type(exc).__name__, "message": str(exc)},
})
@app.exception_handler(Exception)
async def unhandled_handler(request, exc):
logger.exception("unhandled error", extra={"path": request.url.path})
return JSONResponse(status_code=500, content={"error": {"code": "internal"}})
Третье правило — не раскрывать внутреннее устройство в ответе. Текст исключения, фрагмент SQL, путь к файлу и стек вызовов полезны в журнале и опасны в ответе: они помогают атакующему. О практических рисках — в статье про инъекции.
Практика: что писать в журнал и как организовать код
1. Логируйте один раз. Если исключение перехватывается, логируется и пробрасывается дальше, а затем логируется снова, в журнале появляется несколько записей об одном событии. Правило: логирует тот, кто обрабатывает окончательно.
2. Сохраняйте контекст. Запись должна отвечать на вопросы: какой запрос, какой пользователь, какая операция. Практика — request_id, который проходит через все слои и попадает в журнал — см. структурированные логи.
3. Различайте уровни. Ожидаемый отказ бизнес-логики — warning или info; сбой зависимости — error; состояние, требующее вмешательства, — critical. Если всё пишется как error, алерты теряют смысл.
4. Не глотайте исключения. Пустой блок except Exception: pass превращает сбой в молчаливую некорректность. Если ошибку действительно можно игнорировать, это фиксируется комментарием и записью в журнал.
5. Повторы требуют идемпотентности. Повтор запроса, который уже частично выполнен, может привести к двойному списанию. Безопасный повтор возможен только при идемпотентности операции — см. повторы и circuit breaker.
6. Проектируйте ожидаемые отказы как значения, а не исключения. Ситуации, которые происходят регулярно (недостаточно средств, отсутствие товара), удобнее возвращать как результат, оставляя исключения для действительно неожиданных состояний. О подходе — в статье про исключения в Python.
Рекомендации:
- Единый формат ответа об ошибке. Клиент должен разбирать один вариант структуры, а не пять.
- Код ошибки как строка, а не только HTTP-статус. Статус описывает класс, код — конкретную причину; по нему клиент строит сообщение пользователю.
- Тестируйте отказы, а не только успех. Недоступность базы, таймаут внешнего сервиса, конфликт параллельных операций — эти сценарии проверяются так же, как основной поток.
- Проверяйте, что ответ не содержит деталей. Тест, утверждающий отсутствие текста исключения в теле ответа при 500, защищает от случайной утечки.
- Свяжите ошибки с метриками. Доля 4xx и 5xx по типам — индикатор, который замечает проблему раньше обращений пользователей.
Практический сценарий: повторные списания из-за неверной классификации ошибки
Контекст: платёжный сервис возвращал 500 при таймауте внешнего провайдера. Клиентское приложение повторяло запрос, и часть операций выполнялась дважды.
- Диагностика. Провайдер отвечал с задержкой выше клиентского таймаута. Операция на стороне провайдера при этом завершалась успешно, но сервис не успевал получить ответ и сообщал клиенту об ошибке.
- Причина 1. Таймаут классифицировался как внутренняя ошибка (500), хотя это временный сбой. Клиент трактовал 500 как «можно повторить».
- Причина 2. Операция не была идемпотентной: каждый запрос создавал новую запись, поэтому повтор приводил к новому списанию.
- Действия. (а) Таймауты и отказы сети перевели в 504 с явным указанием, что результат неизвестен; (б) добавили идемпотентный ключ операции: повтор с тем же ключом возвращает уже полученный результат; (в) ввели фоновую сверку операций с неизвестным исходом; (г) добавили тесты на сценарии «таймаут при списании» и «повтор после таймаута».
- Результат. Двойные списания прекратились; при таймауте клиент получает предсказуемый ответ, а расхождение устраняется сверкой в течение минут.
Вывод: большинство инцидентов с ошибками связано не с отсутствием обработки, а с неверной классификацией. Ответ сервиса — это инструкция клиенту о том, что делать дальше, и ошибка в ней обходится дороже, чем само исключение.
FAQ
Нужно ли возвращать стек вызовов в ответе при отладке? В локальной разработке — удобно, в продакшене — недопустимо. Практика: подробная информация пишется в журнал, а в ответе остаётся код ошибки и идентификатор записи, по которому её можно найти.
Чем 500 отличается от 503 с точки зрения клиента? 500 означает, что сервер не смог обработать корректный запрос; повтор обычно не помогает. 503 означает временную недоступность; повтор через некоторое время уместен. Смешение этих кодов ломает логику повторов на стороне клиента.
Стоит ли использовать исключения для управления потоком? Для редких и исключительных ситуаций — да, это их назначение. Для регулярных ветвлений стоимость создания исключения и сложность чтения кода делают возврат значения более подходящим вариантом.
Как обрабатывать ошибки в асинхронном коде? Принципы те же, но важно не терять исключения в незавершённых задачах: у отменённых и упавших задач должен быть обработчик, иначе ошибка останется незамеченной до завершения процесса.
Источники: Python docs (errors), RFC 9110, OWASP Error Handling Cheat Sheet — ссылки в шапке.