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

Три независимые темы, которые часто возникают вместе, когда API выходит за рамки простого CRUD: защита от перегрузки, работа с двоичными данными и управляемое снятие версий.

Когда клиент делает слишком много запросов

Любой публичный или высоконагруженный API рано или поздно сталкивается с проблемой: один клиент делает тысячи запросов в минуту и забивает сервер. Чтобы защититься, вводят rate limiting — ограничение числа запросов в единицу времени.

Если лимит превышен, сервер возвращает статус 429 Too Many Requests:

HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/problem+json

{
  "type": "urn:problem:order-service:rate-limit-exceeded",
  "status": 429,
  "title": "Too Many Requests",
  "detail": "Превышен лимит запросов. Повторите через 30 секунд.",
  "code": "RATE_LIMIT_EXCEEDED"
}

Ключевой момент — заголовок Retry-After: 30. Он говорит клиенту: подожди 30 секунд и попробуй снова. Без него клиент не знает, когда повторять, и начинает слать запросы непрерывно — что только усугубляет ситуацию.

Информируй клиента заранее

Хорошая практика — добавлять информацию о лимите в каждый успешный ответ, а не только при превышении:

HTTP/1.1 200 OK
RateLimit-Limit: 100
RateLimit-Remaining: 57
RateLimit-Reset: 1719849600

{ ... }
  • RateLimit-Limit — сколько запросов разрешено в окне.
  • RateLimit-Remaining — сколько ещё можно сделать.
  • RateLimit-Reset — Unix timestamp момента сброса счётчика.

Клиент видит, что осталось 57 из 100, и может заранее замедлиться — вместо того чтобы внезапно получить 429.

В OpenAPI ответ 429 документируют так:

"429":
  description: 'Too Many Requests'
  headers:
    Retry-After:
      schema: { type: integer }
      description: 'Секунд до сброса лимита'
    RateLimit-Limit:
      schema: { type: integer }
    RateLimit-Remaining:
      schema: { type: integer }
    RateLimit-Reset:
      schema: { type: integer }
      description: 'Unix timestamp сброса окна'
  content:
    application/problem+json:
      schema:
        $ref: "#/components/schemas/ProblemDetails"

Частая ошибка: вернуть 429, но без Retry-After и без RateLimit-* заголовков. Клиент не получает никакой информации — он либо делает слепой экспоненциальный откат, либо продолжает спамить.

Загрузка файлов

JSON удобен для структурированных данных, но для файлов он не подходит. Если закодировать файл в Base64 и положить в JSON — он разбухнет на 33% и не будет потоковым. Если передать сырые байты в теле — теряется метаинформация (имя файла, тип).

Правильный формат для загрузки файлов — multipart/form-data. Он позволяет передать файл как бинарные данные вместе с метаданными в одном запросе.

Куда слать запрос

Файл — это вложенный ресурс. Правило простое: файл принадлежит какой-то сущности, и URL это отражает:

POST /api/v1/documents/{id}/attachments     # вложение к документу
POST /api/v1/users/me/avatar               # аватар пользователя

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

POST /api/v1/documents/{id}/attachments
Content-Type: multipart/form-data; boundary=----Boundary

------Boundary
Content-Disposition: form-data; name="file"; filename="report.pdf"
Content-Type: application/pdf

<binary data>
------Boundary
Content-Disposition: form-data; name="description"

Отчет за март
------Boundary--

В одном запросе — файл с его именем и типом, плюс любые текстовые поля.

Ограничения прописывают в OpenAPI:

requestBody:
  content:
    multipart/form-data:
      schema:
        type: object
        required: [file]
        properties:
          file:
            type: string
            format: binary
            description: 'Максимум 10 МБ. Допустимые типы: PDF, PNG, JPG'
          description:
            type: string
            maxLength: 500

Ответ на загрузку — 201 + метаданные

После успешной загрузки сервер возвращает 201 Created и метаданные сохранённого файла:

{
  "attachmentId": "550e8400-...",
  "fileName": "report.pdf",
  "contentType": "application/pdf",
  "size": 1048576,
  "uploadedAt": "2026-05-26T10:30:00Z"
}

Скачивание файла

Для скачивания — обычный GET-запрос, но ответ содержит бинарные данные с нужными заголовками:

GET /api/v1/documents/{id}/attachments/{attachmentId}

HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="report.pdf"
Content-Length: 1048576

<binary data>

Content-Disposition: attachment; filename="..." — браузер сохранит файл с правильным именем. Без этого заголовка файл сохранится как безымянный или с именем URL-пути.

Частые ошибки:

  • Загрузка через JSON с Base64 — раздутый размер, нет потоковой передачи.
  • Отсутствие Content-Disposition при скачивании — браузер не знает имя файла.
  • Не указан максимальный размер в OpenAPI — клиент не знает ограничений.

Deprecation — как правильно выводить эндпоинты из эксплуатации

Ситуация: у вас есть /api/v1/orders/{id}/status, но вы написали более удобный /api/v2/orders/{id}. Нужно перевести всех клиентов на новую версию, но нельзя просто удалить старую — там сотни активных интеграций.

Решение — управляемый вывод из эксплуатации через стандартные заголовки.

Шаг 1: пометить в OpenAPI

/api/v1/orders/{id}/status:
  get:
    deprecated: true
    summary: 'Получить статус заказа'
    description: 'DEPRECATED: используйте GET /api/v2/orders/{id}. Будет удалён после 2026-09-01.'

Флаг deprecated: true — сигнал для инструментов: генераторы SDK подчеркнут этот метод, документация покажет предупреждение.

Шаг 2: добавить заголовки в ответ

Пока эндпоинт ещё работает, каждый ответ от него несёт предупреждение:

HTTP/1.1 200 OK
Sunset: Sat, 01 Sep 2026 00:00:00 GMT
Deprecation: true
Link: </api/v2/orders/{id}>; rel="successor-version"
  • Sunset (RFC 8594) — точная дата отключения.
  • Deprecation: true — флаг, что эндпоинт устарел.
  • Link с rel="successor-version" — ссылка на альтернативу.

Клиенты, которые мониторят заголовки (библиотеки, SDK), автоматически заметят устаревание и начнут сигнализировать разработчикам.

Шаг 3: уведомить потребителей

Заголовков недостаточно — нужно активно сообщить: changelog, рассылка, Slack, внутренний портал разработчиков. Чем раньше узнают, тем больше времени на миграцию.

Стандартный период между объявлением deprecation и фактическим отключением — 6–12 месяцев.

Шаг 4: после Sunset — 410 Gone

Когда срок вышел, эндпоинт не удаляют молча — он возвращает 410 Gone с объяснением:

{
  "type": "urn:problem:order-service:endpoint-removed",
  "status": 410,
  "title": "Gone",
  "detail": "Эндпоинт удалён. Используйте GET /api/v2/orders/{id}.",
  "code": "ENDPOINT_REMOVED"
}

410 отличается от 404: 404 говорит «не нашёл», 410 говорит «было, но больше нет намеренно». Клиент сразу понимает — это не ошибка сети, а закрытый API.

Частая ошибка: пометить deprecated: true в OpenAPI, но не добавить Sunset. Клиент не знает дедлайна — «устарело» превращается в «когда-нибудь уберём», и миграция не происходит.

Коротко

  • Rate limiting: при превышении — 429 + Retry-After (секунд до сброса). В каждый успешный ответ добавляй RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset. 429 без заголовков бесполезен для клиента.
  • Файлы загружают через POST multipart/form-data на вложенный ресурс. Не Base64 в JSON. Ограничения размера и типов документируют в OpenAPI.
  • Скачивание — GET с бинарным Content-Type и Content-Disposition: attachment; filename="...".
  • Deprecation: deprecated: true в OpenAPI + Sunset + Deprecation: true + Link rel=successor-version в ответах. После даты Sunset — 410 Gone с подсказкой альтернативы. Период между объявлением и отключением — минимум 6 месяцев.

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

  • Ошибки и формат RFC 9457 — формат тела 429 и 410.
  • Версионирование API — как строить v1 → v2 и управлять переходом.
  • Пакетные запросы и асинхронность — альтернатива, когда нужно снизить нагрузку.
  • JSON и формат ответов — общие правила структуры ответов.