Опирается на правила: 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 перечёркивает её и выделяет предупреждением. Это видят разработчики при просмотре документации.

Заголовки добавляются через 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.

Процесс вывода из эксплуатации

  1. Добавить deprecated=True в декоратор + заголовки Sunset/Deprecation/Link.
  2. Уведомить потребителей: changelog, рассылка, Slack.
  3. Мониторить трафик на устаревший endpoint (логи, метрики).
  4. После даты Sunset — заменить на 410 Gone.

Период между deprecation и Sunset — от 6 до 12 месяцев. Достаточно для миграции крупных потребителей.

Что запрещено

АнтипаттернПравилоЧто взамен
429 без Retry-AfterR-RATE-X1Retry-After обязателен
429 без RateLimit-*R-RATE-X1RateLimit-Limit/Remaining/Reset в каждый 429
RateLimit-* только при 429, не в успешных ответахR-RATE-2middleware добавляет в каждый response
UploadFile не используется, файл через JSON Base64R-FILE-2UploadFile + multipart/form-data
Скачивание без Content-DispositionR-FILE-5filename= обязателен — иначе браузер не знает имя
Ограничения на размер и тип не указаны в OpenAPIR-FILE-3в description параметра File(...)
deprecated=True без Sunset заголовкаR-DEP-X1Sunset обязателен: клиент не знает срок
Нет Link rel="successor-version"R-DEP-2альтернатива в заголовке обязательна
После даты Sunset эндпоинт возвращает 200R-DEP-3410 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 — v1v2, когда 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.