← Back to the section

Standard CRUD works well for one resource at a time. But there are three tasks that need a separate approach: create a hundred records at once, wait for a long-running background job, and show an error in the user's language. Let's cover each.

Batch operations

Imagine a client wants to create 50 orders in a single request. You could send 50 separate POSTs — but that's 50 HTTP requests, 50 rounds of overhead, and the client has to wait for each one. A batch operation lets you pass all items in one request.

What the request looks like

A batch endpoint follows the pattern POST /resources/batch or POST /resources/batch/<action>:

POST /api/v1/orders/batch
Content-Type: application/json

{
  "items": [
    { "productId": "aaa", "quantity": 2 },
    { "productId": "bbb", "quantity": 1 },
    { "productId": "ccc", "quantity": 5 }
  ]
}

Partial success is not a failure of the whole operation

The key idea: if one of the items fails, the rest are processed as usual. This is called partial success.

The server returns 200 OK with a per-item result:

{
  "results": [
    { "index": 0, "status": "SUCCESS", "orderId": "..." },
    { "index": 1, "status": "ERROR", "error": { "code": "INSUFFICIENT_STOCK", "detail": "Product bbb is out of stock" } },
    { "index": 2, "status": "SUCCESS", "orderId": "..." }
  ],
  "summary": {
    "total": 3,
    "succeeded": 2,
    "failed": 1
  }
}

What matters here:

  • 200 OK even on a partial failure — this is not a request failure, but a normal response with results.
  • index shows the item's position in the original array (zero-based).
  • statusSUCCESS or ERROR for each item.
  • On error — an error object with a code and a detail. This is not a full ProblemDetails, because the error concerns a specific item rather than the whole request.
  • summary — the final counters: total, succeeded, failed.

When you need atomicity

Partial success is the default behavior. Sometimes you need the opposite: all or nothing. This is called atomicity (all-or-nothing). If a service supports this mode, it's stated explicitly in the documentation:

"All items are processed in a single transaction. A failure of any item rolls back all of them. On a partial failure, a 400 is returned with the indexes of the failed items."

Without such a note, the client should expect partial success.

Size limit

Accepting an unlimited number of items is dangerous — it's a load on the server and a long response time. That's why the maximum batch size is stated in the documentation (for example, no more than 100 items).

If the client exceeds the limit, the server returns:

HTTP/1.1 400 Bad Request

{
  "type": "urn:problem:order-service:batch-size-exceeded",
  "status": 400,
  "title": "Bad Request",
  "detail": "Request size exceeds the maximum (100 items)",
  "code": "BATCH_SIZE_EXCEEDED"
}

Async operations

Some operations can't finish within the time of an HTTP request. Generating a yearly report may take 30 seconds, a bulk mailing — several minutes. Holding the connection open that long is a bad idea: the network may drop, and the client's timeout will expire.

The solution: the server accepts the task right away, returns a response, and does the processing in the background. The client checks the status periodically — this is called polling.

Step 1: submit the task

POST /api/v1/reports/generate
Content-Type: application/json

{ "dateFrom": "2026-01-01", "dateTo": "2026-12-31" }

The server replies 202 Accepted — the request has been accepted but not yet completed:

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 in the header — the address where the status can be checked.
  • statusUrl in the body — the same thing, for clients that don't read response headers.
  • taskId — the task identifier.

Step 2: poll the status

The client periodically does GET /api/v1/tasks/{id}. While the task is running:

{
  "taskId": "550e8400-...",
  "status": "PROCESSING",
  "progress": 45,
  "createdAt": "2026-05-26T10:30:00Z"
}

When the task has completed successfully, a link to the result appears:

{
  "taskId": "550e8400-...",
  "status": "COMPLETED",
  "progress": 100,
  "createdAt": "2026-05-26T10:30:00Z",
  "completedAt": "2026-05-26T10:35:00Z",
  "resultUrl": "/api/v1/reports/550e8400-..."
}

If the task failed, a description of the problem arrives:

{
  "taskId": "550e8400-...",
  "status": "FAILED",
  "createdAt": "2026-05-26T10:30:00Z",
  "completedAt": "2026-05-26T10:32:00Z",
  "error": {
    "code": "REPORT_GENERATION_FAILED",
    "detail": "Failed to build the report: no data for the period"
  }
}

Task statuses

A task goes through four states:

StatusWhat it means
PENDINGcreated, waiting in the queue
PROCESSINGrunning right now
COMPLETEDfinished; resultUrl is required
FAILEDfinished with an error; error is required

How often to poll is up to the client. Usually once every 1-5 seconds for short tasks, once every 30-60 seconds for long ones. The server can suggest an interval via the Retry-After header.

Localizing error messages

Users see error messages — and they want to see them in their own language. The client indicates its preferred language via the Accept-Language header:

GET /api/v1/orders/123
Accept-Language: ru

GET /api/v1/orders/123
Accept-Language: en

If the header is not provided, the server uses the default language (typically Russian).

What exactly gets localized

Two fields in an error response get localized:

  • detail in ProblemDetails — the human-readable description of the error.
  • message in violations — the description next to a specific form field.
// Accept-Language: ru
{
  "code": "ORDER_NOT_FOUND",
  "detail": "Заказ не найден"
}

// Accept-Language: en
{
  "code": "ORDER_NOT_FOUND",
  "detail": "Order not found"
}

What must not be localized

Some parts of the response deliberately stay in English:

  • code — the machine error code. Client code does switch (error.code) and must not depend on the user's language. Correct: ORDER_EMPTY; wrong: ЗАКАЗ_ПУСТОЙ.
  • title — the standard HTTP status name: Bad Request, Not Found. Always in English.
  • type — a URI or URN, a technical identifier. Always in English.
  • JSON field namesorderId, not идЗаказа. The JSON structure is the same for all languages.

The reason is simple: these fields are used by program code, not by people. Localizing them means breaking the clients that rely on them.

In short

  • A batch operation accepts a list of items in one request and returns 200 OK with a per-item result.
  • The default is partial success: one item's failure doesn't cancel the rest.
  • Atomicity (all or nothing) requires an explicit note in the documentation.
  • Exceeding the size limit — 400 BATCH_SIZE_EXCEEDED.
  • A long-running operation returns 202 Accepted with Location and taskId; the client polls the status via GET.
  • Task statuses: PENDINGPROCESSINGCOMPLETED (with resultUrl) or FAILED (with error).
  • Only detail and violations.message are localized — via the Accept-Language header.
  • Error codes, HTTP headers, and JSON field names stay in English.
  • Errors in REST API: ProblemDetails and codes — how code, detail, and type are structured.
  • Request and response headers — Idempotency-Key for batch operations, Location for async ones.
  • Limits, files, and versioning — adjacent topics.