← назад к разделу

PYTHON · REST API Фаза 14. API: REST и контракты

Обязательно

Rate limiting, файлы и deprecation в FastAPI

429 · Streaming · Sunset

Эксплуатационный контракт защищает сервис от лавины, не читает большие файлы в память и даёт клиентам время уйти со старого API.

1. Лимит стоит до обработчика

1 / 5

Middleware или gateway пропускает заданное число запросов, остальные получают 429. RateLimit-* показывает окно и остаток, Retry-After - когда повторить.

Requests1000 / secondRate limiter100 / minute429 + Retry-AfterHandlerтолько разрешённые

2. Лимит считается по клиенту

2 / 5

Ключ объединяет API key или пользователя, а не только IP. Хранилище общее для подов, алгоритм token bucket допускает контролируемый всплеск.

Client API keybucket: 100 tokensrefill per minuteRedisshared across podsatomic decrementAllowor 429

3. UploadFile и StreamingResponse не копят память

3 / 5

Multipart передаёт файл потоком, Base64 в JSON раздувает объём. Скачивание идёт чанками, Content-Disposition задаёт имя, большие файлы выгружаются по подписанной ссылке.

UploadFilemultipart/form-dataread chunksObject storagestream to S3не весь файл в RAMDownloadStreamingResponseили signed URL

4. Deprecation объявляется заранее

4 / 5

deprecated=True помечает операцию, Deprecation сообщает статус, Sunset называет дату отключения. До неё старый endpoint продолжает работать.

v1 activeclients workDeprecatedSunset: datemigration period410 Goneafter sunset

5. Миграция измеряется, а не угадывается

5 / 5

Логи и метрики по User-Agent показывают оставшихся клиентов. После миграции v2 и срока Sunset старый маршрут удаляется без сюрпризов.

v1 trafficUser-Agent metrics410 after zero

Сервис работает, бизнес-логика написана — но в реальной эксплуатации всплывают три проблемы, которые к самой логике отношения не имеют. Один клиент заваливает API тысячами запросов и кладёт сервис. Пользователю нужно загрузить аватар или выгрузить отчёт — а вы не знаете, как корректно принять и отдать файл. Старый эндпоинт пора убирать, но на нём ещё сидят клиенты, и резкое удаление их сломает. Разберём все три по очереди.

Rate limiting: как остановить лавину запросов

Представьте, что один клиент в секунду делает тысячи запросов — намеренно или из-за бага. Без ограничений сервис ляжет. Rate limiting — это барьер: разрешаем, скажем, 100 запросов в минуту на клиента, остальные отклоняем с кодом 429 Too Many Requests.

Важный момент: rate limiting не реализуют внутри обработчика запроса. Его выносят в middleware или внешний балансировщик (nginx, API Gateway). Обработчик просто не должен знать о лимитах.

Middleware с заголовками RateLimit-*

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-Limit/Remaining/Reset — добавляются не только в ответ 429, но и в каждый успешный ответ. Клиент видит остаток запасов заранее и может самостоятельно замедлиться.
  • RateLimit-Reset — Unix timestamp момента сброса окна.

Как выглядит ответ 429

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"
}

И в успешных ответах тоже

HTTP/1.1 200 OK
Content-Type: application/json
RateLimit-Limit: 100
RateLimit-Remaining: 57
RateLimit-Reset: 1750291200

{ "orderId": "ord-9182", "status": "CONFIRMED" }

Благодаря заголовкам в успешных ответах клиент знает остаток прямо сейчас — и может притормозить сам, не дожидаясь 429.

Описать 429 в OpenAPI

FastAPI code-first: 429 указывают явно через responses в декораторе, чтобы потребители API видели это в документации.

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:
    ...

Загрузка файлов: почему не Base64 в JSON

Файл — бинарные данные. Если передать его Base64-строкой в JSON-теле, размер вырастает на 33% и теряется возможность передавать данные потоком. Правильный транспорт — multipart/form-data и тип UploadFile в FastAPI.

Приём файла через UploadFile

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,
) -> AttachmentResponse:
    if file.size is not None and file.size > 10 * 1024 * 1024:
        raise FileTooLargeError()
    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, size и к содержимому через await file.read(). Ограничения на размер и допустимые типы проверяют явно в обработчике: size отсекает заведомо большой файл до чтения, а повторная проверка после read() нужна потому, что размер части клиент может не прислать. Для multipart/form-data в проект ставят python-multipart, без него FastAPI откажется объявлять UploadFile.

Как выглядит HTTP-запрос

живой пример

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"
}

Ответ 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-клиент не знают, под каким именем сохранять файл.

Для небольших статических файлов подходит FileResponse — он сам выставляет Content-Disposition и Content-Length:

from fastapi.responses import FileResponse

return FileResponse(
    path=file_path,
    media_type="application/pdf",
    filename="invoice.pdf",
)

Deprecation: как плавно убрать старый эндпоинт

Рано или поздно API меняется: появляется /v2, старый маршрут становится устаревшим. Резко удалить его нельзя — у клиентов нет времени перейти. Правильный путь — объявить deprecation, дать срок и только потом убрать.

день 0 deprecated=True, заголовки Deprecation и Sunset с датой месяцы 1–5 метрика по шаблону маршрута: кто ещё ходит дата Sunset 410 Gone с адресом замены позже удалить роутер из кода

Вывод эндпоинта из работы это объявление, наблюдение за клиентами, отключение в объявленный день и только потом удаление кода.

В FastAPI это делается в два шага.

Шаг 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. Добавить HTTP-заголовки

Пометка в OpenAPI видна только при просмотре документации. Но клиенты, которые уже используют эндпоинт, документацию не читают. Для них — HTTP-заголовки в каждом ответе. Их удобно добавлять через Depends:

from fastapi import Depends, Response


SUNSET_HTTP_DATE = "Tue, 01 Dec 2026 00:00:00 GMT"
DEPRECATION_TS = 1796083200
SUCCESSOR = "/api/v2/orders/{order_id}"


def deprecation_headers(response: Response):
    response.headers["Sunset"] = SUNSET_HTTP_DATE
    response.headers["Deprecation"] = f"@{DEPRECATION_TS}"
    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"}

Каждый ответ будет содержать (дата в Sunset — в формате HTTP-даты, и день недели в ней должен быть настоящим):

HTTP/1.1 200 OK
Sunset: Tue, 01 Dec 2026 00:00:00 GMT
Deprecation: @1796083200
Link: </api/v2/orders/{order_id}>; rel="successor-version"

{ "status": "PROCESSING" }
  • Sunset (RFC 8594) — точная дата отключения в HTTP-date формате. Без даты клиент не может планировать миграцию.
  • Deprecation — машиночитаемый флаг для SDK и мониторинга. RFC 9745 закрепил формат «дата как Unix-время с собачкой»: @1796083200; в старых черновиках и клиентах ещё встречается Deprecation: true.
  • Даты и константы живут на уровне модуля, а не в параметрах функции-зависимости: параметр с умолчанием FastAPI прочитает как query-параметр, и клиент сможет подменить дату через ?sunset_date=....
  • Link с rel="successor-version" — клиент знает, куда переходить.

Шаг 3. После даты Sunset — 410 Gone

После наступления даты Sunset handler заменяется на 410 Gone. Код 404 здесь не подходит: 404 означает «не знаю такого», а 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. Клиент получает понятную ошибку с указанием альтернативы.

Рекомендуемый период между объявлением deprecation и датой Sunset — от 6 до 12 месяцев. Этого хватает крупным потребителям, чтобы переключиться.

Дополнительно: при первом чтении можно пропустить

Глубже: большие файлы: подписанная ссылка мимо приложениярасширенное

Десять мегабайт через UploadFile проходят. Видео на два гигабайта нет: каждый такой запрос держит соединение на минуты, память под буферы, а под нагрузкой контейнеры начинают перезапускаться по памяти. Стандартный ответ для больших файлов: байты не идут через приложение вовсе.

Приложение выдаёт клиенту подписанную ссылку на объектное хранилище: POST /uploads отвечает адресом и полями формы, по которым браузер отправляет файл прямо в S3, с ограничением размера content-length-range внутри подписи, и записью о будущем файле в базе. Когда загрузка завершена, клиент сообщает об этом или хранилище присылает событие, и запись помечается готовой. Скачивание устроено так же: GET /files/{id} отвечает 302 на подписанную ссылку с коротким сроком жизни, а имя файла для сохранения задают параметром ResponseContentDisposition при подписи. Как это делается на boto3, разобрано в статье про объектное хранилище.

Что остаётся приложению: проверка прав на выдачу ссылки, лимит на число и размер загрузок, антивирус или проверка типа по событию после загрузки, и уборка записей о файлах, которые так и не загрузили.

Глубже: скачивание: имя не латиницей и потоковая отдачарасширенное

Файл отдают обычным GET, но ответ несёт двоичный Content-Type и заголовок Content-Disposition: attachment; filename="report.pdf", по которому браузер сохраняет файл с именем. Пока имя латинское, всё хорошо; «Отчёт за март.pdf» в обычный filename не положить, заголовки HTTP это ASCII. Для таких имён есть форма filename*=utf-8''%D0%9E..., и FileResponse(path, filename="Отчёт за март.pdf") ставит её сам, кодируя имя через quote; руками заголовок собирать не нужно.

Файл, которого нет на диске (отчёт, собранный из базы, выгрузка в CSV), не собирают в памяти целиком. StreamingResponse принимает генератор и отдаёт данные по мере готовности:

async def rows() -> AsyncIterator[bytes]:
    yield "id;total\n".encode()
    async for order in repo.stream(customer_id):
        yield f"{order.id};{order.total}\n".encode()

return StreamingResponse(rows(), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=orders.csv"})

Два следствия. Content-Length неизвестен, ответ уходит кусками, и клиент не покажет процент загрузки. И ошибка посреди генератора уже не превратится в 500: статус давно отправлен, клиент получит обрезанный файл. Поэтому данные для выгрузки проверяют до первого yield, а обрыв пишут в лог с идентификатором запроса.

Коротко

  • Rate limiting реализуют в middleware, не в обработчике. Ответ 429 обязательно содержит Retry-After и RateLimit-Limit/Remaining/Reset.
  • RateLimit-* добавляют не только в 429, но и в каждый успешный ответ — клиент видит остаток запасов заранее.
  • Файлы принимают через UploadFile + multipart/form-data, не через Base64 в JSON.
  • При скачивании всегда указывают Content-Disposition: attachment; filename="..." — иначе клиент не знает имя файла.
  • Deprecation = два шага: deprecated=True в декораторе (видно в Swagger) + заголовки Sunset/Deprecation/Link в ответах (видно клиентам).
  • Sunset без конкретной даты — бессмысленен: дату ставят сразу, минимум за 6 месяцев.
  • После даты Sunset эндпоинт возвращает 410 Gone с указанием альтернативы.
  • Файлы больше десятка мегабайт идут мимо приложения по подписанной ссылке в S3; при скачивании FileResponse сам ставит filename* для кириллицы, а собираемый отчёт отдают StreamingResponse, не держа в памяти.

Что почитать дальше