Ошибки API и граница неверного ввода
Успешный каталог и предварительный просмотр уже имеют понятную форму. Теперь рассмотрим отказ: читателю нужно понять, почему операция не состоялась, а интерфейсу — выбрать дальнейшее действие. Пустая коллекция, неизвестный кабинет и отрицательный идентификатор несут разный смысл. В «Окне» добавим общую оболочку error и отделим машинный код от сообщения, которое человек может увидеть на экране.
Задача урока не состоит в перехвате любого исключения программы. Ошибка ввода является ожидаемой веткой разговора с клиентом, а ошибка реализации требует диагностики. Если превратить все исключения в успешный ответ с пустым объектом, клиент потеряет возможность отличить отказ от результата. Поэтому определим обработчики только для конкретных классов и оставим неожиданный сбой серверной ошибкой. Стек вызовов и текст SQL не попадут в заявленное клиентское сообщение.
Три источника отказа
HTTPException применяется, когда обработчик уже решил вернуть определённый статус. В каталоге это отсутствие ресурса. DomainError представляет предметную причину, не привязанную к конкретному HTTP-маршруту. Позднее занятый интервал станет slot_busy со статусом 409, а не будет выглядеть как ошибка Python. RequestValidationError означает, что запрос не соответствует входной модели или параметрам. Эта ошибка возникает до нормального исполнения функции маршрута.
Разделение помогает понять, где искать причину. Если resource_id содержит буквы, изучайте договор пути. Если номер допустим, но кабинета нет, исследуйте каталог и поиск. Если кабинет существует, но занято его время, нужна предметная операция бронирования. Общая оболочка ответа не стирает различий между этими уровнями. Напротив, она даёт интерфейсу одинаковый способ добраться до кода и затем обработать конкретную ситуацию.
Класс DomainError хранит code, message и status. Такой объект удобно поднять из сервисной функции, которая ещё не строит HTTP-ответ. Обработчик переводит его в JSONResponse. Важная деталь: само исключение не является JSON. Мы явно собираем словарь из известных полей. Так на внешний интерфейс не уходит служебное представление объекта, имя модуля или произвольные атрибуты, которые позже может добавить разработчик.
Полные файлы шага
Показанные файлы полностью совпадают с выбранным состоянием архива. Остальные согласованные файлы находятся в examples/fastapi-booking-lab/lesson-03/.
errors.py
from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
class DomainError(Exception):
def __init__(self, code: str, message: str, status: int = 409):
self.code = code
self.message = message
self.status = status
def register_errors(app: FastAPI) -> None:
@app.exception_handler(DomainError)
async def domain_error(request: Request, error: DomainError):
return JSONResponse(
status_code=error.status,
content={"error": {"code": error.code, "message": error.message}},
)
@app.exception_handler(HTTPException)
async def http_error(request: Request, error: HTTPException):
return JSONResponse(
status_code=error.status_code,
headers=error.headers,
content={"error": {"code": "http_error", "message": str(error.detail)}},
)
@app.exception_handler(RequestValidationError)
async def invalid_request(request: Request, error: RequestValidationError):
fields = [
{"path": list(item["loc"]), "type": item["type"]}
for item in error.errors()
]
return JSONResponse(
status_code=422,
content={"error": {"code": "invalid_request", "fields": fields}},
)
Ожидаемый результат
Это ожидаемый договор для самостоятельного исследования; пример не выполнялся.
GET /resources/99 → 404
{"error":{"code":"http_error","message":"Ресурс не найден"}}
Неверная форма запроса → 422, error.code = invalid_request
Безопасная форма ошибок валидации
Pydantic возвращает подробные записи о неверных значениях. Для нашего ответа берём только путь и тип ошибки. Путь показывает место: например, body, затем resource_id. Тип даёт клиенту основание связать отказ с элементом формы. Исходное значение не отражаем назад, поскольку тело запроса в будущем может содержать личные данные. Контекст ошибки также не копируем целиком: внутри могут находиться объекты, которые нельзя просто сериализовать как JSON.
Это особенно заметно при ValueError из model_validator. Проще всего было бы передать error.errors() в JSONResponse без преобразования, но такой подход смешивает внутренний формат валидатора с публичным договором. Мы выбираем устойчивую проекцию. Читателю не нужно видеть полное исключение для понимания того, что конец интервала должен следовать за началом. В интерфейсе можно сопоставить тип с подходящей русской подсказкой, сохранив серверный ответ компактным.
При обработке HTTPException сохраняем её headers. Заголовки являются частью ответа, а не украшением текста. Если позже механизм входа добавит заголовок, связанный с требованием авторизации, общий обработчик не должен его случайно отбросить. Само detail переводим в строку, потому что текущие маршруты используют короткие сообщения. Для более богатого договора лучше отдельный тип предметной ошибки, чем вложенная произвольная структура в строке.
Статус управляет следующей операцией
Представьте клиента, который отправил неправильный интервал. Для 422 он должен предложить исправить ввод. Если бронирование получит 409, повторение без изменения времени обычно не решает занятость. При 404 клиент может обновить каталог ресурсов. Эти реакции отличаются, хотя все ответы содержат error. Поэтому не делаем один статус для всех неудач. Машинный код уточняет предметную причину внутри статуса, а текст сообщения помогает человеку.
Обработчики регистрируются функцией register_errors после создания приложения. Вложенные функции запоминают приложение через декораторы, но не требуют глобального списка всех ошибок. Отдельный модуль не меняет порядок проверки DTO. Если валидация тела не прошла, специальный обработчик получает уже сформированное исключение. Если функция read_resource сама подняла HTTPException, применяется другой обработчик. На этой ступени полезно мысленно проследить оба пути до JSONResponse.
Ожидаемый ответ для /resources/99 содержит статус 404 и code http_error. Для буквенного параметра пути ожидается 422 и массив fields. Текстовые примеры на этой странице описывают договор, а не фактически снятый сетевой ответ. Дополнительный случай для самостоятельного разбора: замените неизвестный кабинет на существующий и убедитесь по коду, что успешная карточка не получает оболочку error. Формат отказа добавлен к прежним данным, а не вместо них.
Не записывайте здесь обработчик Exception, который возвращает клиенту str(error). Сообщение неожиданного сбоя может раскрыть структуру базы, адрес подключения или внутренние пути. Реальное сопровождение требует отдельного журнала и идентификатора запроса, но оно не означает публикацию всего диагностического текста. Наш текущий учебный результат уже определён: известные причины получают согласованные ответы, а неожиданные ошибки не маскируются под успешное бронирование. Следующая глава вынесет параметры процесса из обработчиков.
Коды живут дольше формулировок
Сообщение «Ресурс не найден» можно уточнить для пользователя, не меняя машинную ветку интерфейса. Код же должен оставаться согласованным с клиентом. Если экран проверяет буквальный русский текст, небольшая редактура способна сломать его логику. Поэтому предметные сервисы используют стабильные имена вроде resource_missing и slot_busy. Клиент выбирает действие по коду и статусу, а сообщение показывает или заменяет собственной локализованной подсказкой.
Не следует смешивать эту стабильность с обещанием раскрыть все внутренние причины. Сбой подключения базы не превращается в новый публичный code только потому, что текст драйвера выглядит понятным разработчику. Для него нужны отдельная диагностика и продуманный клиентский договор. В нашем маленьком каталоге http_error достаточно для известных исключений маршрута. Когда появятся самостоятельные предметные причины, они получат собственный DomainError. Так развитие ошибок будет следовать развитию операций, а не случайному списку исключений, встретившихся автору при редактировании.
Документация и исходники
Механизмы главы сверены с первичными источниками: документация 1, документация 2. Полный архив «Окна» содержит все шестнадцать состояний. Пакеты, приложение и миграции не запускались.