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

Under the Hood: Transport, Context, Time, Locking, Notification, Firewall

The Five Ideas and the Request Lifecycle gave the lifecycle as a mental model. This chapter is the mechanical trace — the exact order of operations for one query, and the runtime primitives underneath — for contributors and for anyone debugging deep. Nothing here is needed to use the framework; it is the machine room.

The full trace

api.query(endpoint) unfolds as follows.

1. The scoped request converts the input. Every query entry point accepts an IntoEndpoint; a plain endpoint converts to itself infallibly, a derived input runs its own conversion, and a failure is RequestError::Convert. The scoped request then hands the endpoint and its RequestOverrides to the seated runnerDirectRunner unless a verb seated another — through a RunnerAccess, the borrowed door. Everything below happens inside RunnerAccess::exchange, once per dispatch.

2. ApiRequest::prepare(config, endpoint) derives the context and reads the endpoint. The context is config.base_context().prepare(): same clock, a forked RNG stream, a fresh ContextId, an empty extensions map, a child notification sender. A follow-up uses prepare_with_context(parent) and prepare_follow_up() instead. Assembly then gathers every &self-taking value in one pass, each receiving (config, &context):

  • auth() — the credential declaration, resolved into AuthSettings (one requirement per credential: the key to look up, whether a miss is tolerated, where it rides); Auth::NONE attaches nothing;
  • query_string(config, context) — one call yields the query text and its decode recipe, so the spec riding the request can never describe bytes other than the ones it travels with;
  • url(config, context).to_url(), headers(config, context), accept(config, context);
  • rate_limit(config, context).to_rate_limiter() and signer(config, context).to_signer() — the limiter and the service signer, resolved now and carried as typed fields of the plan.

Then, while the endpoint is still whole, <Endpt::Decoder>::state(&endpoint) snapshots the decoder’s State, and method() and content_type(config, context) are read.

3. The head is assembled. A non-empty config user_agent is inserted — it replaces any User-Agent the endpoint declared, and an endpoint-declared one survives only when the config supplies none; Accept is set; Host is removed — an end-to-end header the client derives from the URL; and the URL is built with the query string. The auth and query specs are recorded on the head’s RequestSpecs.

4. The two preparation hooks run, in a fixed order and against the head the members will see: the required capability’s prepare_request(head, ctx) first — protocol mechanics, the WebSocket upgrade headers — then the decoder’s prepare_request(endpoint, config, ctx, head), which records the ResponseSpecs the fixture tooling reads and any header the decode contract implies. Both run before middleware, signing, or recording observe the head, so everything they write is part of the signed and recorded request.

5. body(self, config, context) consumes the endpoint. Its ToBody becomes a RequestBody; a PolicySigned body is signed here against the config’s store. The body’s own content type wins over the endpoint’s (a multipart body knows its boundary); the body spec and its runtime key classes are recorded; body_source is derived from the stream itself — Empty, Buffered, or Streaming — never hand-declared; and Content-Length is set only when the body knows its exact length, so a bodiless request stays bare. The result is a Request (head plus Bytestream), the decoder state, and the RequestPlanRequestPlan::resolved(rate_limiter, signing, cosigner, auth_store): everything this exchange is gated, sealed, shaped, and metered by, with the two signing layers held apart so a per-send cosigner swap cannot disturb the endpoint’s service signature.

6. The overrides apply, once. apply_overrides patches the head, the plan, and the context with the caller’s RequestOverrides — headers, a substituted limiter, with_auth/as_role, the pipeline delta, transfer stages — and stores them on the context as a lane-typed extension, the one place the lineage reads them from: every follow-up issued under this request applies them freshly at its own assembly, and a credential exchange forwards the transport half and drops the auth half.

7. send(client) runs one traversal. ApiRequest::send opens the client door, mints the detached FollowUpChannel for the endpoint’s lane, and calls traverse with Entry::Primary. The traversal merges the config’s pipeline with the plan’s delta and runs the eight steps: the four request phases, the gate (the plan’s limiter), the seal (resolution and placement from the plan’s store, then the signer with the cosigner folded over it), transmit (hop-by-hop validation, the Host strip again, the capability send, the transfer stages), and the unwind. The response envelope splits at the adapter’s return: the Response unwinds through the members, the extension is held aside. On the final attempt’s response, before any member transforms it, the reactive limiter is fed the head.

8. ApiResponse bundles the reply: the response head and body, the typed extension, the config snapshot, the detached channel, the request’s context, and the decoder State from step 2. ResponseParts is that bundle taken apart.

9. body_decoded() runs the decoder<Endpt::Decoder>::decode(rsp, extension, config, context, client, state), with the RunnerAccess as the door — turning bytes into the Output through a Codec<Format> or laddering to an error. The runner returns the output, the scoped request maps it through the input’s MapOutput, and the query resolves.

capi_transport

The transport crate owns the spine: ApiRequest (assembly, overrides, send), ApiResponse and ResponseParts, ScopedRequest — the per-call override handle api.scoped() opens, onto which the runner verbs and the budget knobs are installed — and the Runner/RunnerAccess/Dispatcher vocabulary. It owns the Traversal with its three entries (new, credential_exchange, capture), the seal, and the three ways back in: FollowUp (the scope a door opens), Finalize with PackagedRequest and PackagedResponse (the capture surface), and PrepareInContext. RequestOverrides and RequestPlan are capi_core’s and re-exported here. The capability plane chapter covers the doors and the capture surface as a user would meet them.

capi_middleware

The member-facing half of the traversal vocabulary: Pipeline, the four Phases, the three member traits with their RequestFlow/ResponseFlow verbs, MemberId, MwError, LaneId, Transmit, TransportError, and the Body view a member shapes a body’s identity and dressing through. Members are non-blocking, bounded, and plan-time; a Resend resumes the request walk at the resending member’s own seat, which is why a member that must act on every wire exchange seats after a re-sender and one that must act once per operation seats before it. capi_transport owns the traversal that drives them.

capi_http_types

The bottom-of-graph vocabulary every transport compiles against: Method, StatusCode, Version, the header types and constants, RequestHead / ResponseHead / Request / Response, TrailerCell / TrailerState, the Invalid* errors, and validate. A request target is a url::Url, re-exported; the builder that applies the framework’s construction rules is capi_url, which this crate does not depend on — a transport reads an address and never builds one. Framework metadata about a request is not here: it rides the head’s Extensions, one entry per type, owned by the crate that reads it — capi_core::specs::RequestSpecs (the auth settings, the body and query specs, the response recipe, body_source), capi_authentication‘s credential-placement record, capi_base_decoders’ event-stream framing, a WebSocket session’s message schema. Adding a fact never touches the vocabulary. The text module, behind text-format, is the canonical head rendering the fixtures and capi_debug_dump share.

capi_context

ApiContext is the execution state threaded through the whole pipeline: an ApiClock, an ApiRng, an optional ContextId, an optional notification sender, a typed extensions map, and the request’s budget when one is set — an absolute deadline() minted by the context’s own clock, a per-wait stall() bound, and a cancel_token(). The budget is read by whatever enforces it, which is the adapter performing the exchange; an adapter that cannot enforce a knob refuses the send with a typed Refusal rather than dropping it silently. A token set on a config’s base context binds every request derived from it; scoped().with_cancel(token) is the per-request form.

Propertyprepare() (consumes)prepare_follow_up() (borrows)
Clockinheritedinherited
RNGforkedforked
ContextIdfreshfresh
Extensionsfresh, emptyshared with the parent
Notificationschild sender, bubbling to the baseinherited lineage
Budgetcarried acrosscarried across — the deadline keeps binding the pages, cancelling the parent reaches them

ApiRng is a facade over a pluggable RngProvider. A ContextId is drawn from it and nothing else: a context whose source cannot answer carries no ID rather than a derived stand-in, because a value that only looks random is worse than one that is absent. Injecting a clock and RNG is what makes replay tests and PKCE deterministic.

capi_time and capi_lock

Two small primitive crates absorb the platform difference:

  • capi_timeInstant for elapsed time and deadlines, SystemTime for epoch timestamps, and ApiClock, a clonable facade over a ClockProvider with now and sleep. The backend is compile-time: the standard library with futures-timer under std on native and WASI; the JS host clocks and gloo timers under js on browser wasm (included by std, the platform switch there); embassy-time in embedded builds; and a stub — monotonic time fixed at zero, no wall clock, no meaningful sleep — when no backend is selected.
  • capi_lockSyncMutex, SyncRwLock, SharedCell, SyncCondvar (under std), and AsyncMutex / AsyncRwLock (under async): parking_lot on native std, spin everywhere else including browser wasm, async-lock for the async family. No lock poisoning by design, and a spin backend means contended acquisition burns CPU — keep critical sections short.

capi_notification, capi_firewall, capi_debug_dump

  • capi_notification — a typed broadcast wired into ApiContext. A NotificationSender (attached with with_notifications) fans structured events — a Notification wrapping a NotificationKind, the built-in LogNotification, the transport’s TraversalEvent, a resumable stream’s ReconnectNotification, a transfer stage’s ProgressNotification — to every NotificationReceiver. Delivery is non-blocking, disconnected receivers are pruned, and ctx.notify(..) with no sender attached costs nothing. Senders propagate through prepare / prepare_follow_up, so a base context’s children all publish into one stream.
  • capi_firewall — the opt-in egress allow-list: Firewall::new(client, policy) wraps any client and checks an EgressPolicy against each send’s URL before delegating, opaque to everything above it and forwarding every lane of the client it wraps. The capability plane chapter has the policy, the modes, and what the firewall does not see.
  • capi_debug_dump — the same wrapper shape for development: DebugClient writes every exchange that passes through it in the canonical text format, unredacted.

The error model in full is next — the exhaustive reference behind the error-modeling tutorial.