Skip to content

feat(client): Interceptor, a per-attempt hook with static dispatch - #10

Merged
casibbald merged 1 commit into
integration/microscaler-forkfrom
feat/client-interceptor
Oct 27, 2026
Merged

casibbald merged 1 commit into
integration/microscaler-forkfrom
feat/client-interceptor

Conversation

@casibbald

Copy link
Copy Markdown

What

Client and RequestBuilder become generic over an Interceptor: per-attempt hooks with static dispatch. The default type parameter is NoInterceptor, so Client keeps meaning what it meant and existing code compiles unchanged.

pub trait Interceptor: Clone + Send + Sync + 'static {
    const ENABLED: bool = true;
    fn before_send(&self, request: &mut OutgoingRequest<'_, '_>) {}
    fn after_response(&self, response: &ResponseHead<'_>, timing: &Timing) {}
    fn on_error(&self, error: &io::Error, timing: &Timing) {}
}
  • before_send runs once the attempt has a connection, before anything is written. It can read the method, URL, attempt number, redirect hop, and whether the connection is reused, and it can change that attempt's headers.
    • It runs again on the pool's own stale-connection retry and on every redirect hop. That is the case it exists for: a header that has to be fresh on each attempt, such as a signature or a per-attempt id, which a header set once on the builder cannot be.
    • headers_mut() copies the request's headers only when it is called, so an interceptor that only reads keeps the borrowed map from perf(client): borrow request headers when nothing is merged; no copy into the request target #9.
  • after_response runs at the response head, before the body. Timing gives the time to first byte and whether the connection was reused.
  • on_error runs for a failed attempt, including one that a retry then hides from the caller.
  • NoInterceptor sets ENABLED = false. The client then skips building the hook arguments and reading the clock, so a client without an interceptor compiles to what it was before.
  • Attaching an interceptor: ClientBuilder::build_with_interceptor(i) builds a client with one. Client::with_interceptor(i) gives an existing client another interceptor while keeping its pool, configuration and counters.
  • Also new: BufferedResponse::reused_connection() and StreamingResponse::reused_connection().

The module docs and the README describe it as the extension point for per-attempt behaviour.

Overhead

examples/bench_client (new mode pool-hook: empty hooks that do not opt out, so the arguments are built and the hooks called) against bench_server on loopback, 16 coroutines × 3000 requests. Five interleaved rounds on ms02; the table shows medians.

GET req/s GET allocs/req POST 512 B req/s POST allocs/req
before (integration head) 132.1k 24.0 119.6k 27.1
this PR, no interceptor 131.0k 24.0 121.2k 27.1
this PR, empty hooks called 135.9k 24.1 124.2k 27.0

Allocations do not change. Req/s varies by about ±5% between rounds on a shared host, and the three rows are within that.

Tests

  • interceptor_runs_on_every_attempt_including_the_stale_retry:
    • a header set in before_send carries attempt 1, then 2 on the pool's retry, and is sent once;
    • checks the exact before/after/error sequence and reuse flags.
  • with_interceptor_shares_the_pool_and_reports_reuse: a buffered request, then a streaming one on the same pool with an interceptor.
  • cargo fmt --check, clippy (default with -D warnings, and all features with all targets), and the full suite with all features (312 passed) and with default features, on Rust 1.99.0.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y3TSRmEZAYU6RBUXhkn7WF

`Client<I = NoInterceptor>` and `RequestBuilder<I>` are generic over an
`Interceptor` with three hooks, all no-ops by default:

- `before_send(&mut OutgoingRequest)`: once the attempt has a connection,
  before it is written. It can read the method, URL, attempt number,
  redirect hop and whether the connection is reused, and change that
  attempt's headers. It runs again on the pool's stale-connection retry
  and on each redirect hop, which is what a header that must be fresh per
  attempt needs.
- `after_response(&ResponseHead, &Timing)`: at the response head, before
  the body; `Timing` gives the time to first byte and connection reuse.
- `on_error(&io::Error, &Timing)`: a failed attempt, including one a
  retry then hides from the caller.

`NoInterceptor` sets `Interceptor::ENABLED = false`, so the client skips
building the hook arguments (and reading the clock): a client without an
interceptor compiles to what it was. `headers_mut()` copies the request's
headers only when called, so a read-only interceptor keeps the borrowed
map.

`ClientBuilder::build_with_interceptor` builds one; `Client::with_interceptor`
gives the same pool, configuration and counters another interceptor.
`BufferedResponse::reused_connection()` and
`StreamingResponse::reused_connection()` say whether the response came
over a pooled connection.

bench_client gains `pool-hook`: the pool with empty hooks that do not opt
out, to measure the plumbing against `pool`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3TSRmEZAYU6RBUXhkn7WF
@casibbald
casibbald merged commit 73dbc91 into integration/microscaler-fork Oct 27, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant