Два одинаковых заголовка в FastAPI: когда читать список, а когда отказать
Прокси добавляет технический заголовок, который уже прислал клиент. В результате приложение видит два X-Tenant. Один компонент выбирает первое значение, другой — последнее, и журнал перестаёт объяснять, какой арендатор обработал запрос. Проблема начинается раньше проверки доступа: разные части цепочки по-разному понимают один HTTP-ввод.
Повторение — отдельное свойство контракта
Некоторые HTTP-поля допускают несколько значений, другие имеют особые правила объединения. Поэтому универсальное «возьмём первое» плохо подходит для всех заголовков. Сначала определите смысл своего поля. В примере X-Tag содержит независимые метки и принимает повторение, а X-Tenant задаёт один контекст и требует ровно одно значение.
В FastAPI аннотация list[str] у Header позволяет получить повторные значения. Обычная строка скрывает сам факт множественного ввода. Если повторение запрещено, удобнее сначала получить список и проверить его длину. Такая проверка явно описывает договор и остаётся видимой в тесте после изменения прокси или клиента.
Не выбирайте арендатора только по заголовку. После разбора строки приложение должно сверить её с правами аутентифицированного пользователя. Единственность значения устраняет неоднозначность ввода, а проверка прав отвечает за доступ. Эти действия выполняются последовательно; успешный первый шаг не даёт разрешения на данные.
Отправим повторные поля настоящим запросом
Для запуска нужны FastAPI и HTTPX. Заголовки TestClient передаются списком пар, поскольку словарь не может содержать два одинаковых ключа. В маршруте список меток сортируется перед ответом: для нашего договора порядок не несёт смысла. Проверять порядок прихода через всю цепочку прокси здесь не требуется.
Мы отклоняем повтор X-Tenant даже при равных строках. Это позволяет быстро обнаружить двойное добавление на границе инфраструктуры. Отдельно запрещается запятая: наш контракт не использует списочный синтаксис для tenant. Код не пытается самостоятельно разобрать все правила всех HTTP-заголовков.
from typing import Annotated
from fastapi import FastAPI, Header, HTTPException
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/context")
def context(
tenant: Annotated[list[str], Header(alias="X-Tenant")],
tags: Annotated[list[str] | None, Header(alias="X-Tag")] = None,
):
if len(tenant) != 1 or "," in tenant[0] or not tenant[0].strip():
raise HTTPException(400, "send exactly one X-Tenant")
return {"tenant": tenant[0], "tags": sorted(tags or [])}
with TestClient(app) as client:
ok = client.get("/context", headers=[("X-Tenant", "alpha"), ("X-Tag", "b"), ("X-Tag", "a")])
assert ok.json() == {"tenant": "alpha", "tags": ["a", "b"]}
duplicate = client.get("/context", headers=[("X-Tenant", "alpha"), ("X-Tenant", "beta")])
assert duplicate.status_code == 400
combined = client.get("/context", headers={"X-Tenant": "alpha,beta"})
assert combined.status_code == 400
assert client.get("/context").status_code == 422
print(ok.json(), duplicate.status_code, combined.status_code)Проверьте место, где появляется повтор
Если прямой запрос к приложению проходит, а через ingress возникает 400, сравните значения на каждом участке цепочки. Посмотрите, заменяет ли прокси клиентское поле или добавляет своё рядом. Для доверенного технического tenant обычно следует удалять внешний ввод перед установкой внутреннего значения. Конкретное правило зависит от того, какая граница выполняет аутентификацию.
Тест из примера проверяет поведение FastAPI и тестового HTTP-транспорта. После изменения инфраструктуры полезен отдельный запрос через реальный маршрут пользователя: некоторые промежуточные компоненты объединяют повторные строки. Поэтому обработчик дополнительно отклоняет списочную строку с запятой, которую наш собственный контракт не допускает.
Не переносите этот фильтр на Authorization, Cookie или другие стандартные поля без разбора их синтаксиса. У них собственные правила и собственные риски. Например, чтение всех Cookie как произвольных меток разрушит смысл пар имя-значение. Начните с одного собственного заголовка и явно зафиксируйте поведение для отсутствия, повтора и пустого значения.
В журнале полезно различать «контекст отсутствует», «контекст повторён» и «нет доступа к выбранному контексту». Первые две причины обычно исправляет клиент или инфраструктура, третью — владелец разрешений. Возвращать наружу внутренние списки доступных арендаторов для объяснения 403 не нужно. Достаточно стабильного кода ошибки.
Если один набор заголовков используется во многих маршрутах, вынесите разбор в зависимость. Тогда бизнес-обработчики получат уже проверенный контекст. Не размножайте условие длины списка вручную: следующий маршрут может случайно выбрать другое значение. Добавьте тест зависимости и хотя бы один HTTP-тест маршрута, подтверждающий реальное подключение.
Для общего контекста задайте также предел длины и допустимый алфавит значения. Номер арендатора обычно имеет известную форму, и произвольная строка только усложняет поиск в журнале. Выполняйте такую нормализацию до проверки прав. Если идентификаторы чувствительны к регистру, сохраните это правило; приведение всех значений к нижнему регистру может случайно объединить разные сущности.
Следующая практика
В учебном API вынесите разбор контекста в Depends и подключите его к нескольким маршрутам. В бесплатной программе FastAPI есть зависимости, авторизация и тесты: на этой основе удобно проверить общий договор заголовков. Перейдите к бесплатному курсу FastAPI.
Продолжить на практике
- FastAPI для начинающих: API с базой данных и тестами ↗
Соберёте свой первый полноценный backend на FastAPI: с базой данных, регистрацией, JWT-авторизацией и автотестами. 34 коротких урока и 140 заданий с проверкой — бесплатно.
