Skip to main content

keel/
middleware.rs

1//! A [`reqwest_middleware::Middleware`] that routes every request through
2//! the keel-core Engine chain (cache → rate → breaker → timeout → retry),
3//! target-keyed by request host — the Rust analogue of `python/keel`'s
4//! `httpx_pack`/`node/keel`'s `fetch.mjs` seam.
5//!
6//! **v1 scope** (deliberately narrow — see the session gap brief's
7//! descoping notes):
8//!
9//! * The target is always the exact request host string: no
10//!   `host:`/URL-pattern glob resolution and no `llm:<provider>` host
11//!   mapping (both are front-end-only conveniences implemented in the
12//!   Python/Node packs on top of shared judgment helpers that have no Rust
13//!   port yet, not core-level features).
14//! * Idempotency follows the RFC 9110 safe/idempotent method set
15//!   (GET/HEAD/OPTIONS/PUT/DELETE/TRACE) plus a default
16//!   `(x-)idempotency-key` header, with no per-target `idempotency.header`
17//!   policy override (that needs a *resolved-policy* read-back from the
18//!   engine — a clean seam for a follow-up).
19//! * Caching is always disabled (`args_hash: None`) since a stable cache
20//!   key would need to buffer and hash the request body, which this v1
21//!   does not do. Retry, the circuit breaker, the rate limiter, and
22//!   per-attempt timeouts all work today — none of those need a cache key.
23//! * **[`KeelMiddleware`] sends every attempt itself, via its own cloned
24//!   [`reqwest::Client`], instead of delegating to the middleware chain's
25//!   [`Next`].** This is not a design preference — it is a real, deliberate
26//!   workaround for a compile-time wall discovered while building this:
27//!   `keel_core::Engine::execute`'s effect closure, when it captures
28//!   anything borrowing `Next<'_>`/`&mut Extensions` (both inherently
29//!   non-`'static` — they're scoped to one `handle()` call), cannot be
30//!   proven `Send` "in general" by rustc once `#[async_trait]` boxes
31//!   `handle`'s returned future into `Pin<Box<dyn Future + Send>>` (needed
32//!   for `Middleware` to be object-safe as `Arc<dyn Middleware>`). This
33//!   reproduces with zero third-party crates involved — a minimal
34//!   `Engine::execute` call from an `async move {}` block, wrapped in
35//!   nothing but a `Send`-bound check, fails identically the moment the
36//!   effect closure captures *any* non-`'static` data, regardless of how
37//!   it's wrapped (`Arc`, `Mutex`, a manual `Box::pin`, boxing the outer
38//!   future explicitly, …). Fixing it would mean changing
39//!   `keel_core::Engine::execute`'s internals (out of this crate's
40//!   territory, and a Tier 1 kernel change with workspace-wide blast
41//!   radius) or waiting on the relevant rustc limitation. The workaround
42//!   here needs the closure's captures to be fully owned/`'static`, which a
43//!   cloned `reqwest::Client` satisfies (it sends independently of the
44//!   `next` chain).
45//!
46//!   **Practical consequence:** any middleware added *after*
47//!   [`KeelMiddleware`] in the `ClientBuilder` chain (i.e. closer to the
48//!   transport) is never invoked, because [`KeelMiddleware`] never calls
49//!   `next.run`. Add it last (closest to `.build()`), or as the only
50//!   middleware.
51//!
52//! A real HTTP response is never turned into a middleware error, even a
53//! persistently transient one (5xx/429) that exhausted every retry: the
54//! last response actually received is always what's handed back, mirroring
55//! `_http.deliver()`'s "Keel never turns a real HTTP response into a
56//! failure" rule in the Python front end. Only a transport-level error
57//! (connection reset, timeout with no response, DNS failure, …) or an
58//! engine judgment made without ever attempting the call (a breaker
59//! fast-fail, a rate-budget rejection) surfaces as an `Err`.
60
61use http::Extensions;
62use keel_core_api::{AttemptResult, ENVELOPE_VERSION, ErrorClass, Request as CoreRequest};
63use reqwest::{Request, Response};
64use reqwest_middleware::{Error as MwError, Middleware, Next, Result as MwResult};
65use std::sync::{Arc, Mutex};
66
67/// RFC 9110 §9.2.2 safe methods plus PUT/DELETE, which convention treats as
68/// idempotent. Parity in spirit with `python/keel`'s `IDEMPOTENT_METHODS`
69/// (POST/PATCH are deliberately absent: retryable only with an idempotency
70/// key, the Level 0 hard rule).
71const IDEMPOTENT_METHODS: [&str; 6] = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE", "TRACE"];
72
73/// Header names that mark an otherwise-unsafe request (POST/PATCH) as safe
74/// to retry. Parity with `python/keel`'s `DEFAULT_IDEMPOTENCY_HEADERS`.
75const DEFAULT_IDEMPOTENCY_HEADERS: [&str; 2] = ["idempotency-key", "x-idempotency-key"];
76
77/// A [`Middleware`] that wraps every request in the keel-core Engine chain.
78/// Owns a cloned [`reqwest::Client`] to send attempts itself (module docs
79/// explain why: `Next`/`Extensions` cannot cross into
80/// [`keel_core::Engine::execute`]'s effect closure under `#[async_trait]`'s
81/// `Send`-boxing requirement).
82///
83/// Add it as the **last** middleware (or the only one) so no
84/// transport-adjacent middleware after it is skipped:
85///
86/// ```no_run
87/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
88/// keel::init()?;
89/// let raw = reqwest::Client::new();
90/// let client = reqwest_middleware::ClientBuilder::new(raw.clone())
91///     .with(keel::KeelMiddleware::new(raw))
92///     .build();
93/// let resp = client.get("https://api.example.com/orders").send().await?;
94/// # let _ = resp;
95/// # Ok(())
96/// # }
97/// ```
98#[derive(Debug, Clone)]
99pub struct KeelMiddleware {
100    client: reqwest::Client,
101}
102
103impl KeelMiddleware {
104    /// `client` is the [`reqwest::Client`] used to actually send every
105    /// attempt (cloning a `reqwest::Client` is cheap — it's an `Arc`
106    /// internally — so passing the same client you built the
107    /// `ClientWithMiddleware` from is the normal usage).
108    #[must_use]
109    pub fn new(client: reqwest::Client) -> Self {
110        Self { client }
111    }
112}
113
114/// Owned, `'static`-compatible, cheaply-`clone`-able handles to the state
115/// one `handle()` call shares across every retry attempt and the final
116/// delivery step (module docs on `handle` explain why these must be `Arc`
117/// handles rather than borrowed references).
118#[derive(Clone)]
119struct LiveState {
120    pending: Arc<Mutex<Option<Request>>>,
121    ok: Arc<Mutex<Option<Response>>>,
122    transient: Arc<Mutex<Option<Response>>>,
123    err: Arc<Mutex<Option<MwError>>>,
124}
125
126impl LiveState {
127    fn new(req: Request) -> Self {
128        Self {
129            pending: Arc::new(Mutex::new(Some(req))),
130            ok: Arc::new(Mutex::new(None)),
131            transient: Arc::new(Mutex::new(None)),
132            err: Arc::new(Mutex::new(None)),
133        }
134    }
135}
136
137/// One retry attempt: get a sendable copy of the pending request, send it via
138/// `client` directly (module docs: not `next.run`), and classify the result.
139async fn run_attempt(client: reqwest::Client, live: LiveState) -> AttemptResult {
140    let attempt_req = match take_attempt_request(&live.pending) {
141        Ok(req) => req,
142        Err(err) => return err,
143    };
144    match client.execute(attempt_req).await {
145        Ok(resp) => {
146            let status = resp.status().as_u16();
147            if is_transient_status(status) {
148                let retry_after_ms = parse_retry_after_ms(resp.headers());
149                let message = format!("http {status}");
150                *live.transient.lock().expect("keel: mutex poisoned") = Some(resp);
151                AttemptResult::Error {
152                    class: ErrorClass::Http,
153                    http_status: Some(status),
154                    retry_after_ms,
155                    message,
156                    original: None,
157                }
158            } else {
159                *live.ok.lock().expect("keel: mutex poisoned") = Some(resp);
160                AttemptResult::Ok {
161                    payload: serde_json::Value::Null,
162                }
163            }
164        }
165        Err(err) => {
166            let class = classify(&err);
167            let message = err.to_string();
168            *live.err.lock().expect("keel: mutex poisoned") = Some(err.into());
169            AttemptResult::Error {
170                class,
171                http_status: None,
172                retry_after_ms: None,
173                message,
174                original: None,
175            }
176        }
177    }
178}
179
180/// A clone of the pending request if its body is clonable, else the
181/// original (taken, leaving `None` behind) on the very first use, else an
182/// `AttemptResult::Error` for a policy-forced retry of a non-clonable body
183/// (only reachable if the front end judged a streaming-body call idempotent
184/// anyway — Keel does not retry non-idempotent calls, so this is a
185/// defensive fallback, not the common path).
186fn take_attempt_request(pending: &Mutex<Option<Request>>) -> Result<Request, AttemptResult> {
187    let mut guard = pending.lock().expect("keel: pending mutex poisoned");
188    if let Some(cloned) = guard.as_ref().and_then(Request::try_clone) {
189        return Ok(cloned);
190    }
191    guard.take().ok_or_else(|| AttemptResult::Error {
192        class: ErrorClass::Other,
193        http_status: None,
194        retry_after_ms: None,
195        message: "keel: request body cannot be cloned for a retry attempt".to_owned(),
196        original: None,
197    })
198}
199
200/// After the engine chain runs: an `ok` outcome delivers the live response;
201/// an `error` outcome still delivers a real (if unhappy) transient response
202/// if one was ever received (module docs: never turn a real HTTP response
203/// into a middleware error); otherwise the live transport error, or — for a
204/// judgment the engine made without ever calling `run_attempt` (a breaker
205/// fast-fail, a rate-budget rejection) — a synthetic error carrying that
206/// judgment.
207fn deliver(outcome: &keel_core_api::Outcome, live: &LiveState) -> MwResult<Response> {
208    if outcome.result == "ok" {
209        let resp = live
210            .ok
211            .lock()
212            .expect("keel: mutex poisoned")
213            .take()
214            .expect("keel: ok outcome without a live response");
215        return Ok(resp);
216    }
217    if let Some(resp) = live.transient.lock().expect("keel: mutex poisoned").take() {
218        return Ok(resp);
219    }
220    if let Some(err) = live.err.lock().expect("keel: mutex poisoned").take() {
221        return Err(err);
222    }
223    let outcome_error = outcome
224        .error
225        .clone()
226        .expect("engine reported an error outcome without an OutcomeError");
227    Err(MwError::middleware(
228        crate::Error::<std::convert::Infallible>::Keel(outcome_error),
229    ))
230}
231
232#[async_trait::async_trait]
233impl Middleware for KeelMiddleware {
234    async fn handle(
235        &self,
236        req: Request,
237        _extensions: &mut Extensions,
238        _next: Next<'_>,
239    ) -> MwResult<Response> {
240        let method = req.method().clone();
241        let host = req.url().host_str().unwrap_or("unknown").to_owned();
242        let op = format!("{method} {host}{}", req.url().path());
243        let idempotent = is_idempotent(method.as_str(), req.headers());
244        let core_req = CoreRequest {
245            v: ENVELOPE_VERSION,
246            target: host,
247            op,
248            idempotent,
249            args_hash: None,
250        };
251
252        let client = self.client.clone();
253        let live = LiveState::new(req);
254
255        let outcome = crate::engine()
256            .execute(&core_req, {
257                let live = live.clone();
258                move |_attempt: u32| run_attempt(client.clone(), live.clone())
259            })
260            .await;
261
262        deliver(&outcome, &live)
263    }
264}
265
266fn is_idempotent(method: &str, headers: &reqwest::header::HeaderMap) -> bool {
267    if IDEMPOTENT_METHODS.contains(&method) {
268        return true;
269    }
270    headers
271        .keys()
272        .any(|name| DEFAULT_IDEMPOTENCY_HEADERS.contains(&name.as_str()))
273}
274
275fn is_transient_status(status: u16) -> bool {
276    status == 429 || (500..=599).contains(&status)
277}
278
279/// Server-provided backoff override (`Retry-After`, seconds form only — the
280/// HTTP-date form is not parsed in this v1; an unparsed header just means
281/// the schedule's own wait applies instead of a server-driven override).
282fn parse_retry_after_ms(headers: &reqwest::header::HeaderMap) -> Option<u64> {
283    let value = headers.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?;
284    let secs: u64 = value.trim().parse().ok()?;
285    Some(secs.saturating_mul(1000))
286}
287
288fn classify(err: &reqwest::Error) -> ErrorClass {
289    if err.is_timeout() {
290        ErrorClass::Timeout
291    } else if err.is_connect() {
292        ErrorClass::Conn
293    } else {
294        ErrorClass::Other
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn idempotent_methods() {
304        for m in ["GET", "HEAD", "OPTIONS", "PUT", "DELETE", "TRACE"] {
305            assert!(is_idempotent(m, &reqwest::header::HeaderMap::new()), "{m}");
306        }
307        assert!(!is_idempotent("POST", &reqwest::header::HeaderMap::new()));
308        assert!(!is_idempotent("PATCH", &reqwest::header::HeaderMap::new()));
309    }
310
311    #[test]
312    fn post_with_idempotency_key_header_is_idempotent() {
313        let mut headers = reqwest::header::HeaderMap::new();
314        headers.insert("idempotency-key", "abc".parse().unwrap());
315        assert!(is_idempotent("POST", &headers));
316    }
317
318    #[test]
319    fn transient_status() {
320        assert!(is_transient_status(429));
321        assert!(is_transient_status(500));
322        assert!(is_transient_status(503));
323        assert!(!is_transient_status(404));
324        assert!(!is_transient_status(200));
325        assert!(!is_transient_status(301));
326    }
327
328    #[test]
329    fn retry_after_seconds_form() {
330        let mut headers = reqwest::header::HeaderMap::new();
331        headers.insert(reqwest::header::RETRY_AFTER, "2".parse().unwrap());
332        assert_eq!(parse_retry_after_ms(&headers), Some(2000));
333    }
334
335    #[test]
336    fn retry_after_http_date_form_is_unparsed() {
337        let mut headers = reqwest::header::HeaderMap::new();
338        headers.insert(
339            reqwest::header::RETRY_AFTER,
340            "Wed, 21 Oct 2026 07:28:00 GMT".parse().unwrap(),
341        );
342        assert_eq!(parse_retry_after_ms(&headers), None);
343    }
344}