Skip to main content

Batch Requests

OPRA's HTTP adapter supports sending multiple requests in a single HTTP round-trip using the multipart/mixed content type. This is useful when a client needs to perform several operations atomically — for example, creating an order and updating inventory in the same database transaction — without the overhead of multiple network calls.


How it works

The client sends a single POST /$bundle request with Content-Type: multipart/mixed. Each part of the multipart body is a complete, self-contained HTTP request (method, path, headers, body). The adapter processes each part independently and returns a single multipart/mixed response whose parts correspond to the individual sub-responses.

POST /api/$bundle HTTP/1.1
Content-Type: multipart/mixed; boundary=---b

-----b
Content-Type: application/http
X-Request-Id: req-1

POST /api/orders HTTP/1.1
Content-Type: application/json

{ "productId": "p1", "quantity": 2 }

-----b
Content-Type: application/http
X-Request-Id: req-2

PATCH /api/inventory/p1 HTTP/1.1
Content-Type: application/json

{ "reserve": 2 }
-----b--

The adapter returns:

HTTP/1.1 200 OK
Content-Type: multipart/mixed; boundary=---b

-----b
X-Request-Id: req-1
...
HTTP/1.1 201 Created
Content-Type: application/json

{ "id": "order-123", ... }

-----b
X-Request-Id: req-2
...
HTTP/1.1 200 OK
-----b--

X-Request-Id headers on request parts are echoed back in the corresponding response parts for client-side correlation.


The HttpBundle context

When the adapter receives a bundle request it creates an HttpBundle — a sibling of HttpContext that holds the shared request/response objects and an array of HttpContext instances, one per sub-request. Operation handlers inside the bundle execute normally and are unaware they are running inside a batch.

multipart/mixed request


HttpAdapter.handleBundle(bundle: HttpBundle)

├── part 1 → handleRawRequest → HttpContext → handler → sub-response
├── part 2 → handleRawRequest → HttpContext → handler → sub-response
└── part N → handleRawRequest → HttpContext → handler → sub-response


multipart/mixed response (all sub-responses assembled)

Transactions

Add ?transaction=true to the bundle URL to wrap the entire batch in a single database transaction:

POST /api/$bundle?transaction=true HTTP/1.1
Content-Type: multipart/mixed; boundary=---b
...

When transaction=true is set:

  1. The first operation that opens a database connection starts a transaction session.
  2. All subsequent operations in the same bundle share that session automatically — no extra wiring needed in your handlers.
  3. If all sub-requests succeed, the transaction is committed after the last part.
  4. If any sub-request fails, the entire transaction is rolled back.

This is supported out of the box by @opra/mongodb and @opra/sqb. Other adapters can opt in by reading ctx.bundle?.transaction.

// Handler code is unchanged — transaction management is automatic
@HttpOperation.Entity.Create(Order)
async create(ctx: HttpContext) {
// If called inside ?transaction=true bundle, this runs in a shared session
return this.ordersService.for(ctx).create(await ctx.getBody());
}
caution

If a sub-request fails and the bundle is transactional, the response for that part will contain the error but the entire transaction is rolled back — including parts that already succeeded. Design your batch operations accordingly.


Lifecycle events

The adapter emits lifecycle events for the bundle as a whole and for each individual context inside it:

Adapter eventFired
bundle-before-executeBefore the first sub-request starts
bundle-after-executeAfter all sub-requests complete
bundle-finishAlways — whether success or failure
context-before-executeBefore each individual sub-request
context-after-executeAfter each individual sub-request
context-finishAfter each individual sub-request (always)
adapter.on('bundle-before-execute', bundle => {
console.log(`Batch of ${bundle.size} requests starting`);
});

adapter.on('bundle-finish', bundle => {
if (!bundle.success) console.error('Batch failed:', bundle.error);
});

Client-side usage

@opra/client handles the multipart serialization and response parsing for you. Use client.bundle() for a plain batch or client.transaction() to add ?transaction=true:

import { OpraHttpClient } from '@opra/client';

const client = new OpraHttpClient('https://api.example.com');

// Plain batch — no transaction
const r1 = client.post<Order>('orders', { productId: 'p1', qty: 2 });
const r2 = client.patch('inventory/p1', { reserve: 2 });

const [res1, res2] = await client.bundle([r1, r2]).getResponses();

// With transaction — server commits on success, rolls back on any failure
await client.transaction([r1, r2]).getResponses();

Requests bound to a bundle do not fire individual network calls. After the bundle resolves, each request can still be awaited individually via .getBody() / .getResponse() — the result is served from a local cache:

const r1 = client.get<Customer>('customers@1');
const r2 = client.get<Order[]>('orders');

await client.bundle([r1, r2]).getResponses();

// Both resolve immediately from cache:
const customer = await r1.getBody();
const orders = await r2.getBody();

HttpBundleObservable · HttpClientBase.bundle()


HttpBundle · ExecutionBundle · HttpAdapter