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
| Adapter | Client | Camp and lane | Construction |
|---|---|---|---|
capic_reqwest | ReqwestClient | native; blocking, or async with its async feature | ReqwestClient::default(), or new(reqwest::Client) to bring a configured client |
capic_ureq | UreqClient | native; blocking only — it forbids the async axis | UreqClient::default(), or new(ureq::Agent) |
capic_native_client | NativeClient | native; both lanes. The family’s own sans-io engines over OS sockets — no third-party HTTP library in the exchange | NativeClient::new() / default() |
capic_wasm_fetch | WasmClient | browser wasm only; async by nature of the platform | WasmClient::new() / default() — a unit struct over the Fetch API |
capic_reqwless | ReqwlessClient<T, D> | embedded no_std; async, and single-threaded is required. MSRV 1.91 for its embassy-net/smoltcp graph | ReqwlessClient::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.
| Adapter | Http | WebSocket | Grpc |
|---|---|---|---|
capic_reqwest | always | websocket 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 API | grpc feature, which implies trailers and therefore async. A dedicated HTTP/2 prior-knowledge lane, with grpc-status surfaced through the trailer cell |
capic_ureq | always | websocket feature. The handshake runs over tungstenite on ureq’s TLS stack and yields a message-level connection | — |
capic_native_client | always, both lanes | websocket feature, both lanes — the genuine wire 101 handed to capi_websocket’s framing | grpc feature, both lanes — h2c by prior knowledge on http, ALPN h2 on https, trailers included |
capic_wasm_fetch | always | websocket 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_reqwless | always | — | — |
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.
| Adapter | TimeoutCapable | StallCapable | CancelCapable | Mechanism |
|---|---|---|---|---|
capic_native_client | both lanes | both lanes | both lanes | governed 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_reqwest | both lanes | async only | async only | whole 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_ureq | yes | no | no | ureq’s per-call timeout, and socket deadlines on the WebSocket handshake (cleared at the session handover). Neither other knob compiles against it |
capic_wasm_fetch | yes | yes | yes | one AbortController covers all three; the governor’s per-wait sleep is min(stall, remaining) for the fetch and every chunk read |
capic_reqwless | not claimed | not claimed | not claimed | embedded 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_rsboots four binaries on an emulated Cortex-M4 (QEMUmps2-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 overcapic_reqwlesson an in-binary smoltcp loopback. Exit code 0 undercargo runis the verdict. Its.cargo/config.tomlcarries the--cfg getrandom_backend="custom"that bare metal needs, and the crate pins Rust 1.91 for thenetlane’s embassy-net/smoltcp/reqwless graph.runtime_canary_capi_rsdrives the pipeline end to end under two executors the family links nowhere else —futures::executor::block_on, the bare poll loop with no reactor, andsmol— 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.