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 OKthat says{ "retryable": true }; a member can’t see that.WaitUntilRunner’s predicate reads the decoded output;RetryRunner’s ranges over theQueryErroran attempt produced. - Redirect following,
Retry-Aftercompliance, and reactive credential renewal on a401— 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 addsEndpt: Clone, because re-dispatch needs a fresh endpoint — while the trait stays unconstrained. Keep constraints on the impl, not the trait. Outputreshapes freely. Transparent runners settype Output = Endpt::Output; a reshaping chain returns something else (e.g.(ResponseHead, Endpt::Output)from a head capture).RunnerAccessis the handle you dispatch through, and it has exactly two ways to reach the network:exchange(endpoint)performs one round trip and yields the undecodedApiResponse, andexecute(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 insideRunner::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):
| Runner | Sugar | Kind | What it does |
|---|---|---|---|
RetryRunner / RetrySpec | api.retry(spec) | wrapper | re-dispatches on a retryable QueryError, with backoff; with_predicate(spec, decide) takes a Fn(&QueryError) -> bool |
WaitUntilRunner / WaitSpec | api.wait_until(spec, pred) | wrapper | polls until the predicate over the decoded output holds (below) |
RetryAfterRunner / RetryAfterSpec | api.retry_after(spec) | dispatcher | complies with a server-directed Retry-After |
RedirectRunner | api.follow_redirects(n) | dispatcher | follows a Location by re-describing the endpoint as a RedirectHop<Endpt>, decoded through HopDecoder |
RefreshRunner | api.refresh_on_unauthorized() | dispatcher | renews 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 member | Runner | |
|---|---|---|
| Layer | one request, one response | decoded output |
| Sees | headers, status, the plan-time body view | typed 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 |
| Composition | phase, then insertion order | later 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.