Опирается на правила:
R-RATE-1..3,R-FILE-1..5,R-DEP-1..3и X-коды → раздел Rate limiting, файлы, deprecation.
Важно знать
429 Too Many Requests— всегда сRetry-AfterиRateLimit-*; без них клиент не может корректно ретраить (R-RATE-X1).RateLimit-Limit/Remaining/Reset— в каждый успешный ответ, не только при превышении (R-RATE-2).- Rate limiting реализуется в middleware или gateway, а не в handler; handler не знает о лимитах.
- Файлы — через
UploadFile+multipart/form-data; не Base64 в JSON-теле (R-FILE-2).- Скачивание —
StreamingResponseилиFileResponseсContent-Disposition(R-FILE-5).deprecated=Trueв декораторе даёт пометку в/openapi.json; заголовкиSunset/Deprecation/Link— через middleware или зависимость (R-DEP-2).deprecated: trueбезSunset— запрещено (R-DEP-X1); «когда-нибудь» = «никогда».- После даты
Sunset— эндпоинт возвращает410 Goneс указанием альтернативы (R-DEP-3).
Три темы объединены в одной статье, потому что у них общий контекст: все три касаются поведения HTTP-слоя за пределами основной бизнес-логики — защита ресурсов, бинарный транспорт и управление жизненным циклом контракта.
Rate limiting
R-RATE-1..3. Rate limiting не реализуется в handler — он реализуется в middleware или внешнем gateway (nginx, Envoy, API Gateway). Middleware добавляет заголовки в каждый ответ и возвращает 429 при превышении.
Middleware
from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse
import time
app = FastAPI(redirect_slashes=False)
RATE_LIMIT = 100
WINDOW_SECONDS = 60
# Упрощённый счётчик (in-process, для production — Redis)
_counters: dict[str, tuple[int, float]] = {}
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
client_id = request.headers.get("X-Client-Id", request.client.host)
now = time.time()
count, window_start = _counters.get(client_id, (0, now))
if now - window_start > WINDOW_SECONDS:
count, window_start = 0, now
remaining = RATE_LIMIT - count
reset_at = int(window_start + WINDOW_SECONDS)
if remaining <= 0:
return JSONResponse(
status_code=429,
media_type="application/problem+json",
content={
"type": "urn:problem:order-service:rate-limit-exceeded",
"status": 429,
"title": "Too Many Requests",
"detail": f"Превышен лимит запросов. Повторите через {reset_at - int(now)} секунд.",
"code": "RATE_LIMIT_EXCEEDED",
},
headers={
"Retry-After": str(reset_at - int(now)),
"RateLimit-Limit": str(RATE_LIMIT),
"RateLimit-Remaining": "0",
"RateLimit-Reset": str(reset_at),
},
)
_counters[client_id] = (count + 1, window_start)
response: Response = await call_next(request)
response.headers["RateLimit-Limit"] = str(RATE_LIMIT)
response.headers["RateLimit-Remaining"] = str(remaining - 1)
response.headers["RateLimit-Reset"] = str(reset_at)
return response
Retry-After — секунды до сброса окна. RateLimit-Reset — Unix timestamp. Клиент видит оба и выбирает удобный формат для backoff.
429 + problem+json
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 23
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 1750291200
{
"type": "urn:problem:order-service:rate-limit-exceeded",
"status": 429,
"title": "Too Many Requests",
"detail": "Превышен лимит запросов. Повторите через 23 секунды.",
"code": "RATE_LIMIT_EXCEEDED"
}
RateLimit-* в успешных ответах
HTTP/1.1 200 OK
Content-Type: application/json
RateLimit-Limit: 100
RateLimit-Remaining: 57
RateLimit-Reset: 1750291200
{ "orderId": "ord-9182", "status": "CONFIRMED" }
Клиент заранее видит остаток и замедляется сам, до того как получит 429. Это убирает лишние ошибки для добросовестных клиентов.
OpenAPI — 429 в схеме
FastAPI code-first: 429 описывается через responses в декораторе.
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter(prefix="/api/v1", tags=["Orders"])
class OrderResponse(BaseModel):
model_config = {"populate_by_name": True}
orderId: str
status: str
@router.get(
"/orders/{order_id}",
response_model=OrderResponse,
operation_id="getOrder",
summary="Получить заказ",
responses={
429: {
"description": "Too Many Requests",
"headers": {
"Retry-After": {"schema": {"type": "integer"}},
"RateLimit-Limit": {"schema": {"type": "integer"}},
"RateLimit-Remaining": {"schema": {"type": "integer"}},
"RateLimit-Reset": {"schema": {"type": "integer"}},
},
"content": {
"application/problem+json": {
"schema": {"$ref": "#/components/schemas/ProblemDetails"}
}
},
}
},
)
async def get_order(order_id: str) -> OrderResponse:
...
R-RATE-3 требует явного 429 в OpenAPI — клиент видит, что эндпоинт лимитирован, и закладывает backoff в свой SDK.
Загрузка файлов
R-FILE-1..5. Файл — бинарный ресурс. Он не вписывается в JSON-модель: передавать его Base64 в теле — раздувает payload на 33% и делает его не-streamable. Правильный транспорт — multipart/form-data.
Endpoint и маршрут
from fastapi import APIRouter, UploadFile, File, Form
from fastapi.responses import StreamingResponse, Response
from pydantic import BaseModel
from datetime import datetime, timezone
router = APIRouter(prefix="/api/v1", tags=["Orders"])
class AttachmentResponse(BaseModel):
attachmentId: str
fileName: str
contentType: str
size: int
uploadedAt: str
@router.post(
"/orders/{order_id}/attachments",
response_model=AttachmentResponse,
status_code=201,
operation_id="uploadOrderAttachment",
summary="Загрузить вложение к заказу",
response_model_exclude_none=True,
)
async def upload_order_attachment(
order_id: str,
file: UploadFile = File(..., description="Максимум 10 МБ. PDF, PNG, JPG"),
description: str | None = Form(None, max_length=500),
response: Response = None,
) -> AttachmentResponse:
content = await file.read()
if len(content) > 10 * 1024 * 1024:
raise FileTooLargeError()
allowed = {"application/pdf", "image/png", "image/jpeg"}
if file.content_type not in allowed:
raise UnsupportedMediaTypeError()
attachment_id = "att-" + order_id[:8]
response.headers["Location"] = (
f"/api/v1/orders/{order_id}/attachments/{attachment_id}"
)
return AttachmentResponse(
attachmentId=attachment_id,
fileName=file.filename,
contentType=file.content_type,
size=len(content),
uploadedAt=datetime.now(timezone.utc).isoformat(),
)
UploadFile даёт доступ к filename, content_type и к файлу как к потоку (await file.read()). Ограничение размера — явно в handler; ограничение типов — через allowed-множество.
multipart/form-data запрос
POST /api/v1/orders/ord-9182/attachments
Content-Type: multipart/form-data; boundary=----Boundary7MA4
------Boundary7MA4
Content-Disposition: form-data; name="file"; filename="invoice.pdf"
Content-Type: application/pdf
<binary data>
------Boundary7MA4
Content-Disposition: form-data; name="description"
Счёт за февраль
------Boundary7MA4--
Ответ 201 + метаданные
{
"attachmentId": "att-ord-9182",
"fileName": "invoice.pdf",
"contentType": "application/pdf",
"size": 204800,
"uploadedAt": "2026-06-19T09:15:00+00:00"
}
R-RSP-3: 201 Created + заголовок Location + тело-ресурс целиком.
Скачивание — StreamingResponse
import aiofiles
@router.get(
"/orders/{order_id}/attachments/{attachment_id}",
operation_id="downloadOrderAttachment",
summary="Скачать вложение заказа",
)
async def download_order_attachment(
order_id: str,
attachment_id: str,
) -> StreamingResponse:
file_path = resolve_attachment_path(order_id, attachment_id)
file_name = "invoice.pdf"
content_type = "application/pdf"
async def file_stream():
async with aiofiles.open(file_path, "rb") as f:
while chunk := await f.read(65536):
yield chunk
return StreamingResponse(
file_stream(),
media_type=content_type,
headers={
"Content-Disposition": f'attachment; filename="{file_name}"',
},
)
Content-Disposition: attachment; filename="invoice.pdf" — браузер и HTTP-клиент сохраняют файл с корректным именем. Без этого заголовка имя файла теряется (R-FILE-5).
Для небольших статических файлов — FileResponse проще:
from fastapi.responses import FileResponse
return FileResponse(
path=file_path,
media_type="application/pdf",
filename="invoice.pdf",
)
FileResponse сам выставляет Content-Disposition и Content-Length.
Ограничения в OpenAPI — Pydantic
В code-first подходе OpenAPI для file endpoint описывается через responses и File(...):
@router.post(
"/customers/{customer_id}/avatar",
status_code=201,
operation_id="uploadCustomerAvatar",
summary="Загрузить аватар клиента",
responses={
400: {
"description": "Файл слишком большой или неподдерживаемый тип",
"content": {"application/problem+json": {}},
},
415: {
"description": "Unsupported Media Type",
"content": {"application/problem+json": {}},
},
},
)
async def upload_customer_avatar(
customer_id: str,
file: UploadFile = File(
...,
description="PNG или JPG, максимум 2 МБ",
),
) -> AttachmentResponse:
...
Ограничения на размер и тип фиксируются в description параметра — они попадают в /openapi.json и видны в Swagger UI.
Deprecation
R-DEP-1..3. Deprecation в FastAPI состоит из двух частей: пометка в OpenAPI (через deprecated=True в декораторе) и HTTP-заголовки в ответах (через зависимость или middleware).
1. Пометить в OpenAPI
@router.get(
"/orders/{order_id}/status",
operation_id="getOrderStatusLegacy",
summary="Получить статус заказа",
deprecated=True,
description=(
"DEPRECATED: используйте GET /api/v2/orders/{order_id}. "
"Будет удалён после 2026-12-01."
),
)
async def get_order_status_legacy(order_id: str):
...
deprecated=True помечает операцию в /openapi.json — Swagger UI перечёркивает её и выделяет предупреждением. Это видят разработчики при просмотре документации.
2. Заголовки Sunset, Deprecation, Link
Заголовки добавляются через Depends — зависимость, которую добавляют на конкретный маршрут:
from fastapi import Depends, Response
def deprecation_headers(
response: Response,
sunset_date: str = "Thu, 01 Dec 2026 00:00:00 GMT",
successor: str = "/api/v2/orders/{order_id}",
):
response.headers["Sunset"] = sunset_date
response.headers["Deprecation"] = "true"
response.headers["Link"] = f'<{successor}>; rel="successor-version"'
@router.get(
"/orders/{order_id}/status",
operation_id="getOrderStatusLegacy",
summary="Получить статус заказа",
deprecated=True,
description=(
"DEPRECATED: используйте GET /api/v2/orders/{order_id}. "
"Будет удалён после 2026-12-01."
),
dependencies=[Depends(deprecation_headers)],
)
async def get_order_status_legacy(order_id: str):
return {"status": "PROCESSING"}
Ответ клиенту:
HTTP/1.1 200 OK
Sunset: Thu, 01 Dec 2026 00:00:00 GMT
Deprecation: true
Link: </api/v2/orders/{order_id}>; rel="successor-version"
{ "status": "PROCESSING" }
Sunset(RFC 8594) — точная дата отключения, HTTP-date format.Deprecation: true— машиночитаемый флаг для SDK и мониторинга.Linkсrel="successor-version"— клиент знает, куда переходить.
3. 410 Gone после даты Sunset
После наступления даты Sunset handler заменяется на 410:
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from datetime import datetime, timezone
SUNSET = datetime(2026, 12, 1, tzinfo=timezone.utc)
@router.get(
"/orders/{order_id}/status",
operation_id="getOrderStatusRemoved",
include_in_schema=False,
)
async def get_order_status_gone(order_id: str):
if datetime.now(timezone.utc) >= SUNSET:
return JSONResponse(
status_code=410,
media_type="application/problem+json",
content={
"type": "urn:problem:order-service:endpoint-removed",
"status": 410,
"title": "Gone",
"detail": (
"Эндпоинт удалён. "
"Используйте GET /api/v2/orders/{order_id}."
),
"code": "ENDPOINT_REMOVED",
},
)
return {"status": "PROCESSING"}
include_in_schema=False — удалённый эндпоинт не фигурирует в /openapi.json, но физически ещё обслуживает запросы с 410. Клиент получает понятную ошибку с альтернативой, а не 404.
Процесс вывода из эксплуатации
- Добавить
deprecated=Trueв декоратор + заголовкиSunset/Deprecation/Link. - Уведомить потребителей: changelog, рассылка, Slack.
- Мониторить трафик на устаревший endpoint (логи, метрики).
- После даты
Sunset— заменить на410 Gone.
Период между deprecation и Sunset — от 6 до 12 месяцев. Достаточно для миграции крупных потребителей.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
429 без Retry-After | R-RATE-X1 | Retry-After обязателен |
429 без RateLimit-* | R-RATE-X1 | RateLimit-Limit/Remaining/Reset в каждый 429 |
RateLimit-* только при 429, не в успешных ответах | R-RATE-2 | middleware добавляет в каждый response |
UploadFile не используется, файл через JSON Base64 | R-FILE-2 | UploadFile + multipart/form-data |
Скачивание без Content-Disposition | R-FILE-5 | filename= обязателен — иначе браузер не знает имя |
| Ограничения на размер и тип не указаны в OpenAPI | R-FILE-3 | в description параметра File(...) |
deprecated=True без Sunset заголовка | R-DEP-X1 | Sunset обязателен: клиент не знает срок |
Нет Link rel="successor-version" | R-DEP-2 | альтернатива в заголовке обязательна |
После даты Sunset эндпоинт возвращает 200 | R-DEP-3 | 410 Gone с code: ENDPOINT_REMOVED |
| Deprecation без периода ожидания | R-DEP-3 | минимум 6 месяцев до Sunset |
Куда дальше
- python/errors.md — форматирование
429,410как problem+json; маппингRequestValidationError. - python/headers.md — кастомные заголовки без
X-;Idempotency-Key;traceparent. - python/versioning.md —
v1→v2, когда deprecation неизбежен. - python/batch-async-localization.md —
202 Acceptedдля длительных операций с файлами. - python/json-and-responses.md — ответ на upload как ресурс;
exclude_none. - python/url-and-resources.md — структура вложенного ресурса
/orders/{id}/attachments. - Ошибки RFC 9457 (Java) — нормативный формат problem+json.