Стандартный CRUD хорошо работает для одного ресурса за раз. Но бывают три задачи, где нужен отдельный подход: создать сразу сотню записей, дождаться длительной фоновой задачи и показать ошибку на языке пользователя. Разберём каждую.
Сначала — все три случая на одной схеме: что уходит в запросе, что приходит в ответ и что с этим делает клиент.
Статус HTTP отвечает за запрос, а не за судьбу каждого элемента: 200 OK с одним ERROR внутри — нормальный ответ. Длительная работа уходит за 202 и опрос статуса, а перевод касается текстов для человека — code остаётся машинным.
Массовые операции
Экран импорта прайса висит две минуты: приложение шлёт по одному POST на позицию, пятьдесят раз платит за соединение, авторизацию и запись в журнал и ждёт каждый ответ по очереди. Массовая операция (batch) передаёт все элементы в одном запросе, и платят за них один раз.
Как выглядит запрос
Endpoint для массовой операции строится по шаблону POST /resources/batch или POST /resources/batch/<действие>:
живой пример
POST /api/v1/orders/batch
Content-Type: application/json
{
"items": [
{ "productId": "aaa", "quantity": 2 },
{ "productId": "bbb", "quantity": 1 },
{ "productId": "ccc", "quantity": 5 }
]
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Частичный успех — не ошибка всей операции
Ключевая идея: если один из элементов не прошёл, остальные обрабатываются как обычно. Это называется частичный успех (partial success).
Сервер возвращает 200 OK с результатом по каждому элементу:
{
"results": [
{ "index": 0, "status": "SUCCESS", "orderId": "..." },
{ "index": 1, "status": "ERROR", "error": { "code": "INSUFFICIENT_STOCK", "detail": "Товар bbb отсутствует на складе" } },
{ "index": 2, "status": "SUCCESS", "orderId": "..." }
],
"summary": {
"total": 3,
"succeeded": 2,
"failed": 1
}
}
Два места в этом ответе решают больше, чем кажется. Статус 200 OK при частичной ошибке: это не сбой запроса, а нормальный ответ с результатами, и есть отдельный код ровно для такого случая, 207 Multi-Status; если партнёрское API отвечает им, это не ошибка, а другой выбор того же самого. И объект error у элемента: это не полный ProblemDetails, потому что ошибка относится к конкретной позиции, а не ко всему запросу; index говорит, к какой именно, нумерация с нуля.
Когда нужна атомарность
Частичный успех — поведение по умолчанию. Иногда нужно противоположное: либо все, либо никто. Это называется атомарность (all-or-nothing). Если сервис поддерживает такой режим, это явно указывается в документации:
«Все элементы обрабатываются в одной транзакции. Ошибка любого элемента приводит к откату всех. При частичном сбое возвращается 400 с индексами упавших элементов.»
Без такой оговорки клиент должен рассчитывать на частичный успех.
Ограничение на размер
Принимать неограниченное количество элементов опасно — это нагрузка на сервер и долгое время ответа. Поэтому максимальный размер массовой операции указывается в документации (например, не более 100 элементов).
Если клиент превысил лимит, сервер возвращает:
HTTP/1.1 400 Bad Request
{
"type": "urn:problem:order-service:batch-size-exceeded",
"status": 400,
"title": "Слишком большая партия",
"detail": "Размер запроса превышает максимум (100 элементов)",
"code": "BATCH_SIZE_EXCEEDED"
}
Где порог между партией и задачей
Партия с ответом сразу и длинная задача с ответом «принято» — не два независимых приёма, а две стороны одного выбора. Порог определяется одним вопросом: укладывается ли обработка в бюджет ответа.
Бюджет ответа задают таймауты по цепочке: клиент, балансировщик, шлюз. Обычно это единицы секунд, реже десятки. Значит:
Партия обрабатывается синхронно, если её обработка уверенно укладывается в этот бюджет с запасом — то есть десятки элементов, каждый по несколько миллисекунд. Ответ 200 (или 207) с разбором по элементам.
Операция становится задачей, если обработка может выйти за бюджет: сотни и тысячи элементов, обращения к внешним службам на каждый элемент, тяжёлые вычисления. Ответ 202 с идентификатором задачи.
Практический ориентир для «десяти тысяч позиций»: это всегда задача. Даже если каждая позиция обрабатывается за миллисекунду, десять тысяч — это десять секунд, то есть за пределом бюджета; а любая ошибка на девятитысячной позиции означает, что клиент не узнает ни результата, ни причины.
Отсюда и предел размера партии в контракте: сервер объявляет максимум (обычно 100–1000 элементов) и отвечает отказом на превышение. Предел выбирают так, чтобы худший случай укладывался в бюджет: сто элементов по 50 миллисекунд — пять секунд, уже на грани; значит, либо предел ниже, либо операция асинхронная.
Что отвечать на превышение предела
Оба кода законны, и выбор объясним.
400 — «запрос неверен по нашим правилам»: предел размера партии это правило контракта, и отказ по нему ничем не отличается от отказа по любой другой проверке. Ответ содержит код ошибки и сам предел, чтобы клиент мог порезать список сам.
413 — «слишком большое содержимое»: уместнее, когда речь о размере тела в байтах, а не о числе элементов, и особенно когда предел установлен не вашим кодом, а сервером приложения или прокси (там 413 придёт сам, и вы его не контролируете).
Практическое правило: число элементов — 400 со своим кодом и указанием предела; размер тела — 413. И то и другое описывают в контракте, потому что клиент обязан знать предел заранее, а не выяснять его отказами.
Идемпотентность массовой операции
Повтор POST /orders/batch после обрыва сети создаст пятьдесят заказов заново — и это самый частый инцидент с партиями, потому что обрыв на большом запросе куда вероятнее, чем на маленьком.
Защита та же, что для одиночной операции, но с двумя уровнями.
Ключ на партию. Клиент передаёт один ключ идемпотентности на весь запрос; сервер запоминает его вместе с результатом и на повтор возвращает тот же ответ. Этого достаточно, когда партия — единица работы («загрузить этот файл»).
Ключ на элемент. Каждый элемент несёт свой идентификатор от клиента (clientRequestId), и сервер проверяет его при создании. Тогда повтор частично прошедшей партии дообработает только то, чего не было, — и это то, что нужно, когда партия может пройти наполовину.
POST /orders/batch
Idempotency-Key: 9b1c…
{ "items": [
{ "clientRequestId": "cart-7f2a", "customerId": "cus-01", "lines": [ … ] },
{ "clientRequestId": "cart-91bd", "customerId": "cus-02", "lines": [ … ] }
] }
Второй уровень нужен и при резке на страницы: клиент, у которого пять тысяч элементов, отправляет пятьдесят запросов по сто — и у каждого свой ключ партии, а у каждого элемента свой идентификатор. Тогда повтор любого куска безопасен, и порядок кусков не важен. Без идентификаторов элементов клиент не может ни повторить кусок, ни понять, какие элементы уже прошли.
Как узнать о готовности: опрос или обратный вызов
Опрос — не единственный способ, и выбор между двумя стоит сделать осознанно.
Опрос (клиент периодически спрашивает состояние) прост, работает всегда, не требует от клиента публичного адреса и переживает его перезапуск. Цена: задержка (в среднем половина интервала опроса), лишние запросы (тысяча клиентов раз в секунду — тысяча запросов в секунду на ручку состояния) и необходимость объяснить клиенту разумный интервал. Интервал подсказывают заголовком с рекомендуемой задержкой, а на слишком частый опрос отвечают ограничением.
Обратный вызов (сервер сам обращается к адресу клиента по готовности) даёт нулевую задержку и снимает лишний трафик. Цена больше: клиенту нужен доступный извне адрес, серверу — повторы с нарастающей паузой, подпись запроса, очередь недоставленных, а клиенту — идемпотентность на приём (уведомление придёт дважды).
Правило выбора: клиентов много и они разные (мобильные приложения, браузеры) — опрос; клиент один-два, они серверные и готовность важна сразу — обратный вызов. И частая рабочая схема — оба: обратный вызов как основной путь, опрос как страховка, потому что уведомление может не дойти.
Срок жизни задачи и ссылки на результат
Контракт асинхронной операции неполон без ответа на «сколько это живёт».
Состояние задачи хранят фиксированный срок после завершения — обычно от суток до недели. Пока срок не истёк, GET /tasks/{id} отвечает состоянием; после — 410 Gone, что честнее 404: задача была, просто её больше нет. 404 оставляют для идентификаторов, которых не существовало вовсе.
Ссылка на результат живёт отдельно и обычно короче: файл с результатом в объектном хранилище отдают подписанной ссылкой на минуты, а сам файл держат по правилу жизненного цикла (скажем, семь дней). Отсюда важное следствие для контракта: ссылка в ответе может истечь, и клиент должен уметь запросить новую через состояние задачи, а не хранить её у себя навсегда.
Незавершённые задачи тоже надо ограничивать: задача, которая «выполняется» третьи сутки, — это почти наверняка потерянная задача. Ей нужен предел времени выполнения, после которого она переводится в состояние отказа с внятной причиной, иначе клиент будет опрашивать её вечно.
Локализация: как читать Accept-Language
Заголовок сложнее, чем «язык клиента»: это список с весами.
Accept-Language: ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7
Читается так: сначала российский русский, потом русский вообще (вес 0,9), потом американский английский (0,8), потом английский (0,7). Сервер обязан выбрать наиболее подходящий из того, что умеет, а не первый в списке: если поддерживаются только ru и de, ответ будет на ru, потому что он подходит под второй пункт.
Три обязательных элемента реализации.
Откат по умолчанию. Ни один из перечисленных языков не поддерживается — отвечаем на основном языке сервиса, а не ошибкой. Ошибка здесь была бы формально допустима (406), но практически вредна.
Content-Language в ответе. Сервер сообщает, какой язык он в итоге выбрал. Без этого клиент не знает, получил он перевод или откат, и не может, например, отрисовать переключатель языка правильно.
Vary: Accept-Language. Обязателен, если ответ зависит от языка: без него кэш (браузер, прокси, сеть доставки) отдаст русский текст англоязычному клиенту — и это тот класс ошибок, который находят через неделю и не могут воспроизвести.
HTTP/1.1 400 Bad Request
Content-Language: ru
Vary: Accept-Language
И отдельно про то, что локализуют: человеческое сообщение (detail, title), а не машинный код. Код ошибки, имя поля и type остаются на латинице и не переводятся никогда — иначе клиентская логика начинает зависеть от языка.
Retry-After на 202 — соглашение, а не стандарт
Небольшая, но важная поправка. Заголовок с рекомендуемой задержкой определён стандартом для ответов 503, 429 и перенаправлений. Для 202 его использование — распространённое соглашение, а не требование: клиенты его обычно понимают, но полагаться на это в контракте нельзя.
Поэтому интервал опроса указывают дважды: заголовком (для тех, кто его читает) и полем в теле ответа (pollAfterSeconds), которое описано в контракте. Второе — то, на что клиент имеет право опираться.
Асинхронные операции
Некоторые операции не успевают завершиться за время HTTP-запроса. Генерация отчёта за год может занять 30 секунд, массовая рассылка — несколько минут. Держать соединение открытым так долго — плохая идея: сеть может оборваться, у клиента истечёт таймаут.
Решение: сервер сразу принимает задачу, возвращает ответ, а обработку делает в фоне. Клиент периодически проверяет статус — это называется опрос (polling).
Шаг 1: отправить задачу
POST /api/v1/reports/generate
Content-Type: application/json
{ "dateFrom": "2026-01-01", "dateTo": "2026-12-31" }
Сервер отвечает 202 Accepted — запрос принят, но ещё не выполнен:
HTTP/1.1 202 Accepted
Location: /api/v1/tasks/550e8400-...
{
"taskId": "550e8400-...",
"status": "PENDING",
"createdAt": "2026-05-26T10:30:00Z",
"statusUrl": "/api/v1/tasks/550e8400-..."
}
Locationв заголовке — адрес, по которому можно проверять статус.statusUrlв теле — то же самое, для клиентов, которые не читают заголовки ответа.taskId— идентификатор задачи.
Шаг 2: опрашивать статус
Клиент периодически делает GET /api/v1/tasks/{id}. Пока задача выполняется:
{
"taskId": "550e8400-...",
"status": "PROCESSING",
"progress": 45,
"createdAt": "2026-05-26T10:30:00Z"
}
Когда задача завершилась успешно — появляется ссылка на результат:
{
"taskId": "550e8400-...",
"status": "COMPLETED",
"progress": 100,
"createdAt": "2026-05-26T10:30:00Z",
"completedAt": "2026-05-26T10:35:00Z",
"resultUrl": "/api/v1/reports/550e8400-..."
}
Если задача завершилась с ошибкой — приходит описание проблемы:
{
"taskId": "550e8400-...",
"status": "FAILED",
"createdAt": "2026-05-26T10:30:00Z",
"completedAt": "2026-05-26T10:32:00Z",
"error": {
"code": "REPORT_GENERATION_FAILED",
"detail": "Не удалось сформировать отчёт: данные за период отсутствуют"
}
}
Статусы задачи
Задача проходит через четыре состояния:
Четыре состояния задачи по порядку: смотрите, в какой момент в ответе появляется resultUrl, а в какой вместо него приходит error.
| Статус | Что означает |
|---|---|
PENDING | создана, ожидает своей очереди |
PROCESSING | выполняется прямо сейчас |
COMPLETED | завершена; resultUrl обязателен |
FAILED | завершена с ошибкой; error обязателен |
Как часто опрашивать — решает клиент. Обычно раз в 1-5 секунд для коротких задач, раз в 30-60 секунд для длинных. Сервер может подсказать интервал через заголовок Retry-After.
Локализация сообщений об ошибках
Пользователи видят сообщения об ошибках — и хотят видеть их на своём языке. Клиент сообщает предпочтительный язык через заголовок Accept-Language:
живой пример
GET /api/v1/orders/123
Accept-Language: ru
GET /api/v1/orders/123
Accept-Language: en
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Если заголовок не указан — сервер использует язык по умолчанию (как правило, русский).
Что именно локализуется
В ответе об ошибке локализуются три поля:
detailв ProblemDetails — человекочитаемое описание ошибки.titleв ProblemDetails — короткое название самой проблемы: «Заказ не найден», «Недостаточно средств». Это не имя HTTP-статуса, а сводка о том, что пошло не так, и RFC 9457 прямо разрешает её переводить.messageвviolations— описание рядом с конкретным полем формы.
// Accept-Language: ru
{
"code": "ORDER_NOT_FOUND",
"detail": "Заказ не найден"
}
// Accept-Language: en
{
"code": "ORDER_NOT_FOUND",
"detail": "Order not found"
}
Что локализовать нельзя
Некоторые части ответа специально остаются на английском:
code— машинный код ошибки. Клиентский код делаетswitch (error.code)и не должен зависеть от языка пользователя. Правильно:ORDER_EMPTY, неправильно:ЗАКАЗ_ПУСТОЙ.type— URI или URN, технический идентификатор. Всегда на английском.- Имена JSON-полей —
orderId, а неидЗаказа. Структура JSON одна для всех языков.
Причина простая: эти поля используются программным кодом, а не людьми. Локализовать их — значит сломать клиентов, которые на них полагаются.
Глубже: вебхуки: события потребителю вместо опросарасширенное
Асинхронная операция выше заканчивается тем, что клиент опрашивает статус. Когда клиентов сорок тысяч, а событий у каждого несколько в день, опрос превращается в основную нагрузку на API, и правильный ответ это вебхук: потребитель регистрирует адрес, а вы сами присылаете туда событие. Как надёжно доставлять наружу (очередь, повторы, подпись, пределы), разбирает статья про надёжность в сети. Здесь контрактная сторона.
Регистрация. Это обычный ресурс API: POST /webhooks с адресом, списком типов событий и ответом, в котором лежит секрет для проверки подписи; GET /webhooks/{id}/deliveries показывает журнал доставок с ответами получателя, без него интеграцию не отладить. Адрес принимают только https и проверяют, что он не ведёт во внутреннюю сеть.
Конверт события. Один формат на все типы, чтобы потребитель писал один разборщик:
{
"id": "evt_01J8X7Z4M2Q9",
"type": "order.paid",
"occurredAt": "2026-09-24T10:15:30Z",
"apiVersion": "2026-09-01",
"data": { "orderId": "550e8400-...", "totalAmount": "1500.00" }
}
id нужен для дедупликации, потому что повторы доставки неизбежны; type с точкой и в прошедшем времени; data это тот же ресурс, что отдаёт GET, или его идентификатор, если тело большое или чувствительное (тогда потребитель дочитывает по API, и данные не утекают в чужие логи). Версия события подчиняется версии API, и новые поля в data добавляют, а не переименовывают.
Что обещают и чего нет. Обещают доставку хотя бы один раз и повторы с растущим интервалом в течение суток; не обещают порядок, поэтому в событии есть время и, для одного объекта, порядковый номер. Потребитель отвечает 2xx за секунды и обрабатывает потом; на повтор известного id отвечает 200, а не ошибкой. Всё это записано в документации рядом с контрактом, потому что потребитель узнаёт о повторах и порядке только оттуда.
В OpenAPI это описывают двумя способами. Раздел callbacks внутри операции связывает регистрацию с тем, что придёт на указанный адрес: выражение '{$request.body#/url}' показывает, откуда берётся адрес, а внутри лежит обычное описание POST с телом события. В OpenAPI 3.1 есть корневой раздел webhooks для событий, не привязанных к операции регистрации. Из обоих генерируется документация и, что важнее, схема события, по которой потребитель генерирует свой обработчик.
Опрос при этом не исчезает: вебхук это ускоритель, а выгрузка по курсору (GET /events?after=evt_...) остаётся способом догнать пропущенное после сбоя у получателя, и полноценная интеграция использует оба.
Коротко
- Массовая операция принимает список
itemsв одном запросе и возвращает200 OKс результатом по каждому элементу. - По умолчанию — частичный успех: ошибка одного элемента не отменяет остальных. Повтор партии защищают ключом идемпотентности на запрос и идентификатором клиента на каждый элемент — иначе обрыв сети создаст всё заново.
- Атомарность (все или никто) требует явного указания в документации.
- Порог между партией и задачей один: укладывается ли обработка в бюджет ответа (единицы секунд). Превышение предела числа элементов —
400 BATCH_SIZE_EXCEEDEDс указанием предела, превышение размера тела —413. - Длительная операция возвращает
202 AcceptedсLocationиtaskId; клиент опрашивает статус через GET. - Статусы задачи:
PENDING→PROCESSING→COMPLETED(сresultUrl) илиFAILED. Состояние живёт ограниченный срок (после —410, а не404), ссылка на результат истекает раньше, а зависшая задача переводится в отказ по предельному времени. Узнать о готовности можно опросом или обратным вызовом — часто и тем и другим. - Локализуются
title,detailиviolations.message:Accept-Languageчитают как список с весами и с откатом на основной язык, в ответ кладутContent-Languageи обязательныйVary: Accept-Language. - Коды ошибок, заголовки HTTP, имена JSON-полей остаются на английском.
- Вебхук это контракт: регистрация как ресурс с секретом и журналом доставок, единый конверт события с
idиtype, обещание «хотя бы один раз без порядка»,callbacksилиwebhooksв OpenAPI; выгрузка по курсору остаётся для догона.
Что почитать дальше
- Ошибки в REST API: ProblemDetails и коды — как устроены
code,detail,type. - Заголовки запросов и ответов —
Idempotency-Keyдля массовых операций,Locationдля асинхронных. - Ограничения, файлы и версионирование — смежные темы.