Batching: multipart/mixed Envelopes and Changesets
Some services accept several requests in one: a multipart/mixed body whose
parts are complete HTTP requests, answered by a multipart/mixed body whose
parts are complete HTTP responses. Google’s batch format and its relatives do
this as a flat list; OData does it as a nested changeset the service applies
as one transaction. capi_http_batch is the machinery for both — and, like
every capability in the framework, it appears only where a service opts in.
Reusable machinery, service policy
The engine is generic: capture each member request, merge the captures into one envelope, send it, split the reply back apart, and decode each part through its own member’s decoder. What is not generic is whether a service accepts such an envelope at all, and in what shape. So the entry point exists only on an interface whose config implements a batch trait:
| Trait | Verb it enables | Output |
|---|---|---|
BatchConfig — batch_url(), batch_profile() | api.batch(members, &overrides, &rng) | a flat envelope; parts fail independently, so Vec<Result<E::Output, _>> in request order |
ChangesetConfig — changeset_url(), changeset_profile() | api.changeset(members, &overrides, &rng) | a nested group the service applies as one transaction; all-or-nothing, so Vec<E::Output> against a ChangesetError naming the member the service blamed |
A config implements exactly the traits its service honours. A service that only
takes changesets never grows a batch(..) that would post an envelope it
rejects — the Azure Table service is one, so azure_tables_capi_rs implements
ChangesetConfig alone.
The second gate is on the endpoint. Only types marked Batchable ride in a
batch, and there is deliberately no blanket impl: the marker is the service’s
explicit per-endpoint decision, written in its own crate as
impl Batchable for GetFile {}. Its supertrait structurally excludes streaming
outputs — a finite envelope cannot contain an open connection.
The lifecycle
- Arm.
api.batch(vec![member, ..], &overrides, &rng)finalizes every member through the framework’s capture surface — the config’s pipeline run, credentials and signatures resolved, everything short of transport — and merges the results into onemultipart/mixedbody ofapplication/httpparts, with boundaries drawn from the RNG handed in. - Execute. The armed value is an honest endpoint:
api.query(batch)sends it. The caller’s own members, budget, rate policy, and fixture recording behave exactly as for any request, and the envelope authorizes exactly as any request does. - Decode. The response envelope is split positionally —
Content-IDechoes asserted where the profile expects them — and each part decodes through its own member’s decoder, yielding per-member results in request order.
let batch = api.batch(vec![GetFile::new("a"), GetFile::new("b")], &RequestOverrides::default(), &rng)?;
let results: Vec<Result<FileOutput, _>> = api.query(batch).await?;
The profile
How the parts are written is the service’s wire contract, captured in a
BatchProfile the config returns: the part content type (application/http),
whether inner request lines are path-only (GET /v1/files/x, the Google form,
with the envelope’s URL supplying scheme and authority) or absolute, how
response parts map back to members, where a member’s credential rides, the
Content-ID scheme, the per-part Content-Transfer-Encoding, and the boundary
prefixes. The type is #[non_exhaustive] and built with with_ methods from a
default that matches the Google shape, so a profile names only its departures.
Azure’s changeset departs in four:
const TABLE_CHANGESET: BatchProfile = BatchProfile::new("application/http")
.with_path_only_requests(false) // absolute inner request lines
.with_content_id_scheme(ContentIdScheme::Bare { first: 1 })
.with_binary_transfer_encoding()
.with_boundary_prefixes("batch_", "changeset_"); // the spelling the emulator derives its replies from
impl ChangesetConfig for TablesConfig {
fn changeset_url(&self) -> Result<Url, UrlError> {
self.config.base_url().path("$batch").build()
}
fn changeset_profile(&self) -> &BatchProfile { &TABLE_CHANGESET }
}
Where the credential rides
By default the envelope authorizes and members are captured bare —
MemberAuth::EnvelopeOnly, which is what every surveyed batch facility does. A
part then carries no credential in either plane, neither a placed token nor a
signature; the armed batch is an ordinary request to the service, so its
credential resolves at send time through the ordinary pipeline, with the
caller’s scope (with_auth, as_role) applying there. MemberAuth::PerMember
is the minority mode for a facility that accepts a per-call credential: it
places one inside each part in addition to the envelope’s.
A capture is not a wire exchange, and that draws two lines. Caller-scoped
conduct — members, budgets, throttles — applies to the envelope send, never to
member captures; a part is what the solo send would produce minus the caller’s
own members. And a challenge-driven signer (capi_auth_digest) under
PerMember must be pre-armed by a prior exchange before its endpoints join a
batch, because a capture signs once and has no response to answer a challenge
on. Under the default, nothing is placed at capture and the envelope send is a
real exchange the signer can answer on. A payment runner (capim_x402) reaches
neither a capture nor the config’s pipeline: seat it on the api.query(batch)
call and it pays for the envelope. This is also the one legitimate use of
RequestOverrides::no_signing(): a member captured into an envelope that
authorizes for the whole group has nothing to sign.
Wrapping it for one service
Nothing here is sealed against a service dressing it up. A profile plus a
branded entry point bounded on the service’s own marker gets a long way; a
service’s own armed endpoint type needs only ArmedBatch (so
BatchEnvelopeDecoder decodes for it), MergedBatch::build plus from_merged
for construction, and an IntoBatch impl for the collection newtype — and the
changeset side is the same seam for seam.
A signing service needs the branded type. The generic armed types leave
Endpoint::signer at its default — whether a service signs is that service’s
fact, not the machinery’s — so an envelope built from them goes out unsigned.
One signer line on the service’s own armed type declares the envelope’s
signature, and it is the only place it can be. That is why azure_tables’
entry point is api.transaction(ops) rather than the generic changeset(..):
its TableTransaction is the armed changeset with the SharedKey signer
declared, and api.query(transaction) yields one TableOpResult per member or
a ChangesetError whose failed_member is the zero-based index the service
blamed. Only a TableOp rides it; the read endpoints carry no membership
marker, which is what keeps a query structurally outside a transaction.
That closes Advanced Response Patterns. The next part turns to the exchanges that are not one request and one response at all — WebSocket first.