Единый формат ошибок FastAPI: обработчик доменного исключения

Несколько маршрутов повторяют try/except для одной бизнес-ошибки: товар закончился. В одном ответе приходит detail, в другом message, в третьем 500. Клиенту приходится распознавать три формы. Общий обработчик конкретного исключения позволяет сохранить один договор и оставить сервисную логику независимой от HTTP.

Выделите ожидаемый отказ операции

Собственное исключение OutOfStock обозначает понятный продуктовый результат: нужного количества нет. В обработчике FastAPI оно превращается в 409 и стабильный код out_of_stock. Отдельные поля содержат контекст, который действительно разрешено сообщать клиенту. Сервис не импортирует HTTPException и может использоваться вне web-слоя.

Не добавляйте catch-all, который превращает любое Exception в конфликт или успешный JSON. Опечатка в коде и недоступная база должны оставаться серверными неисправностями. Иначе мониторинг увидит нормальный бизнес-отказ, а клиент будет пытаться исправить количество вместо ожидания восстановления сервиса.

Проверяем точный договор ожидаемой ошибки

Нужны FastAPI и HTTPX. Запас товара задаётся локально, чтобы тест полностью воспроизводился. Покажем успешный запрос, ожидаемый конфликт и отдельную внутреннюю ошибку. Для последней клиент создан с raise_server_exceptions=False, иначе TestClient поднимет исключение в тесте вместо ответа 500.

Сохраните весь блок в файл и запустите Python 3.11+ в отдельном окружении с пакетами fastapi, httpx. Для моделей используется Pydantic v2; отдельный сервер для TestClient не нужен.

from fastapi import FastAPI, Request, Query
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient

app = FastAPI()
class OutOfStock(Exception):
    def __init__(self, available):
        self.available = available

@app.exception_handler(OutOfStock)
async def stock_error(request: Request, exc: OutOfStock):
    return JSONResponse(status_code=409,
        content={"error": {"code": "out_of_stock", "available": exc.available}})

@app.post("/reserve")
def reserve(quantity: int = Query(ge=1)):
    available = 3
    if quantity > available:
        raise OutOfStock(available)
    return {"reserved": quantity}

@app.get("/broken")
def broken():
    raise RuntimeError("internal test failure")

with TestClient(app, raise_server_exceptions=False) as client:
    assert client.post("/reserve?quantity=2").json() == {"reserved": 2}
    denied = client.post("/reserve?quantity=4")
    assert denied.status_code == 409
    assert denied.json() == {"error": {"code": "out_of_stock", "available": 3}}
    assert client.post("/reserve?quantity=0").status_code == 422
    assert client.get("/broken").status_code == 500
print("domain conflict formatted; validation and internal error remain distinct")

Как использовать код ошибки на frontend

Код out_of_stock подходит для ветвления и перевода сообщения. Значение available помогает предложить доступное количество. Не делайте клиент зависимым от текста Python-исключения: он меняется при рефакторинге и может содержать внутренние подробности. Публичный договор задаёт обработчик, а не repr ошибки.

В примере 422 сохраняет штатный формат валидации FastAPI. Если продукт требует единый внешний конверт для всех ошибок, добавьте отдельный обработчик RequestValidationError и решите, как передавать пути полей. Не стирайте различие между ошибкой формы документа и конфликтом состояния товара.

Что учитывать в реальном резервировании

Проверка available перед записью требует атомарного изменения запаса в базе. Этот пример посвящён отображению ошибки в HTTP; для реальной продажи условное обновление или блокировка должны защитить остаток от двух одновременных запросов. Доменное исключение можно поднимать после фактического результата такой операции.

Добавьте в OpenAPI описание ответа 409, если клиенты строят SDK по схеме. Сам зарегистрированный exception_handler не перечисляет все ответы каждой операции. Затем проверьте JSON на настоящем маршруте: прямой вызов handler тестировал бы только формирование словаря и пропустил ошибку регистрации обработчика.

Следующая практика

Начните с открытой практики HTTPException и ответа для отсутствующей записи. Освоив явный HTTP-отказ, вынесите повторяемую доменную ошибку в собственный обработчик и сохраните отдельные тесты 422, 409 и 500. Откройте урок об ошибках API.

Бесплатная программа FastAPI связывает ошибки API с CRUD, базой и тестами. Продолжайте её, сохраняя различие между невалидным входом, отсутствующим ресурсом и конфликтом операции.

Продолжить на практике