Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Обработка ошибок в сервисе: от исключения к ответу

Обработка ошибок в сервисе: что возвращать и что логировать

Обработка ошибок: классификация, коды HTTP, границы слоёв, идемпотентность повторов и правила логирования ошибок в продакшене.

Прямой ответ

Обработка ошибок в сервисе решает три разные задачи, которые часто смешивают:

  1. Что вернуть клиенту — понятный и безопасный ответ.
  2. Что записать в журнал — достаточно для расследования, без лишнего.
  3. Что делать дальше — повторить, деградировать, остановить операцию.

Первое правило — классифицировать ошибку по тому, кто может её исправить:

Класс Пример Ответ клиенту Повтор имеет смысл
Ошибка клиента Неверный формат, нет прав, не найдено 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.

Рекомендации:

  1. Единый формат ответа об ошибке. Клиент должен разбирать один вариант структуры, а не пять.
  2. Код ошибки как строка, а не только HTTP-статус. Статус описывает класс, код — конкретную причину; по нему клиент строит сообщение пользователю.
  3. Тестируйте отказы, а не только успех. Недоступность базы, таймаут внешнего сервиса, конфликт параллельных операций — эти сценарии проверяются так же, как основной поток.
  4. Проверяйте, что ответ не содержит деталей. Тест, утверждающий отсутствие текста исключения в теле ответа при 500, защищает от случайной утечки.
  5. Свяжите ошибки с метриками. Доля 4xx и 5xx по типам — индикатор, который замечает проблему раньше обращений пользователей.

Практический сценарий: повторные списания из-за неверной классификации ошибки

Контекст: платёжный сервис возвращал 500 при таймауте внешнего провайдера. Клиентское приложение повторяло запрос, и часть операций выполнялась дважды.

  1. Диагностика. Провайдер отвечал с задержкой выше клиентского таймаута. Операция на стороне провайдера при этом завершалась успешно, но сервис не успевал получить ответ и сообщал клиенту об ошибке.
  2. Причина 1. Таймаут классифицировался как внутренняя ошибка (500), хотя это временный сбой. Клиент трактовал 500 как «можно повторить».
  3. Причина 2. Операция не была идемпотентной: каждый запрос создавал новую запись, поэтому повтор приводил к новому списанию.
  4. Действия. (а) Таймауты и отказы сети перевели в 504 с явным указанием, что результат неизвестен; (б) добавили идемпотентный ключ операции: повтор с тем же ключом возвращает уже полученный результат; (в) ввели фоновую сверку операций с неизвестным исходом; (г) добавили тесты на сценарии «таймаут при списании» и «повтор после таймаута».
  5. Результат. Двойные списания прекратились; при таймауте клиент получает предсказуемый ответ, а расхождение устраняется сверкой в течение минут.

Вывод: большинство инцидентов с ошибками связано не с отсутствием обработки, а с неверной классификацией. Ответ сервиса — это инструкция клиенту о том, что делать дальше, и ошибка в ней обходится дороже, чем само исключение.

FAQ

Нужно ли возвращать стек вызовов в ответе при отладке? В локальной разработке — удобно, в продакшене — недопустимо. Практика: подробная информация пишется в журнал, а в ответе остаётся код ошибки и идентификатор записи, по которому её можно найти.

Чем 500 отличается от 503 с точки зрения клиента? 500 означает, что сервер не смог обработать корректный запрос; повтор обычно не помогает. 503 означает временную недоступность; повтор через некоторое время уместен. Смешение этих кодов ломает логику повторов на стороне клиента.

Стоит ли использовать исключения для управления потоком? Для редких и исключительных ситуаций — да, это их назначение. Для регулярных ветвлений стоимость создания исключения и сложность чтения кода делают возврат значения более подходящим вариантом.

Как обрабатывать ошибки в асинхронном коде? Принципы те же, но важно не терять исключения в незавершённых задачах: у отменённых и упавших задач должен быть обработчик, иначе ошибка останется незамеченной до завершения процесса.

Источники: Python docs (errors), RFC 9110, OWASP Error Handling Cheat Sheet — ссылки в шапке.