Пока API это JSON туда и JSON обратно, ему хватает кодов ответа и тела. Три ситуации из этого выпадают, и у них общая беда: клиент не может догадаться сам, а сервер ему не говорит. Один интегратор выбирает всю ёмкость, остальные ждут, а после отказа он долбит ещё чаще, потому что ему не сказали, сколько ждать. Файл в JSON не помещается, а файл на два гигабайта не помещается в приложение. Старый адрес выключили, потому что неделю к нему никто не ходил, и в ночь после релиза упала квартальная выгрузка партнёра. Во всех трёх случаях ответ это заголовок, который сервер обязан отдать, и решение на стороне сервера, которое за этим заголовком стоит.
Когда клиент делает слишком много запросов
Публичный 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 говорит клиенту, сколько ждать. Без него клиент знает только, что ему отказали, и повторяет с той скоростью, с какой умеет: по запросу в секунду, тридцать слепых попыток за полминуты, каждая из которых снова 429 и снова нагрузка на ограничитель.
Один заголовок решает, чем окажется ответ 429: без Retry-After клиент добивает сервер тридцатью слепыми попытками, с Retry-After: 30 — ждёт и повторяет ровно один раз.
Хорошо воспитанный сервер предупреждает заранее и кладёт остаток лимита в каждый успешный ответ: RateLimit-Limit: 100, RateLimit-Remaining: 57, RateLimit-Reset: 23. Последний это секунды до сброса окна, а не момент времени: клиенту не надо сверять часы с сервером. Стандартом эти три заголовка пока не стали: в IETF это черновик, в последних редакциях он выглядит как одна строка RateLimit: "default";r=57;t=23 и отдельная RateLimit-Policy; в живых API преобладает форма из трёх заголовков, и незнакомая запись у чужого сервиса это другая редакция того же черновика, а не ошибка.
Как считать: окно или ведро
Самый простой счётчик это фиксированное окно: ключ «клиент плюс текущая минута», на каждый запрос INCR, при ста отказ, в начале следующей минуты новый ключ. У него есть дыра на границе: сто запросов в 0:59 и сто в 1:00 проходят оба, и сервер принял двести за две секунды при лимите сто в минуту.
Окно честно считает минуту, но на её границе пропускает двойной всплеск: двести запросов за две секунды при лимите сто в минуту. Ведро допускает всплеск только на свою ёмкость, а дальше выдаёт ровный поток со скоростью пополнения.
Скользящее окно эту дыру закрывает: хранят метки времени последних запросов и считают, сколько их за последние шестьдесят секунд. Точно, но на клиента хранится сотня меток вместо одного числа. Компромисс, скользящий счётчик, берёт два соседних окна и взвешивает прошлое по тому, какая его часть ещё попадает в последнюю минуту: одно число на окно и погрешность в проценты. И ведро с токенами: у клиента ведро на сто токенов, каждый запрос берёт один, ведро пополняется со скоростью сто в минуту. Всплеск проходит только на ёмкость ведра, дальше клиент получает ровный поток. Ведро это то, что делают большинство библиотек и шлюзов, и его же стоит выбирать, когда всплески допустимы, а средняя скорость нет.
По какому ключу
Считают по клиенту, а клиент это идентификатор из токена или ключ API, не адрес. Лимит по IP кажется универсальным, но за одним адресом сотового оператора или корпоративного прокси сидят тысячи пользователей, и один шумный сосед закрывает доступ всем. IP остаётся для путей без авторизации, входа и регистрации, где другого ключа нет. И запросы не равны: выгрузка каталога стоит сервера в сто раз дороже чтения одного заказа, поэтому ей назначают цену в десять токенов, а не один, и тяжёлые операции считают отдельно.
Где живёт счётчик
Пока шлюз один, счётчик лежит у него в памяти. С десятью шлюзами каждый считает свою тысячу, реальный лимит становится десятикратным, а при добавлении одиннадцатого плывёт ещё раз. Честный лимит требует общего счётчика, обычно Redis с атомарной операцией: INCR с истечением ключа для окна или Lua-скрипт для ведра, чтобы чтение и запись не разъезжались между шлюзами. Цена: обращение по сети на каждый запрос, около миллисекунды, и новая зависимость на пути каждого вызова.
Счётчик в памяти узла считает свою тысячу на каждом узле. Общий счётчик честный, но становится точкой отказа. Поэтому общий счётчик страхуют локальным потолком: пока Redis недоступен, каждый узел режет по грубому пределу, а не пропускает всё и не останавливает всех.
Зависимость это то, что падает. Если при недоступном Redis ограничитель отказывает всем, минута его недоступности останавливает весь API; если пропускает всё, защита исчезает ровно тогда, когда площадке плохо. Поэтому общий счётчик страхуют локальным потолком: у каждого шлюза есть свой грубый предел, и пока Redis недоступен, он режет по нему. Лимит в это время неточный, но площадка живёт.
В Java: Bucket4j и заголовки
Готовая реализация ведра для Java это Bucket4j, а на несколько узлов у неё есть модуль для Redis. В фильтре перед контроллерами берут ведро клиента и просят из него токен:
Bucket bucket = buckets.computeIfAbsent(clientId, id -> Bucket.builder()
.addLimit(limit -> limit.capacity(100).refillGreedy(100, Duration.ofMinutes(1)))
.build());
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
if (probe.isConsumed()) {
response.setHeader("RateLimit-Remaining", String.valueOf(probe.getRemainingTokens()));
chain.doFilter(request, response);
return;
}
long waitSeconds = Math.ceilDiv(probe.getNanosToWaitForRefill(), 1_000_000_000L);
response.setStatus(429);
response.setHeader("Retry-After", String.valueOf(waitSeconds));
Ответ пробы содержит и остаток, и время до следующего токена, то есть ровно то, что нужно для заголовков. Resilience4j тоже умеет ограничивать частоту, но его RateLimiter сделан для исходящих вызовов: он придерживает поток, который хочет вызвать чужой сервис, а не отказывает чужому клиенту.
429 или 503
Эти два кода путают, а клиенты реагируют на них по-разному. 429 значит «ты слишком часто»: проблема у этого клиента, остальные обслуживаются, и правильная реакция клиента подождать Retry-After и снизить темп. 503 значит «мне сейчас плохо»: сервер перегружен или сбрасывает нагрузку, и это касается всех; клиент откроет предохранитель и перестанет ходить на время. Оба несут Retry-After, но отдать 503 одному нарушителю значит заставить его выключить интеграцию целиком, а отдать 429 всем при перегрузке значит соврать, что виноват каждый.
Ограничение по клиентам нужно там, где клиенты чужие: публичный API и арендаторы на общей площадке. Внутренний трафик между своими сервисами так не режут: там защищают таймаутами, переборками и сбросом нагрузки, а не квотами на вызывающего.
Загрузка файлов
JSON сделан для структур, а не для байтов. Файл, закодированный в Base64 внутри JSON, разбухает на треть, читается целиком в память по обе стороны и не течёт потоком. Формат для файлов это multipart/form-data: тело запроса разделено границей на части, у каждой своё имя, свой тип и, у файловой, имя файла:
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--
Файл принадлежит сущности, и адрес это отражает: вложение к документу, аватар пользователя. В контракте часть описывают как type: string, format: binary, а пределы размера и допустимые типы словами в description.
Контракт обещает 10 МБ, а Spring отдаёт ошибку на втором
Написать «максимум 10 МБ» в контракте мало, надо разрешить это приложению. Spring Boot по умолчанию режет раньше: spring.servlet.multipart.max-file-size равен 1 МБ на файл, max-request-size 10 МБ на весь запрос. Первый же файл на два мегабайта вылетает, и вылетает некрасиво: MaxUploadSizeExceededException без обработчика превращается в 500, и клиент видит «внутренняя ошибка», а не «файл великоват». Пределы выставляют вровень с контрактом, max-file-size: 10MB и max-request-size: 12MB с запасом на текстовые поля, а исключение ловят и отвечают 413 Content Too Large в формате Problem Details. И перед приложением стоит ещё один предел, client_max_body_size у nginx, по умолчанию тот же 1 МБ: он отрежет запрос раньше, чем тот доедет до Java.
Тип по содержимому, а не по расширению
Контроллер принимает часть через @RequestPart, и первое, что он делает, проверяет тип. Не по расширению и не по Content-Type части: и то и другое прислал клиент, и report.pdf с типом application/pdf может оказаться исполняемым файлом. Тип определяют по первым байтам содержимого, это умеет Apache Tika:
@PostMapping(value = "/documents/{id}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<AttachmentResponse> upload(@PathVariable UUID id,
@RequestPart("file") MultipartFile file) throws IOException {
String type = tika.detect(file.getInputStream(), file.getOriginalFilename());
if (!ALLOWED.contains(type)) {
throw new UnsupportedAttachmentType(type);
}
var saved = storage.save(id, file.getInputStream(), file.getSize(), type);
return ResponseEntity.created(location(saved)).body(AttachmentResponse.of(saved));
}
Поток из части отдают в хранилище как поток, не собирая в массив: file.getBytes() это весь файл в куче, и десять параллельных загрузок по десять мегабайт занимают сто. Ответ на загрузку это 201 Created с адресом и метаданными сохранённого файла: идентификатор, имя, тип, размер, время.
Большие файлы: подписанная ссылка мимо приложения
Десять мегабайт через приложение проходят. Видео на два гигабайта нет: каждый такой запрос держит поток и соединение на минуты, память под буферы, а под нагрузкой контейнеры начинают перезапускаться по памяти. Стандартный ответ для больших файлов: байты не идут через сервис вовсе. Сервис выдаёт подписанную ссылку в объектное хранилище, клиент кладёт файл по ней напрямую, а сервису сообщает, что закончил.
Приложение выдаёт ссылку и принимает подтверждение, а байты идут в объектное хранилище напрямую. Память и потоки сервиса не заняты файлом, а проверку содержимого делает фоновая задача до того, как файл станет виден.
Ссылка живёт минуты и подписана на конкретный ключ и тип, поэтому положить по ней что-то другое или позже нельзя. В AWS SDK это одна операция:
var presigned = presigner.presignPutObject(b -> b
.signatureDuration(Duration.ofMinutes(15))
.putObjectRequest(p -> p.bucket("attachments").key(key).contentType(type)));
return presigned.url();
Файл становится видимым не сразу после подтверждения, а после фоновой проверки: тип по содержимому, антивирус, при необходимости размер картинки. До неё запись стоит в состоянии «ожидает», и клиент это состояние видит в ответе.
Скачивание
Отдают файл обычным GET, но ответ несёт двоичный Content-Type, длину и заголовок Content-Disposition: attachment; filename="report.pdf", по которому браузер сохраняет файл с именем. Пока имя латинское, всё хорошо; «Отчёт за март.pdf» в обычный filename не помещается, потому что в заголовках HTTP разрешена только латиница, и каждый браузер портит его по-своему. RFC 6266 добавляет второй параметр, filename*=UTF-8''%D0%9E%D1%82..., с именем в UTF-8 и процентной кодировкой; оба отдают вместе, первый как запасной. Руками это не собирают:
@GetMapping("/documents/{id}/attachments/{attachmentId}")
public ResponseEntity<Resource> download(@PathVariable UUID id, @PathVariable UUID attachmentId,
WebRequest request) {
var meta = attachments.get(id, attachmentId);
if (request.checkNotModified(meta.etag())) {
return null;
}
var disposition = ContentDisposition.attachment()
.filename(meta.fileName(), StandardCharsets.UTF_8)
.build();
return ResponseEntity.ok()
.eTag(meta.etag())
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.contentType(MediaType.parseMediaType(meta.contentType()))
.contentLength(meta.size())
.body(new InputStreamResource(storage.open(meta.key())));
}
Три вещи здесь не про имя. Тело это Resource, а не byte[]: массив это весь файл в памяти на время ответа, поток из хранилища уходит клиенту по мере чтения. ETag и checkNotModified дают клиенту с прежней копией 304 без единого прочитанного байта. А докачку по Range Spring делает сам и отвечает 206, если у ресурса известна длина: файл на диске или свой Resource с contentLength; поток из InputStreamResource читается один раз, и Range с ним не работает. Для больших файлов и это лишнее: скачивание, как и загрузку, отдают подписанной ссылкой на хранилище, и сервис отвечает 302 на неё.
Deprecation — как правильно выводить эндпоинты из эксплуатации
Старый эндпоинт выгрузки удалили в релизе: в журналах за неделю к нему не было обращений. На следующий день упала ночная интеграция партнёра, потому что его задание квартальное, и неделя журналов его не видит. Тишина в журналах означает «не вызывали в этом окне», а не «не вызывают», и вывод эндпоинта из эксплуатации это не удаление, а процесс со сроком, который вмещает самые редкие задания клиентов.
Между объявлением и выключением лежит время на переезд, и оно должно вмещать самые редкие задания клиентов. Выключение в срок это не удаление адреса, а осмысленный ответ «было и больше не будет».
Начинается он в контракте: deprecated: true у операции, и генераторы SDK помечают метод устаревшим, а документация показывает предупреждение. Одновременно каждый ответ старого эндпоинта получает три заголовка:
HTTP/1.1 200 OK
Deprecation: @1756684800
Sunset: Tue, 01 Mar 2027 00:00:00 GMT
Link: </api/v2/orders/550e8400-e29b-41d4-a716-446655440000>; rel="successor-version"
Deprecation (RFC 9745) это момент, с которого эндпоинт объявлен устаревшим; запись @ и секунды с начала 1970 года выглядит странно, но это дата, а не флаг: в старых черновиках писали true, в итоговом стандарте осталась дата. Sunset (RFC 8594) это момент, в который эндпоинт выключат, в обычном формате даты HTTP. Даты разные, и в этом весь смысл: первая в прошлом, вторая в будущем, между ними время на переезд. Link с rel="successor-version" ведёт на преемника, и это настоящий адрес, по которому можно перейти, а не шаблон с фигурными скобками.
Заголовков мало, потому что их почти никто не читает: SDK редко выводят их наружу, а человек в ответе смотрит на тело. Поэтому объявление дублируют людям, письмом и в журнале изменений, а по журналам запросов с идентификатором клиента смотрят, кто продолжает ходить, и дожимают поимённо. Срок между объявлением и выключением для публичного API берут в полгода-год: партнёр с годовым планом работ меньше не успеет, а квартальное задание должно сработать хотя бы раз и увидеть предупреждение. Для внутреннего API, где все потребители известны, хватает пары месяцев и проверки, что каждая команда переехала.
В дату Sunset эндпоинт не удаляют, а меняют ответ на 410 Gone с телом Problem Details, где сказано, куда идти. 404 здесь неправильный: он значит «не нашёл» и оставляет клиенту надежду, что адрес опечатан или временно недоступен; интеграции с повтором так и будут стучать. 410 значит «было, и больше не будет намеренно», и клиентская библиотека, получив его, повторять не станет. Частая ошибка на этом пути одна: deprecated: true есть, Sunset нет, и «устарело» превращается в «когда-нибудь уберём», а переезд не происходит никогда.
Коротко
- Отказ по лимиту это
429сRetry-After; без него клиент повторяет вслепую. В успешные ответы кладутRateLimit-Limit,-Remaining,-Reset. - Фиксированное окно пропускает двойной всплеск на границе; ведро с токенами ограничивает всплеск ёмкостью и даёт ровный поток.
- Ключ лимита это клиент из токена, не IP; тяжёлые операции стоят больше токенов.
- На несколько шлюзов счётчик общий и атомарный, с локальным потолком на случай, когда Redis недоступен.
429это «ты слишком часто»,503это «мне плохо»; внутренний трафик защищают таймаутами и переборками, а не квотами.- Файлы идут через
multipart/form-dataпотоком, тип проверяют по содержимому; пределы Spring и nginx по умолчанию 1 МБ, их ставят вровень с контрактом. - Большие файлы идут мимо приложения по подписанной ссылке в хранилище; скачивание отдают
Resource, сETagи именем черезfilename*. - Вывод эндпоинта:
deprecatedв спеке,DeprecationиSunsetв ответах, наблюдение по клиентам, полгода-год на переезд, в срок410 Gone, не404.
Что почитать дальше
- Ошибки и формат RFC 9457 — тело для
429,413и410. - Версионирование API — как тот же
Sunsetгасит целую версию. - Заголовки — идемпотентный повтор, который стоит уметь клиенту после
429. - Массовые операции и асинхронность — как снять нагрузку с клиента, который делает тысячу запросов вместо одного.