FastAPI и завершающий слеш: почему тест не замечает редирект

Клиент обращается к /items, маршрут объявлен как /items/. В браузере всё выглядит работающим, тест получает 200, но на сетевой границе есть дополнительный ответ перенаправления. Когда прокси меняет схему или клиент иначе обрабатывает редирект, запрос ломается в неожиданном месте.

TestClient умеет идти за перенаправлением

По умолчанию клиент теста следует редиректам, поэтому вы видите итоговый 200. Это удобно для пользовательского сценария, но скрывает первый ответ сервера. Чтобы проверить именно контракт адреса, создайте TestClient(..., follow_redirects=False) и отдельно рассмотрите Location.

FastAPI по умолчанию пытается перенаправить между вариантами с завершающим слешем и без него. Если приложение требует строго один адрес, можно отключить redirect_slashes. Тогда неверный вариант вернёт 404. Выбор должен совпадать с адресами в документации, frontend и настройками обратного прокси.

Сравним три наблюдаемых результата

В примере один маршрут со слешем. Сначала клиент показывает непосредственный 307, затем стандартный клиент получает итоговый 200 и историю перехода. Второе приложение демонстрирует строгий режим. Установите FastAPI и HTTPX.

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

from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()
@app.get("/items/")
def items():
    return ["A"]

with TestClient(app, follow_redirects=False) as client:
    response = client.get("/items")
    assert response.status_code == 307
    assert response.headers["location"] == "http://testserver/items/"
    assert client.get("/items/").json() == ["A"]

with TestClient(app) as client:
    response = client.get("/items")
    assert response.status_code == 200
    assert len(response.history) == 1
    assert response.history[0].status_code == 307

strict = FastAPI(redirect_slashes=False)
strict.add_api_route("/items/", items, methods=["GET"])
with TestClient(strict) as client:
    assert client.get("/items").status_code == 404
    assert client.get("/items/").status_code == 200
print("direct redirect, followed response and strict routing checked")

Что проверить за обратным прокси

Location содержит адрес, который приложение считает внешним. При неправильной передаче схемы или хоста оно способно вернуть внутренний hostname либо http вместо https. Смотрите первый ответ через сетевой инструмент клиента и проверьте доверенные настройки прокси. Не включайте доверие к любым forwarded-заголовкам от внешних пользователей без понимания сетевой границы.

307 сохраняет метод и тело запроса при переходе, но клиент всё равно должен уметь и быть готовым следовать перенаправлению. Для API удобнее сразу вызывать канонический адрес. Особенно это важно для POST с файлом или для клиента, который запрещает переходы между хостами.

Как оставить понятный тест

Разделите проверку доступного сценария и проверку адресного договора. В первом допустимо следовать редиректам. Во втором явно отключите их и проверьте статус с Location. Тогда случайное изменение адресов при переносе роутеров не будет скрыто привычным итоговым 200.

Не исправляйте эту ситуацию дублированием каждого маршрута со слешем и без него без необходимости. Дубли усложняют OpenAPI и названия операций. Выберите канонический вариант, согласуйте его с клиентом и оставьте ожидаемое поведение второго варианта явным.

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

Закрепите локальные HTTP-запросы в открытом уроке TestClient. Затем отключите follow_redirects в собственном тесте и сравните первый ответ с итоговым: так адресный договор останется видимым. Пройдите открытый урок HTTP-тестов FastAPI.

Продолжите бесплатную программу FastAPI до собственного API с базой и тестами. При переносе маршрутов сохраняйте проверку канонических адресов: она пригодится и после публикации приложения. Откройте программу курса.

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