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

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

Сначала — все три случая на одной схеме: что уходит в запросе, что приходит в ответ и что с этим делает клиент.

что уходитчто приходит в ответчто дальшеPOST /orders/batch3 элемента, лимит 100200 OK — 2 из 3 прошлиSUCCESS, ERROR, SUCCESSповтор 1 из 3 POST /reports/generateсчитается 5 минут202 Accepted + taskIdGET /tasks/{id}: 45% → 100%опрос 30-60 с Accept-Language: enошибка ORDER_NOT_FOUNDdetail: «Order not found»code остался ORDER_NOT_FOUNDswitch работает частая ошибка в каждом из трёх случаев400 на весь запрос2 успеха из 3 потеряныдержать соединение5 минут до таймаутаперевести codeswitch у клиента падает

Статус 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": "Не удалось сформировать отчёт: данные за период отсутствуют"
  }
}

Статусы задачи

Задача проходит через четыре состояния:

PENDING PROCESSING COMPLETED взяли в работу есть resultUrl FAILED есть error

Четыре состояния задачи по порядку: смотрите, в какой момент в ответе появляется 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; выгрузка по курсору остаётся для догона.

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