← назад к разделу

В Python исключение само долетает до того, кто его ждёт, и у FastAPI для этого есть реестр: @app.exception_handler(Тип). Поймать несложно. Сложно другое: в одном приложении ошибки рождаются в четырёх местах — в обработчике, в зависимости с проверкой токена, в роутере, который не нашёл путь, и в разборе тела запроса, — и у каждого места свой формат по умолчанию. Единый обработчик — это не одна функция, а соглашение о том, что все четыре источника пишут ответ одним способом.

Обязательно

Проблема: разбор ошибок в каждом обработчике

Без общего места обработчик выглядит так:

@router.get("/orders/{order_id}")
async def get_order(order_id: str, orders: OrdersDep):
    try:
        return await orders.by_id(order_id)
    except NoResultFound:
        raise HTTPException(status_code=404, detail="not found")
    except Exception:
        raise HTTPException(status_code=500, detail="internal error")

Проблемы те же, что везде: соответствие «ошибка → статус» повторяется в каждом методе, тело у HTTPException — {"detail": "..."}, а у соседнего обработчика, который собрал JSONResponse сам, другое, и ни одно из этих мест не знает про идентификатор трассировки. Второй except вдобавок прячет настоящую причину: трассировка остаётся внутри, в журнал попадает только internal error.

Короткая формула: обработчик описывает успешный путь и даёт исключению вылететь; что с ним делать — сквозная ответственность одного места.

Ошибка несёт вид, а не статус

Домен не должен знать про HTTP, но должен сказать, что за ошибка. Для этого у исключения есть вид:

from enum import StrEnum


class Kind(StrEnum):
    INVALID = "invalid"
    UNAUTHORIZED = "unauthorized"
    FORBIDDEN = "forbidden"
    NOT_FOUND = "not_found"
    CONFLICT = "conflict"
    RULE = "rule"


class AppError(Exception):
    kind: Kind

    def __init__(self, code: str, message: str) -> None:
        super().__init__(f"{code}: {message}")
        self.code = code
        self.message = message


class NotFound(AppError):
    kind = Kind.NOT_FOUND


class Conflict(AppError):
    kind = Kind.CONFLICT


class RuleViolated(AppError):
    kind = Kind.RULE

Домен бросает NotFound("ORDER_NOT_FOUND", "заказ не найден"), и про 404 в этом месте ничего не сказано. Соответствие вида и статуса живёт в веб-слое:

Вид ошибкиHTTP-статус
INVALID (неверный формат полей)400 Bad Request
UNAUTHORIZED401 Unauthorized
FORBIDDEN403 Forbidden
NOT_FOUND404 Not Found
CONFLICT409 Conflict
RULE (поля верны, правило нарушено)422 Unprocessable Content
всё остальное500 Internal Server Error

Пакет с AppError не импортирует ни fastapi, ни starlette: сценарии и домен зависят только от него.

Один центр: обработчик AppError

import logging

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

log = logging.getLogger("app.http")

STATUS_BY_KIND = {
    Kind.INVALID: 400,
    Kind.UNAUTHORIZED: 401,
    Kind.FORBIDDEN: 403,
    Kind.NOT_FOUND: 404,
    Kind.CONFLICT: 409,
    Kind.RULE: 422,
}


def problem(request: Request, status: int, code: str, detail: str, **extra) -> JSONResponse:
    body = {"status": status, "code": code, "detail": detail, "traceId": getattr(request.state, "trace_id", None), **extra}
    return JSONResponse(body, status_code=status, media_type="application/problem+json")


@app.exception_handler(AppError)
async def on_app_error(request: Request, exc: AppError) -> JSONResponse:
    status = STATUS_BY_KIND.get(exc.kind, 500)
    log_expected(request, exc, status)
    return problem(request, status, exc.code, exc.message)


@app.exception_handler(Exception)
async def on_unhandled(request: Request, exc: Exception) -> JSONResponse:
    log.exception("unhandled error", extra={"path": request.url.path})
    return problem(request, 500, "INTERNAL_ERROR", "Попробуйте позже. Если повторяется, сообщите идентификатор запроса.")
обработчик маршрута: raise OrderNotFound только успешный путь в коде маршрута реестр: exception_handler(AppError) по __mro__ обработчик подкласса побеждает Kind → статус: один словарь в веб-слое клиенту без str(exc) ответ: статус, code, message, traceId

Маршрут бросает доменную ошибку, а в статус и тело её переводит один реестр; соответствие вида ошибки статусу живёт в веб-слое одним словарём.

Обработчик ищется по классу исключения и его родителям: Starlette идёт по type(exc).__mro__ и берёт первый зарегистрированный. Поэтому одного обработчика на AppError хватает на всю иерархию, а если какому-то виду нужен особый ответ, регистрируют обработчик на подкласс — он победит. Это и есть замена switch по виду, завёрнутому в обёртки: подклассы читаются сквозь наследование, а ветка по умолчанию остаётся 500.

request.state.trace_id кладёт middleware трассировки, и он доступен в обработчике: state живёт в scope запроса, общем для всех слоёв. Про Problem — структуру ответа по RFC 9457 с типом application/problem+json — подробно в статье про ошибки REST API.

Где ошибки базы превращаются в доменные

sqlalchemy.exc.NoResultFound — не доменная ошибка, а подробность адаптера. Если пропустить её наверх, обработчик AppError её не узнает, и клиент получит 500 вместо 404. Перевод делают на границе репозитория:

from sqlalchemy.exc import IntegrityError, NoResultFound


class OrderRepository:
    async def by_id(self, order_id: UUID) -> Order:
        try:
            row = (await self.session.scalars(select(OrderRow).where(OrderRow.id == order_id))).one()
        except NoResultFound as e:
            raise NotFound("ORDER_NOT_FOUND", "заказ не найден") from e
        return to_domain(row)

    async def add(self, order: Order) -> None:
        self.session.add(to_row(order))
        try:
            await self.session.flush()
        except IntegrityError as e:
            if e.orig.sqlstate == "23505":
                raise Conflict("ORDER_ALREADY_EXISTS", "заказ с таким номером уже есть") from e
            raise

from e сохраняет исходное исключение в __cause__: в журнале будет и доменная ошибка, и текст драйвера. Код 23505 — нарушение уникальности в PostgreSQL; драйверы psycopg и asyncpg отдают его в e.orig.sqlstate. Правило: наверх из адаптера уходят либо доменные ошибки, либо технические, которые станут 500.

Что приходит другим путём

Обработчик AppError видит исключения, вылетевшие из обработчика маршрута или его зависимостей. Три источника ошибок до него не доходят или приходят в другом классе.

Зависимости и роутер. Проверка токена в Depends бросает HTTPException(401), роутер на неизвестный путь и неподдерживаемый метод бросает HTTPException(404) и HTTPException(405) сам. Без обработчика все три приходят клиенту как {"detail": "..."}, и в одном API живут две формы ошибок. Лечится одним обработчиком, но зарегистрировать его надо на базовый класс:

from starlette.exceptions import HTTPException as StarletteHTTPException

CODE_BY_STATUS = {401: "UNAUTHENTICATED", 403: "FORBIDDEN", 404: "ROUTE_NOT_FOUND", 405: "METHOD_NOT_ALLOWED"}


@app.exception_handler(StarletteHTTPException)
async def on_http_exception(request: Request, exc: StarletteHTTPException) -> JSONResponse:
    response = problem(request, exc.status_code, CODE_BY_STATUS.get(exc.status_code, "HTTP_ERROR"), str(exc.detail))
    if exc.headers:
        response.headers.update(exc.headers)
    return response

fastapi.HTTPException наследует starlette.exceptions.HTTPException, но роутер бросает именно родителя. Обработчик, зарегистрированный на fastapi.HTTPException, поймает ошибки из ваших зависимостей и пропустит 404 роутера — клиент снова увидит {"detail": "Not Found"}. Заголовки из исключения (WWW-Authenticate у 401) переносят в ответ руками.

Ошибки разбора запроса. Неверное тело, параметр не того типа и сломанный JSON приходят как RequestValidationError, по умолчанию с кодом 422 и списком detail. Обработчик превращает их в 400 с тем же конвертом и списком violations; как собрать список из exc.errors() — в статье про свои правила валидации.

Непойманные исключения. Обработчик на Exception работает иначе, чем остальные: его вызывает самый внешний слой приложения, ServerErrorMiddleware. Ответ клиенту уходит в вашем формате, но исключение затем пробрасывается дальше, чтобы сервер записал его в журнал и чтобы тест, если его не попросили об обратном, упал. Поэтому в тестах на этот путь TestClient создают с raise_server_exceptions=False, а FastAPI(debug=True) в проде не включают: вместо обработчика клиент получит страницу с трассировкой.

Клиент ушёл. Отключение клиента в середине запроса приходит как asyncio.CancelledError. Это BaseException, в реестр обработчиков он не попадает, и писать ответ некому; в журнал ошибок такое не должно попадать.

Ошибки после начала ответа

Обработчики могут заменить ответ, но не могут отозвать уже отправленный. Два места, где это ловит врасплох.

Исключение в BackgroundTasks случается после того, как клиент получил 200: обработчик Exception будет вызван, запишет журнал, но ответ не изменится. Фоновая задача ловит свои ошибки сама и переводит их в журнал и метрику.

Исключение внутри генератора StreamingResponse обрывает тело на середине: клиент видит 200 и неполные данные, а в журнале, если вылетело исключение из реестра, — RuntimeError: Caught handled exception, but response already started. Отсюда правило: всё, что может упасть, выполняют до того, как вернуть ответ; поток отдают только из данных, которые уже готовы, либо ошибку передают внутри самого потока.

Несколько форматов сразу

Один формат ошибок на сервис работает, пока сервис небольшой. У старого API для партнёров формат может быть другим. Реестр обработчиков у FastAPI один на приложение, поэтому второй формат — это второе приложение, смонтированное под своим префиксом:

legacy = FastAPI()


@legacy.exception_handler(AppError)
async def on_legacy_error(request: Request, exc: AppError) -> PlainTextResponse:
    return PlainTextResponse(f"ERR {exc.code}", status_code=STATUS_BY_KIND.get(exc.kind, 500))


app.mount("/partner", legacy)

У смонтированного приложения своя цепочка middleware и свои обработчики, включая обработчик Exception. Какой формат сработает, видно по префиксу пути, а «мой обработчик не вызвался» здесь не бывает: порядка и приоритетов нет, есть одно приложение на один префикс.

Что клиенту, что в журнал

Клиент получает безопасный минимум: статус, машинный код, человеческое сообщение и идентификатор трассировки. Журнал получает всё остальное: цепочку __cause__, текст ошибки от драйвера, параметры запроса.

Идентификатор трассировки — связующее звено: клиент видит его в ответе, поддержка находит по нему путь запроса в журналах и трассах. Без него совет «причина в журналах» неисполним.

Чего в ответе быть не должно: str(exc) целиком. У ошибок SQLAlchemy в тексте есть сам запрос и его параметры — [SQL: INSERT INTO users ...] [parameters: ('bob',)], — и обработчик, который кладёт str(exc) в detail, отдаёт клиенту схему базы. Поэтому в Problem уходит exc.message, который написан для человека, а log.exception пишет трассировку с цепочкой причин в журнал.

Ожидаемые ошибки: тише в журнале, но считать

404 и 409 пишут уровнем отладки, иначе журнал прода тонет в ожидаемом. Вторая половина правила, без которой первая вредна: ошибка, исчезнувшая из журнала, должна появиться в метриках.

from prometheus_client import Counter

api_errors = Counter("api_errors_total", "Ошибки API по машинному коду", ["code"])


def log_expected(request: Request, exc: AppError, status: int) -> None:
    api_errors.labels(code=exc.code).inc()
    level = logging.ERROR if status >= 500 else logging.DEBUG
    log.log(level, "request failed", extra={"code": exc.code, "status": status, "path": request.url.path}, exc_info=exc.__cause__)

Всплеск «не найдено» означает либо сломанного клиента, либо потерянные данные, и заметить его можно только по графику. Оповещение ставят на долю от общего потока, а не на абсолютное число.

Ошибки проверок в том же формате

Нарушения валидации — тот же конверт вида INVALID, только со списком. Для них не заводят отдельный класс: обработчик RequestValidationError вызывает ту же функцию problem с code="VALIDATION_FAILED" и полем violations. Сломанный JSON приходит с типом json_invalid без поля — ему отдать нечего, кроме общего сообщения. Так ошибки формы приходят в том же конверте, что и доменные, и клиент пишет одну ветку разбора.

Тест обработчика

Тестируют не функцию problem напрямую, а путь запроса целиком: роутер, зависимости, обработчик, реестр, сериализацию. TestClient делает это без сети, а сценарий подменяется через dependency_overrides:

import pytest
from fastapi.testclient import TestClient

from app.main import app, get_orders


class StubOrders:
    def __init__(self, error: Exception) -> None:
        self.error = error

    async def by_id(self, order_id):
        raise self.error

    async def cancel(self, order_id):
        raise self.error


@pytest.mark.parametrize(
    ("method", "path", "body", "error", "status", "code"),
    [
        ("POST", "/api/v1/orders/42/cancel", None, RuleViolated("ORDER_CANNOT_BE_CANCELLED", "заказ уже отправлен"), 422, "ORDER_CANNOT_BE_CANCELLED"),
        ("POST", "/api/v1/orders", "{", None, 400, "VALIDATION_FAILED"),
        ("GET", "/api/v1/nothing", None, None, 404, "ROUTE_NOT_FOUND"),
        ("GET", "/api/v1/orders/42", None, RuntimeError("pgconn: connection refused"), 500, "INTERNAL_ERROR"),
    ],
)
def test_errors(method, path, body, error, status, code):
    app.dependency_overrides[get_orders] = lambda: StubOrders(error)
    client = TestClient(app, raise_server_exceptions=False)

    response = client.request(method, path, content=body, headers={"Authorization": "Bearer test", "Content-Type": "application/json"})

    assert response.status_code == status
    assert response.headers["content-type"] == "application/problem+json"
    problem = response.json()
    assert problem["code"] == code
    assert problem["traceId"]
    assert "pgconn" not in problem["detail"] and "SQL:" not in problem["detail"]

Что важно в этом тесте. Проверяется тип содержимого — его легко потерять при ручной сборке ответа. Проверяется, что в detail не утекли внутренности: проверка того, чего быть не должно, важнее проверки того, что должно быть. Четыре сквозных случая покрыты один раз на приложение, а не на каждый обработчик: сломанный JSON, неизвестный путь, доменная ошибка и непойманное исключение. Пятым добавляют запрос без токена и ждут 401 с WWW-Authenticate и тем же конвертом.

Дополнительно: при первом чтении можно пропустить

Глубже: что считать по ошибкам: счётчик по коду, а не по текстурасширенное

Реестр обработчиков это единственное место, через которое проходит каждая ошибка сервиса, и потому лучшее место для счётчика. Что считают: Counter("app_errors_total", labelnames=["code", "status"]), где code это машинный код из исключения (ORDER_NOT_FOUND, VALIDATION_FAILED), а status класс ответа. Инкремент делает сам обработчик AppError и обработчик Exception с кодом INTERNAL.

Чего в метках нет: текста сообщения, идентификатора заказа, пользователя. Любое из этого делает число рядов неограниченным и роняет Prometheus, об этом статья про метрики. Коды это перечисление с десятками значений, их можно позволить.

Что по этим числам делают. Доля 5xx идёт в SLO и в тревогу по скорости сжигания бюджета ошибок, как в статье про SLO и алерты. Резкий рост одного кода 4xx после выката это сломанный клиент или сломанная валидация, и на него ставят отдельную тревогу «код X вырос в пять раз за десять минут». А ровный фон ожидаемых ошибок (ORDER_NOT_FOUND на старые ссылки) не тревога вовсе, и ради него не повышают уровень логов.

Логи при этом не дублируют метрику: ожидаемую ошибку пишут уровнем отладки или вовсе одной строкой на тысячу повторов, а INTERNAL всегда с трассировкой и traceId, по которому её найдут.

Коротко

  • Обработчик маршрута описывает успешный путь и даёт исключению вылететь; переводит его в ответ один реестр exception_handler, а не try/except в каждом методе.
  • Исключение несёт вид (Kind) и машинный код, а не статус; соответствие «вид → статус» живёт в веб-слое одним словарём, пакет с AppError не импортирует FastAPI.
  • Обработчик ищется по __mro__: одного на AppError хватает на иерархию, обработчик на подкласс побеждает, ветка по умолчанию — 500.
  • Ошибки базы (NoResultFound, sqlstate == "23505") превращаются в доменные на границе репозитория с from e, иначе 404 и 409 станут 500.
  • 401 из зависимостей и 404/405 роутера ловят обработчиком на starlette.exceptions.HTTPException, не на fastapi.HTTPException; RequestValidationError — тот же конверт с violations.
  • Обработчик Exception вызывает внешний слой: ответ уйдёт в вашем формате, но исключение пробросится в журнал сервера; в тестах — raise_server_exceptions=False, в проде — без debug=True.
  • Ответ, который уже отправлен, не отозвать: ошибки BackgroundTasks и генераторов StreamingResponse до клиента не доходят; всё, что может упасть, делают до ответа.
  • Разный формат для разных API — отдельное приложение через app.mount, со своими обработчиками.
  • Клиенту уходят статус, код, сообщение для человека и traceId; str(exc) с текстом запроса и параметрами остаётся в журнале.
  • Ожидаемые ошибки пишут уровнем отладки, но считают счётчиком по коду; обработчик тестируют табличным тестом через TestClient и dependency_overrides на статус, тип содержимого, код и отсутствие утечки.

Что почитать дальше