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}