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

Clients, Lanes, and the Request Budget

“Write the endpoints, ship everywhere” rests on one abstraction — the client — and on the rule that the compilation target picks the platform while features switch capabilities on. Features and Targets owns that rule; this chapter owns the client side of it: the trait, the shipped adapters, the table that says which adapter can do what, and the request budget an adapter enforces.

The ApiClient abstraction

ApiClient is deliberately tiny — the identity of a client, not its behavior:

pub trait ApiClient: Clone + MaybeSend + MaybeSync + 'static {}

No method, no error, no claim about any protocol. The send lives on Speaks<Proto>, one impl per protocol a client speaks, and the error rides with it: the failures are the client’s own — a name that would not resolve, a handshake that failed — so they are declared where the send is, once per client per protocol. The signature follows the lane:

pub trait Speaks<Proto: Protocol> {
    type Error: StdError;

    // blocking lane
    fn capi_send(&self, ctx: ApiContext, outbound: Proto::Outbound)
        -> Result<Proto::Inbound, SendError<Self::Error>>;

    // async lane, and every browser-wasm build
    fn capi_send(&self, ctx: ApiContext, outbound: Proto::Outbound)
        -> impl Future<Output = Result<Proto::Inbound, SendError<Self::Error>>> + MaybeSend;
}

For an HTTP-carried protocol — one the request pipeline hands a Request — the seat the pipeline bounds on is Supports<Cap>, and it is derived: Supports<Cap>: ApiClient + Speaks<Cap> has a single blanket impl covering every client that is both, so a hand-written impl Supports overlaps it and is refused. An endpoint’s Requires names the capability it needs, so a query against a client with no matching Speaks impl is a compile error at the call site — the capability plane chapter has the whole mechanism. A library stays generic over Client rather than naming an adapter, which is how one interface definition serves every adapter below. Bringing your own client is those two impls and nothing else; the Arc and Box blankets forward every protocol of the client they hold, and so do the family’s two client wrappers, capi_firewall’s Firewall<Client> and capi_debug_dump’s DebugClient<Client>, for every protocol whose outbound message is the framework Request.

The shipped adapters

AdapterClientCamp and laneConstruction
capic_reqwestReqwestClientnative; blocking, or async with its async featureReqwestClient::default(), or new(reqwest::Client) to bring a configured client
capic_ureqUreqClientnative; blocking only — it forbids the async axisUreqClient::default(), or new(ureq::Agent)
capic_native_clientNativeClientnative; both lanes. The family’s own sans-io engines over OS sockets — no third-party HTTP library in the exchangeNativeClient::new() / default()
capic_wasm_fetchWasmClientbrowser wasm only; async by nature of the platformWasmClient::new() / default() — a unit struct over the Fetch API
capic_reqwlessReqwlessClient<T, D>embedded no_std; async, and single-threaded is required. MSRV 1.91 for its embassy-net/smoltcp graphReqwlessClient::new(stack, dns) — generic over the TCP stack and the DNS resolver, so there is no Default

A client library does not pick among these. Native consumers name the adapter they want in their own manifest; the browser adapter ships with the client from a browser-wasm target table, because there is exactly one. Swapping adapters is a one-line change at the interface — the endpoints do not move.

The client capability table

Three compiler diagnostics point here. When a query needs a capability the client does not serve, or a scoped request asks for a budget knob the client cannot enforce, the error names the marker and says to check this table.

AdapterHttpWebSocketGrpc
capic_reqwestalwayswebsocket feature. Async lane: the upgrade is performed and Sec-WebSocket-Accept validated. Blocking lane: the impl exists so the code compiles, and the send fails with WebSocketRequiresAsync — reqwest’s blocking client has no upgrade APIgrpc feature, which implies trailers and therefore async. A dedicated HTTP/2 prior-knowledge lane, with grpc-status surfaced through the trailer cell
capic_ureqalwayswebsocket feature. The handshake runs over tungstenite on ureq’s TLS stack and yields a message-level connection
capic_native_clientalways, both laneswebsocket feature, both lanes — the genuine wire 101 handed to capi_websocket’s framinggrpc feature, both lanes — h2c by prior knowledge on http, ALPN h2 on https, trailers included
capic_wasm_fetchalwayswebsocket feature. The browser opens the socket; the send yields a synthetic 101 beside the message stream, and a refused upgrade surfaces as a connection error
capic_reqwlessalways

Neither capi_websocket nor capi_grpc is behind a feature of its own; the protocol crates are plain dependencies. The feature lives on the adapter that can perform the exchange, and a client library conventionally gates its own WebSocket or gRPC endpoints behind a websocket / grpc feature that pulls the protocol crate in. capic_native_client additionally speaks Redis (redis) and SIP with media (voip). Those are not capabilities: they never meet the request pipeline, so they have no Supports seat and no row in the table above — the client implements Speaks<Redis>, Speaks<Sip> and Speaks<Media> directly, and each exchange hands back a live session. The Protocol Core and the Two Planes is where that side of the client lives.

Timeouts, stalls, and cancellation

A request’s budget — the three knobs a scoped request can set — is enforced by the adapter, and adapters genuinely diverge. Three marker traits state what each client enforces: TimeoutCapable gates with_timeout, StallCapable gates with_stall_timeout, CancelCapable gates with_cancel, and asking a client for a knob it lacks fails at compile time with the missing marker named. A budget value that reaches an adapter through a path the markers cannot see — a channel-carried follow-up send, a hand-built context — is refused loudly, never silently dropped. The refusal rides the send’s own Refused arm rather than the adapter’s error, so it survives type erasure: QueryError::refusal() hands back a Refusal naming the client and the knob, and its kind is InvalidRequest, so nothing retries a send that will refuse again.

AdapterTimeoutCapableStallCapableCancelCapableMechanism
capic_native_clientboth lanesboth lanesboth lanesgoverned carriers beneath TLS: each wait bounded by min(stall, remaining) — a socket timeout re-derived per wait on the blocking lane, a sleep armed per await on the async one
capic_reqwestboth lanesasync onlyasync onlywhole call via reqwest’s per-request timeout (it travels with the streamed body); stall and cancel are the adapter’s own governed waits, which the blocking lane has no way to perform
capic_ureqyesnonoureq’s per-call timeout, and socket deadlines on the WebSocket handshake (cleared at the session handover). Neither other knob compiles against it
capic_wasm_fetchyesyesyesone AbortController covers all three; the governor’s per-wait sleep is min(stall, remaining) for the fetch and every chunk read
capic_reqwlessnot claimednot claimednot claimedembedded lane; every knob is refused before transmission

The two client wrappers forward whatever markers the wrapped client claims.

Why three markers and not one. The two time knobs measure different things, and an adapter can honour one without the other. with_timeout bounds the whole call — all wall time counts, the consumer’s own reading pace included — so any client with a per-request timeout can enforce it. with_stall_timeout bounds one wait on the peer, re-anchored whenever bytes move, so it needs a per-wait surface. ureq has phase timeouts, but they measure wall time through the phase; using them for a stall bound would fail a slow reader for the peer’s silence, which is the confusion the knob exists to prevent. So ureq claims TimeoutCapable and refuses the rest, and the compiler says so at the call site rather than the server saying so an hour into a download.

The deadline is a whole-call budget: with_timeout(d) is fixed as one absolute instant when the send starts, and every retry attempt, backoff sleep, and follow-up (next-page fetches included) measures against that same instant. Per-attempt bounding exists separately, as retry policy (RetrySpec::attempt_timeout), always tightened under the whole-call ceiling. FollowUpChannel claims nothing at all — it is a send channel, not a client — so the sugar exists only on clients, and the adapters’ typed refusals stand guard behind every channel-carried path.

Where the platform enters

Nothing above named a feature to pick a platform, because none exists: a native build takes the defaults, a browser build is cargo build --target wasm32-unknown-unknown, and std is the platform-capability switch on the browser rather than a library switch. Two consequences an adapter author meets: the browser owns cookies, so CookieMiddleware is a no-op there; and the browser enforces CORS, which no adapter can lift.

Two canaries in the family exercise the far ends of the model rather than merely compiling them:

  • nostd_canary_capi_rs boots four binaries on an emulated Cortex-M4 (QEMU mps2-an386) or, with --target riscv32imac-unknown-none-elf, a RISC-V hart — a scripted JSON exchange over the mock client, a real generated client replayed against embedded fixtures, the async lane under an embassy executor on a real SysTick/CLINT timer, and real HTTP/1.1 over capic_reqwless on an in-binary smoltcp loopback. Exit code 0 under cargo run is the verdict. Its .cargo/config.toml carries the --cfg getrandom_backend="custom" that bare metal needs, and the crate pins Rust 1.91 for the net lane’s embassy-net/smoltcp/reqwless graph.
  • runtime_canary_capi_rs drives the pipeline end to end under two executors the family links nowhere else — futures::executor::block_on, the bare poll loop with no reactor, and smol — with tokio absent from the whole dependency graph. That is what “runtime agnostic” is measured by.

The client as a swap point

Because the client is a constructor argument to the interface, it is also the seam for testing: swap the adapter for a ReplayClient and the same endpoints run against recorded fixtures with no network. That is the whole of the testing chapter, and the clearest payoff of keeping transport out of the endpoint.

Next: the capability plane — the typed vocabulary behind Supports<Cap> and Requires.