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

Consuming and Patching a Client Downstream

This chapter is the outside view: you are using a client someone else built on Capi, not authoring one. The properties that make a client a description rather than code are also what make it pleasant to consume — and, when it lags the service or misdescribes an endpoint, to fix in your own crate without waiting on a release.

Consuming: the whole surface

Using a published client is three steps — pick a client adapter, construct the interface, authenticate, and query:

use some_api_rs::{SomeApi, endpoints::GetThing};

let api = SomeApi::new_with_defaults(capic_reqwest::ReqwestClient::default());
api.authenticate(std::env::var("SOME_API_KEY")?);
let thing = api.query(GetThing::new("id_123")).await?;

new_with_defaults takes the batteries-included codecs behind the client’s default-codecs feature; new takes your own. authenticate is the credential flow the client library chose — a bearer token here — and a client with several credential tiers exposes each as a role (api.management().authenticate(..)). Two calling styles exist and are one path:

let thing = api.query(GetThing::new("id")).await?;    // interface-first (inherent)
let thing = GetThing::new("id").query(&api).await?;   // endpoint-first (EndpointQuery, from capi_extras)

Anything that opens a scoped request works on the endpoint-first side too, so endpoint.query(api.with_timeout(d)) reads as well as the other way round.

Reading a failure

Errors arrive as one type-erased QueryError, and its accessors are the consumer’s whole triage vocabulary:

QuestionAccessor
What class is it?kind()ErrorKind; the predicates is_api(), is_auth(), is_decode(), is_io(), is_transport(), is_invalid_request(), is_status_client(), is_status_server(), is_rate_limited()
Should I try again?is_retryable(), and retry_kind() for how — a RetryKind separates a transient failure (back off) from throttling (wait the delay it named) from a terminal one
What did the service say?status(), metadata() (the service’s own error code and message, its request id), response_head() (the observed head, when a head-capturing runner kept it)
What did the service mean?api_error::<SomeError>() — the client’s typed error, recovered by downcast
Which knob fired?is_timed_out(), is_stalled(), is_cancelled(), refusal()
Whose fault at the wire?client_error(&api) — the adapter’s own error, its type inferred from the interface
Did my own adaptation fail?convert_error::<E>() for an IntoEndpoint conversion, map_output_error::<E>() for a MapOutput remap

Every accessor is a reference into the same error, so a match arm can ask several questions of one value before deciding. to_log_string() is the one-line form for a log.

Runner verbs a consumer applies

Without touching the client, a consumer reshapes a call at the runner layer, and the verbs read as a sentence:

let raw = api.with_raw_response().query(ep).await?;       // ApiResponse — skip decoding, inspect bytes and headers
let (head, thing) = api.with_headers().query(ep).await?;  // (ResponseHead, T) — the decoded value plus its head
let wrapper = api.wrapped().query(ep).await?;             // the wire wrapper of a response-unwrap endpoint

let thing = api
    .retry(RetrySpec::default())            // RetryRunner: backoff on retryable failures; retry_if(spec, |e| ..) for your own predicate
    .retry_after(RetryAfterSpec::default()) // RetryAfterRunner: obey a 429/503's Retry-After
    .follow_redirects(3)                    // RedirectRunner: up to n hops, re-described at each
    .refresh_on_unauthorized()              // RefreshRunner: one credential refresh on a 401, then resend
    .with_headers()
    .query(ep)
    .await?;

let done = api.wait_until(WaitSpec::default(), |job: &Job| job.is_finished()).query(GetJob::new(id)).await?;

with_raw_response and wrapped seat a response terminal — one per chain — while with_headers mounts an observer over whichever terminal is seated, and the re-dispatching verbs (retry, retry_after, follow_redirects, refresh_on_unauthorized, wait_until) nest: each wraps the runner beneath it, and the order you write is the order they fold. progress(..) and throttle(..) seat a transfer stage on the same chain. The composition rules — what an observer sees, which terminal pairs with which endpoint — are the runners chapter’s.

A failed call keeps its head. with_headers() attaches its head clone to the error arm as well, and QueryError::response_head() reads it back — which is how a consumer gets a Retry-After off a 503, or the trailing metadata a gRPC peer attaches to an UNIMPLEMENTED, without dropping to with_raw_response():

match api.with_headers().query(ep).await {
    Ok((head, thing)) => { /* … */ }
    Err(error) => {
        if let Some(head) = error.response_head() {
            let retry_after = head.headers.get("retry-after");
            // for a gRPC refusal the trailer cell is shared with the head,
            // and it has filled by the time the error reaches you
        }
    }
}

The per-request knobs beneath the runners are on the scoped request — api.scoped() opens one — and they are the consumer’s without any verb: with_header / append_header / without_header, with_rate_limit / no_rate_limit, with_auth / no_auth / as_role, with_user_agent, with_middleware and its response and exchange forms, with_transfer_stage, with_notifications, and with_context_extension. Every verb above returns that same scoped request, so the knobs and the verbs interleave freely.

Bounding a call: timeouts and cancellation

Three per-request knobs give a call a budget — TimeoutExt, StallExt, CancelExt, all in the prelude — and they differ in what time counts:

use capi_rs::prelude::CancelToken;

// Whole call: "this must resolve by T". All wall time counts.
let thing = api.with_timeout(Duration::from_secs(10)).query(GetThing::new("id")).await?;

// Each wait on the peer: "no single silence longer than this".
let feed = api.with_stall_timeout(Duration::from_secs(5)).query(StreamEvents::new()).await?;

// Both, for both guarantees.
let page = api
    .with_timeout(Duration::from_secs(60))
    .with_stall_timeout(Duration::from_secs(5))
    .query(ListThings::new())
    .await?;

let token = CancelToken::new();          // fire token.cancel() from anywhere
let call = api.with_cancel(token.clone()).query(GetSlowThing::new("id"));

Unary and SLA-bound calls take with_timeout; long streams and pagination take with_stall_timeout; combine them when you want both guarantees.

with_timeout(d) is a whole-call budget: the duration becomes one absolute deadline when the send starts, and everything under the call — every retry attempt, backoff sleep, and follow-up page fetch — measures against that same instant. Because all wall time counts, so does the consumer’s own pace: a stream you read one line per minute exhausts a ten-second deadline however healthy the server is. Right for an SLA, wrong for a long download.

with_stall_timeout(d) bounds each individual wait on the peer — the dial, the response head, every body chunk — re-anchored at each one. Consumer think-time never counts, because nothing is armed while nobody is waiting; and a peer that trickles steadily never trips it, because each chunk does arrive inside d. Set both and every wait is bounded by min(stall, remaining).

A knob that fires ends the call with an outcome the framework owns rather than one the adapter invented, so the same code reads it whichever adapter is underneath:

match call.await {
    Ok(thing) => { /* … */ }
    Err(err) if err.is_cancelled() => { /* the token fired — do not retry */ }
    Err(err) if err.is_timed_out() => { /* the whole-call deadline passed */ }
    Err(err) if err.is_stalled() => { /* one wait on the peer ran long */ }
    Err(err) => match err.refusal() {
        Some(refusal) => { /* this client cannot enforce a knob you set */ }
        None => { /* an ordinary failure — triage with kind() */ }
    },
}

Each of the first three is true whether the knob fired before the response head arrived or while the body was still coming — the same event, noticed at different moments — and the predicates are the only way to tell a timeout or a stall from a dropped connection, since both keep ErrorKind::Transport and stay retryable. A fired token does not: is_cancelled() is ErrorKind::Cancelled, which is_retryable() rejects, so a retry runner stops at once instead of sleeping through its backoff schedule against a token that will never un-fire.

The knobs are gated on the client’s capability markers, so they only compile against a client that enforces them — the adapter table says who enforces what. A budget that reaches an adapter by a path the markers cannot see is refused rather than dropped: refusal() hands back a Refusal naming the client and the knob, the kind is InvalidRequest, and nothing was sent.

Patching an endpoint without forking

An endpoint is an ordinary struct bound to an ApiConfig, so a new endpoint in your crate can wrap an imported one and change only what is wrong. The tool is OverrideEndpoint: name the wrapped type, hand back the two accessors, and every request-construction method defaults to delegating to it, so the impl states exactly its departures. grok’s Deferred<T> is the shipped model — the same chat request, asked to return a polling handle instead of a completion:

#[derive(Debug, Clone, Default)]
pub struct Deferred<T: DeferrableChat>(pub T);

impl<T: DeferrableChat> OverrideEndpoint for Deferred<T> {
    type Endpt = T;
    type Output = GetChatDeferredCompletion;                    // a different response shape
    type Error = GrokError;
    type Decoder = BodyDecoder;
    type Requires = <Self::Endpt as Endpoint>::Requires;        // the lane is the inner endpoint's

    fn endpoint_ref(&self) -> &Self::Endpt { &self.0 }
    fn endpoint(self) -> Self::Endpt { self.0 }

    fn body(self, config: &InnerConfig<Self>, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
        #[derive(Debug, WireModel, WireEncode)]
        struct DeferChat<T> {
            #[wire(flatten)]
            endpt: T,
            deferred: bool,
        }
        config.json_lib().encode_body(&DeferChat { endpt: self.0.wire_body_fields(), deferred: true })
    }
}

impl<T: DeferrableChat> DecodeBodyFn for Deferred<T> {
    fn decode_fn() -> BodyFnDecoder<Self> { GrokResponse::decode }
}

The five associated types are required — Output, Error, Decoder, and Requires forward with <Self::Endpt as Endpoint>::X when they do not change — and InnerConfig<Self> names the wrapped endpoint’s config, so an override speaks the same codecs and base URL. From this seam you add a body field the upstream crate omitted, change the URL or the method, add a header, apply a tighter rate limit, or swap the decoder. The override is an Endpoint in its own right through a blanket impl, so api.query(Deferred::new(chat)) needs nothing else; and because it wraps rather than copies, an upstream fix to the inner endpoint reaches you on the next update.

Derived inputs: adapting the call shape

A related seam reshapes the input rather than the endpoint. IntoEndpoint lets query() accept your own type — an app-specific struct with a fallible conversion into the real endpoint:

impl IntoEndpoint for ThingByName {
    type Endpoint = GetThing;
    type Error = LookupError;
    type Remap = remap::No;               // remap::Yes to also reshape the output through MapOutput
    fn into_endpoint(self) -> Result<GetThing, LookupError> { /* resolve name → id */ }
}
// now: api.query(ThingByName { name }).await?

With remap::Yes and a MapOutput impl the input also transforms the output (StreamIterator::try_map is the streaming equivalent); conversion and mapping failures surface as QueryError::convert_error and map_output_error. The flag is sealed to those two markers, so a third state can never fall outside the pass-through blanket. Reach for OverrideEndpoint to change what an existing endpoint sends, and IntoEndpoint to adapt a foreign input (or output) into the call; both keep the adaptation in your crate.

Patching the crate, not the endpoint

Sometimes the fix belongs upstream and you want to build against your own checkout of the client — or of a framework crate — while it is in review. Family crates depend on each other by plain version requirement, never by path, so a checkout resolves each dependency from crates.io unless a patch table above it says otherwise. cargo capi patch (from capi_verify’s cargo-capi binary) writes that table:

cargo capi patch

Run it at the directory holding the checkouts, never inside one — it refuses a directory that is itself a cargo workspace, so the table sits above every repo and inside none of them — and --only <dir>,<dir> narrows it to the checkouts you name. Cargo reads .cargo/config.toml from the current directory upward, so one table reaches every repo beneath it, rust-analyzer and trybuild scratch projects included. The table is never committed: an entry names a filesystem path, so a committed one would point a consumer’s clone at directories that do not exist and suppress the registry resolution that should have happened. And one table serving several repos over-patches by construction — cargo reports the entries a given build does not reach as unused on every command, which is expected.

Extending

When the fix is not a patch but a new piece — a codec, a transport, an auth scheme or signer, a middleware, a whole client — capi-extension-template is the starting point: cargo generate --git https://github.com/capi-rs/capi-extension-template renders a crate with the family’s feature lanes, the alignment guard, the documentation skeletons, and a [package.metadata.capi] table already in place, so cargo capi verify is its gate from the first commit. The capi-rs-author skill covers the authoring patterns — config, interface, auth, endpoints, decoders, errors, and replay tests — and is where a new client library starts.

Next: testing — how a client author, and you, verify all of this offline.