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

Runners

A runner drives a query from a configured request to its final decoded output. It’s the layer above the decoder — where a pipeline member sees one request and one response, a runner sees the typed Output and decides whether to send again — so it’s what expresses “poll until done”, “retry on a transient body”, or “follow this redirect” without changing the call site. Users still just write api.query(endpoint).await; the runner is invisible until you install one.

Why runners exist

A pipeline member shapes one request and one response — the right place for request IDs and cookies. A response member can send the exchange it holds again (ResponseFlow::Resend, which is how FollowRedirects follows a hop), but it only ever re-sends that exchange, resuming at its own seat. A runner holds the endpoint and re-derives the next attempt’s whole description from it — a new URL, a fresh body, a renewed credential. Re-send the same exchange: a member. Re-describe it from the endpoint: a runner. That is why anything that re-describes is runner work:

  • Polling — query, check whether status == "done", re-query if not.
  • Body-aware retry — a 200 OK that says { "retryable": true }; a member can’t see that. WaitUntilRunner’s predicate reads the decoded output; RetryRunner’s ranges over the QueryError an attempt produced.
  • Redirect following, Retry-After compliance, and reactive credential renewal on a 401 — each decides from the response and re-dispatches by endpoint value.

Runners sit above the decoder and can run the whole prepare→send→decode cycle zero or more times. Each pass is one full traversal:

[query call] → [runner] → [traversal] → [client] → [unwind] → [decoder] → [runner] → [result]
                  └──────── one or more round trips, possibly looping ────────┘

Because every attempt re-derives its request from the endpoint, an endpoint that can be described twice is exactly an endpoint that is Clone — and that bound is the whole re-dispatch contract. An endpoint that cannot be described twice simply is not Clone, and the refusal is the compile error at .query(..).

The default: DirectRunner

Without a custom runner, queries go through DirectRunner — exactly one traversal, which may itself carry a member’s Resend hops, returning the decoder’s output. It has no extra bounds on the endpoint (no Clone, no decoder-shape restriction), so it works for body, stream, paged, and WebSocket endpoints alike. For the 99% case the runner system is invisible and free.

The trait

// blocking lane
pub trait Runner<Endpt: Endpoint> {
    type Output;
    fn run<Client>(
        &mut self,
        access: &mut RunnerAccess<'_, Endpt::ApiConfig, Client, Endpt::Requires>,
        endpoint: Endpt,
    ) -> Result<Self::Output, QueryError>
    where
        Endpt::Requires: Capability,
        Client: Supports<Endpt::Requires>;
}

// async lane, and every browser-wasm build: a boxed future, because `run`
// borrows both `self` and `access`
fn run<'r, 'a, Client>(
    &'r mut self,
    access: &'r mut RunnerAccess<'a, Endpt::ApiConfig, Client, Endpt::Requires>,
    endpoint: Endpt,
) -> RunnerFuture<'r, Self::Output>
where
    Endpt: MaybeSend + 'r,
    Self::Output: MaybeSend,
    Endpt::Requires: Capability,
    Client: Supports<Endpt::Requires> + 'r,
    'a: 'r;
  • Parameterized by Endpt. Each impl declares its own bounds — a polling or retrying runner adds Endpt: Clone, because re-dispatch needs a fresh endpoint — while the trait stays unconstrained. Keep constraints on the impl, not the trait.
  • Output reshapes freely. Transparent runners set type Output = Endpt::Output; a reshaping chain returns something else (e.g. (ResponseHead, Endpt::Output) from a head capture).
  • RunnerAccess is the handle you dispatch through, and it has exactly two ways to reach the network: exchange(endpoint) performs one round trip and yields the undecoded ApiResponse, and execute(endpoint) is the fused convenience — the same exchange followed by the endpoint’s decode. Loop either, call once, or short-circuit and never call. The handle can only be obtained inside Runner::run, so all dispatch flows through the runner system.

Composition

Runners compose by tuple through RunnerStack, layering left-to-right with the first element outermost — the same convention as rate limiters:

api.scoped()
   .with_runner((WaitUntilRunner::new(spec, pred), RetryRunner::with_predicate(spec, decide)))  // wait wraps retry
   .query(get_video).await?

with_runner composes your stack onto DirectRunner at the bottom. The stack is endpoint-agnostic — whether it actually implements Runner<Endpt> is checked at the .query() call, so you build stacks freely and the type system validates on use. Chained with_runner calls nest the existing runner inside the new one, so the runner installed last is outermost; only within one tuple is the first element outermost.

A chain has two kinds of member. Wrappers (retry, polling) re-dispatch through whatever sits beneath them and stack freely with with_runner. A dispatcher performs the exchange itself, so a chain holds exactly one, innermost; it seats with with_dispatcher, sinking beneath the wrappers already installed, and a second dispatcher is a compile error rather than a silent replacement. DirectRunner is the default dispatcher; implementing your own is for strategies that need the exchange in hand — a cache answering without the network, a bespoke send discipline.

The shipped runners

capi_extras::runners ships five, each with the extension trait that installs it (all in the prelude):

RunnerSugarKindWhat it does
RetryRunner / RetrySpecapi.retry(spec)wrapperre-dispatches on a retryable QueryError, with backoff; with_predicate(spec, decide) takes a Fn(&QueryError) -> bool
WaitUntilRunner / WaitSpecapi.wait_until(spec, pred)wrapperpolls until the predicate over the decoded output holds (below)
RetryAfterRunner / RetryAfterSpecapi.retry_after(spec)dispatchercomplies with a server-directed Retry-After
RedirectRunnerapi.follow_redirects(n)dispatcherfollows a Location by re-describing the endpoint as a RedirectHop<Endpt>, decoded through HopDecoder
RefreshRunnerapi.refresh_on_unauthorized()dispatcherrenews the query’s credential through the store’s seat on a 401 and dispatches once more

The three dispatchers act on the undecoded response, so they perform the exchange themselves and seat with with_dispatcher; wrappers stack above them. All of them re-dispatch by endpoint value, deriving a fresh request from the description each time. A chain holds one dispatcher, so retry_after and a raw-response terminal (with_raw_response()) cannot co-seat. capim_x402’s payment runner (api.pay_when_required(..) from its X402Ext) is the worked third-party runner on this plane: it reads a 402 and re-dispatches with the payment attached.

The response side: observers and interpreters

What happens after the exchange composes on its own axis, inside the shipped Respond dispatcher. Interpreters consume the response — one per chain, innermost: the default Decode runs the endpoint’s declared decoder, Wrapped preserves the wire wrapper, Raw hands back the ApiResponse itself. Observers wrap whichever interpreter is seated: the head capture pairs the ResponseHead with the inner output, and observers stack. The sugar mounts them — wrapped() / with_raw_response() seat a terminal, with_headers() mounts the observer — and every order lands on the same chain:

// (ResponseHead, Wrapper), retried: the retry drives the head-capturing,
// wrapper-preserving exchange. Any mounting order builds this chain.
let (head, wrapper) = api
    .retry(RetrySpec::new())
    .with_headers()
    .wrapped()
    .query(ep)
    .await?;

Seating a second terminal is a compile error — wrapped() after with_raw_response() has no decode leaf left to replace. A third-party observer or terminal is ordinary code: implement Interpret plus Observer (stacking) or Interpreter (seating) from capi_transport::interpret, and the mounts, the commuting orders, and the wrapper forwarding all apply to it unchanged.

WaitUntilRunner: polling built in

The most common pattern ships in capi_extras::runners. It pairs perfectly with a deferred endpoint:

use capi_rs::extras::{WaitSpec, WaitUntilExt};

let response = api
    .wait_until(
        WaitSpec::new().interval(Duration::from_secs(2)).timeout(Duration::from_secs(60)),
        |r: &DeferredChatResponse| matches!(r, DeferredChatResponse::Success(_)),
    )
    .query(deferred_id)
    .await?;

WaitSpec tunes the loop — interval, timeout, eager (call once immediately), retry_errors, backoff_multiplier, max_interval, jitter_ratio. Crucially, the pacing runs off the context’s clock and RNG (ApiContext::clock() / rng()), so a polling loop is fully deterministic under replay tests — mock time advances instantly, jitter is reproducible. When the loop runs out of time it fails with a WaitTimeout, whose cause() carries the last attempt’s error when there was one. RetryRunner / RetrySpec are the retry counterpart; RetrySpec::attempt_timeout(d) bounds each individual attempt, always under the whole-call deadline a scoped request’s with_timeout fixes as one instant when the send starts — WaitUntilRunner tightens its own timeout under that ceiling too, and RunnerAccess arms the deadline once so every attempt measures against the same instant (bounding a call).

The predicate ranges over the chain’s output, whatever that is: pair it with with_headers() to test &(ResponseHead, T), or with a HEAD probe to poll until a resource exists:

let head = api
    .wait_until(WaitSpec::new(), |head: &ResponseHead| head.status.is_success())
    .head(&endpoint)
    .await?;   // polls HEAD until it stops 404ing

Writing a custom runner

Wrap an Inner: Runner<Endpt> (default it to DirectRunner) and delegate inside run, adding whatever per-impl bounds you need. The impl is written once per lane; the async twin returns RunnerFuture from a Box::pin(async move { … }):

impl<Endpt, Inner> Runner<Endpt> for LogRunner<Inner>
where
    Endpt: Endpoint,
    Endpt::ApiConfig: ApiConfig,
    Endpt::Requires: Capability,
    Inner: Runner<Endpt>,
{
    type Output = Inner::Output;

    #[cfg(not(any(feature = "async", all(target_arch = "wasm32", target_os = "unknown"))))]
    fn run<Client>(&mut self, access: &mut RunnerAccess<'_, Endpt::ApiConfig, Client, Endpt::Requires>, endpoint: Endpt)
        -> Result<Self::Output, QueryError>
    where Client: Supports<Endpt::Requires>
    {
        log::info!("dispatching");
        self.inner.run(access, endpoint)
    }

    #[cfg(any(feature = "async", all(target_arch = "wasm32", target_os = "unknown")))]
    fn run<'r, 'a, Client>(&'r mut self, access: &'r mut RunnerAccess<'a, Endpt::ApiConfig, Client, Endpt::Requires>, endpoint: Endpt)
        -> RunnerFuture<'r, Self::Output>
    where Endpt: MaybeSend + 'r, Self::Output: MaybeSend, Client: Supports<Endpt::Requires> + 'r, 'a: 'r
    {
        Box::pin(async move {
            log::info!("dispatching");
            self.inner.run(access, endpoint).await
        })
    }
}

Three more impls make a wrapper a first-class chain member: RunnerStack, so with_runner can install it over an existing chain, and ObserverMount / InterpreterMount, so with_headers() and wrapped() thread through it to the interpreter beneath — each a short forward into Inner, as RetryRunner’s are. Use access.overrides_mut() to change per-request overrides between iterations, and access.base_context() for the clock/RNG. Custom runners are an advanced escape hatch — while authoring a normal client you’ll almost never need one; the shipped runners cover the common cases.

Runners vs. pipeline members

Pipeline memberRunner
Layerone request, one responsedecoded output
Seesheaders, status, the plan-time body viewtyped body, stream handle, page iterator
Short-circuit?yes (RequestFlow::Respond)yes (return early)
Send again?the same exchange, from its seat (Resend)a re-described one, by endpoint value
Compositionphase, then insertion orderlater with_runner outermost; within a tuple, first element outermost

They’re complementary: members stamp and read every trip, while a runner decides how many trips there are.

Next: HTML forms, a decoder that itself issues a second request.