Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

TraitVerb it enablesOutput
BatchConfigbatch_url(), batch_profile()api.batch(members, &overrides, &rng)a flat envelope; parts fail independently, so Vec<Result<E::Output, _>> in request order
ChangesetConfigchangeset_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

  1. 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 one multipart/mixed body of application/http parts, with boundaries drawn from the RNG handed in.
  2. 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.
  3. Decode. The response envelope is split positionally — Content-ID echoes 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.