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:
- The first operation that opens a database connection starts a transaction session.
- All subsequent operations in the same bundle share that session automatically — no extra wiring needed in your handlers.
- If all sub-requests succeed, the transaction is committed after the last part.
- 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());
}
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 event | Fired |
|---|---|
bundle-before-execute | Before the first sub-request starts |
bundle-after-execute | After all sub-requests complete |
bundle-finish | Always — whether success or failure |
context-before-execute | Before each individual sub-request |
context-after-execute | After each individual sub-request |
context-finish | After 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()