Внешний API не ответил: различаем timeout и плохой ответ в FastAPI
Маршрут корзины запрашивает цену у соседнего сервиса. Иногда тот отвечает 503, иногда соединение обрывается, иногда ожидание заканчивается по тайм-ауту. Если ловить один Exception, клиент получит одинаковую ошибку, а журнал не покажет, что именно сломалось. Если не обрабатывать ничего, технические исключения будут превращаться в случайные 500.
Сначала отделим ответ от отсутствия ответа
HTTPX возвращает Response даже для 503. Ошибка HTTP-статуса появляется после raise_for_status. Сетевые ошибки, напротив, прерывают выполнение запроса до нормального ответа. TimeoutException относится к транспортным ошибкам RequestError, поэтому её обработчик должен стоять раньше общего обработчика сети. Иначе тайм-аут попадёт в ту же ветку, что отказ соединения.
В нашем API истечение ожидания зависимости даёт 504, остальные транспортные ошибки и неуспешный статус — 502. Это выбранный договор шлюза: клиент знает, что его запрос дошёл до нашего приложения, но зависимость не позволила получить результат. Копировать наружу чужой 401 или 404 автоматически опасно: они могут описывать внутренний токен сервиса или иной идентификатор.
Успешный 200 тоже проверяется. Соседний сервис способен вернуть HTML или JSON другой формы. Поэтому после сетевого шага ответ проходит через модель Price. Неверный JSON и нарушение схемы получают ту же внешнюю категорию 502, а внутренний журнал должен различать причины. Здесь для краткости возвращается стабильное сообщение без полного чужого тела.
Проверим все ветки без настоящего сервиса
Нужны FastAPI, HTTPX и Pydantic v2. MockTransport возвращает ответ либо выбрасывает конкретное исключение HTTPX. Это проверяет реальное подключение клиента и обработчики маршрута. Тайм-аут в тесте создаётся явно: транспорт не измеряет задержку и не доказывает соблюдение реального сетевого бюджета.
AsyncClient создаётся в lifespan и закрывается при выходе. Один клиент приложения позволяет переиспользовать пул соединений. Параметр timeout=2 ограничивает операции клиента, но не является общим секундомером всего endpoint: несколько последовательных обращений могут суммарно занять больше. Для общего бюджета операции нужна отдельная политика времени.
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI, HTTPException, Request
from fastapi.testclient import TestClient
from pydantic import BaseModel, ValidationError, Field
class Price(BaseModel):
cents: int = Field(gt=0)
def make_app(mode):
def upstream(request):
if mode == "timeout":
raise httpx.ReadTimeout("slow upstream", request=request)
if mode == "network":
raise httpx.ConnectError("unreachable", request=request)
if mode == "status":
return httpx.Response(503, json={"error": "busy"})
if mode == "schema":
return httpx.Response(200, json={"cents": -1})
return httpx.Response(200, json={"cents": 1250})
@asynccontextmanager
async def lifespan(app):
async with httpx.AsyncClient(base_url="https://price.example.test", timeout=2,
transport=httpx.MockTransport(upstream)) as client:
app.state.prices = client
yield
app = FastAPI(lifespan=lifespan)
@app.get("/price", response_model=Price)
async def price(request: Request):
try:
response = await request.app.state.prices.get("/price/1")
response.raise_for_status()
except httpx.TimeoutException:
raise HTTPException(504, "price service timed out")
except (httpx.RequestError, httpx.HTTPStatusError):
raise HTTPException(502, "price service failed")
try:
return Price.model_validate(response.json())
except (ValueError, ValidationError):
raise HTTPException(502, "invalid price response")
return app
results = {}
for mode, expected in [("ok", 200), ("timeout", 504), ("network", 502), ("status", 502), ("schema", 502)]:
with TestClient(make_app(mode)) as client:
response = client.get("/price")
assert response.status_code == expected
if mode == "ok":
assert response.json() == {"cents": 1250}
results[mode] = response.status_code
print(results)Что добавить к рабочей диагностике
Записывайте имя зависимости, длительность обращения, категорию исключения и технический идентификатор запроса. Чужое тело целиком может содержать секрет или персональные данные. Для HTTPStatusError полезны статус и адрес без чувствительных параметров. Для тайм-аута важно различать подключение, чтение, запись и ожидание пула: эти причины требуют разных изменений.
Увеличение timeout не всегда исправляет проблему. Если не хватает соединений, проверьте размер пула и количество параллельных обращений. Если зависимость быстро отдаёт 503, длинное ожидание не поможет. Сначала определите, какая ветка сработала и сколько времени заняла операция, затем выбирайте изменение. Стабильная классификация делает эти данные сравнимыми.
Повторы добавляйте отдельно от преобразования исключений. Чтение цены обычно допускает повтор, а создание платежа требует защиты от двойного выполнения. Даже безопасные повторы расходуют общий бюджет и увеличивают нагрузку на зависимость. Ограничьте число попыток и учитывайте время, которое уже прошло. Код примера выполняет ровно одно обращение, поэтому его результат легко объяснить.
Для регрессии оставьте все пять режимов теста. Один успешный ответ не заметит удалённый raise_for_status, неправильный порядок except или случайное принятие отрицательной цены. Сценарий schema специально получает HTTP 200: он доказывает, что приложение проверяет данные после успешного сетевого шага. Настройку реальных тайм-аутов проверяйте отдельно с контролируемым сервером, если от неё зависит эксплуатационный бюджет.
Следующая практика
В бесплатном курсе FastAPI вы соберёте маршруты, зависимости и тесты в один API. Добавьте к нему внешний источник цены и сохраните пять сценариев отказа рядом с успешным запросом. Перейдите к бесплатному курсу FastAPI.
Для следующего шага разберите владение асинхронными клиентами и освобождение ресурсов. На платном курсе «Python для профессионалов» есть async/await, asyncio и контекстные менеджеры. Откройте программу, чтобы продолжить работу с жизненным циклом ресурсов и асинхронным Python.
Продолжить на практике
- FastAPI для начинающих: API с базой данных и тестами ↗
Соберёте свой первый полноценный backend на FastAPI: с базой данных, регистрацией, JWT-авторизацией и автотестами. 34 коротких урока и 140 заданий с проверкой — бесплатно.
- Python для профессионалов ↗
Python для профессионалов — курс для людей, стремящихся углубить свои знания языка. Вы изучите итераторы, генераторы, декораторы, современные возможности Python (3.8+), освоите метапрограммирование, асинхронность, внутреннее устройство интерпретатора, инструменты оптимизации и профилирования, а также получите уверенность в использовании продвинутых возможностей стандартной библиотеки.
