Три независимые темы, которые часто возникают вместе, когда 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 и формат ответов — общие правила структуры ответов.