Skip to main content

otelite_api/
cached.rs

1//! Response-caching middleware for read-only API endpoints.
2//!
3//! Analytics endpoints on a large database take seconds even with a warmed
4//! storage layer, and the web UI refreshes every 20-30 seconds. This layer
5//! gives read-only endpoints a short-TTL response cache plus in-flight
6//! deduplication: concurrent identical requests (e.g. two dashboard widgets
7//! hitting the same endpoint at once) collapse into a single query.
8//!
9//! Only GET requests to a small whitelist of read-only endpoints are cached;
10//! everything else passes through untouched. Error responses are never
11//! cached. The cache is cleared on data-mutating operations (purge).
12
13use std::{
14    collections::HashMap,
15    sync::{Arc, Mutex},
16    time::Duration,
17};
18
19use axum::{
20    body::Body,
21    extract::Request,
22    http::{Method, StatusCode},
23    middleware::Next,
24    response::Response,
25};
26
27use crate::cache::LruCache;
28
29/// Maximum response body size (bytes) that will be cached. Analytics
30/// responses are small JSON; anything bigger is still served, uncached
31/// (prefix + continuing stream, #197).
32const MAX_CACHE_BODY_BYTES: usize = 8 * 1024 * 1024;
33
34/// How long a dedup waiter will block on an in-flight request before
35/// giving up and running its own copy (guards against a cancelled owner).
36const DEDUP_WAIT_TIMEOUT: Duration = Duration::from_secs(120);
37
38/// A (status, content-type, body) triple recovered from an in-flight or
39/// cached response.
40type CachedBody = (u16, Option<String>, Vec<u8>);
41
42/// Outcome of buffering a response body for the cache.
43enum CollectedBody {
44    /// The whole body fit under the cache limit.
45    Fitted(bytes::Bytes),
46    /// The body exceeded the limit: a stream of the FULL body (buffered
47    /// prefix followed by the continuing original stream). Return it to
48    /// the client verbatim, uncached.
49    Oversized(futures_util::stream::BoxStream<'static, Result<bytes::Bytes, axum::Error>>),
50}
51
52/// A `Stream` adapter for axum's `Body`, which in axum 0.8 implements
53/// only `http_body::Body` (no `Stream` impl). Yields data frames as
54/// `Bytes`; HTTP trailers, if any, are dropped (JSON responses do not
55/// use them).
56struct BodyStream(Body);
57
58impl futures_util::Stream for BodyStream {
59    type Item = Result<bytes::Bytes, axum::Error>;
60
61    fn poll_next(
62        mut self: std::pin::Pin<&mut Self>,
63        cx: &mut std::task::Context<'_>,
64    ) -> std::task::Poll<Option<Self::Item>> {
65        use http_body::Body as _;
66        match std::pin::Pin::new(&mut self.0).poll_frame(cx) {
67            std::task::Poll::Pending => std::task::Poll::Pending,
68            std::task::Poll::Ready(None) => std::task::Poll::Ready(None),
69            std::task::Poll::Ready(Some(Err(err))) => {
70                std::task::Poll::Ready(Some(Err(axum::Error::new(err))))
71            },
72            std::task::Poll::Ready(Some(Ok(frame))) => match frame.into_data() {
73                Ok(bytes) => std::task::Poll::Ready(Some(Ok(bytes))),
74                // Trailing-headers frame (if any) — data is complete,
75                // drop it.
76                Err(_trailers) => std::task::Poll::Ready(None),
77            },
78        }
79    }
80}
81
82/// Buffer a response body up to `limit` bytes for the response cache.
83///
84/// axum's `to_bytes` consumes the body and drops it on overflow, which
85/// turned every oversize whitelisted response (e.g. a wide-window
86/// `/api/metrics/export`) into a 500 (#197). Instead, once the limit is
87/// hit this hands back a stream of the full body (buffered prefix, the
88/// current chunk's tail, then the remaining frames) so the caller can
89/// serve it uncached. Memory stays bounded at `limit` plus one chunk.
90async fn collect_for_cache(body: Body, limit: usize) -> Result<CollectedBody, axum::Error> {
91    use futures_util::{Stream, StreamExt};
92    let mut buf: Vec<u8> = Vec::new();
93    let mut frames = BodyStream(body);
94    loop {
95        let item = std::future::poll_fn(|cx| std::pin::Pin::new(&mut frames).poll_next(cx)).await;
96        let chunk = item.transpose().map_err(axum::Error::new)?;
97        let Some(mut chunk) = chunk else {
98            return Ok(CollectedBody::Fitted(bytes::Bytes::from(buf)));
99        };
100        let room = limit.saturating_sub(buf.len());
101        if chunk.len() <= room {
102            buf.extend_from_slice(&chunk);
103            continue;
104        }
105        // split_to mutates in place: returns the first `room` bytes and
106        // leaves the tail in `chunk`.
107        let head = chunk.split_to(room);
108        buf.extend_from_slice(&head);
109        let tail = chunk;
110        let prefix = bytes::Bytes::from(buf);
111        let full: futures_util::stream::BoxStream<'static, Result<bytes::Bytes, axum::Error>> =
112            Box::pin(futures_util::stream::iter([Ok(prefix), Ok(tail)]).chain(frames));
113        return Ok(CollectedBody::Oversized(full));
114    }
115}
116
117/// Bookkeeping for one in-flight request that duplicates may await.
118struct InFlight {
119    done: Arc<tokio::sync::Notify>,
120    response: Mutex<Option<CachedBody>>,
121}
122
123/// RAII cleanup for an in-flight dedup entry.
124///
125/// The entry is removed when the owner future is dropped — including
126/// on client disconnect (cancellation) and panics — not just at the
127/// end of the happy path. Without this, a cancelled owner leaves a
128/// stale entry behind and every subsequent duplicate waits out the
129/// full [`DEDUP_WAIT_TIMEOUT`] before running its own copy.
130///
131/// Removal is conditional on the map still holding this exact
132/// [`Arc`]: a guard that outlives a re-registration (the stale entry
133/// was already evicted and a fresh owner inserted) must not delete
134/// the newcomer's entry.
135///
136/// The shared state is held by [`Arc`] rather than a reference so the
137/// guard stays `Send`/`'static`-compatible inside the middleware
138/// future (axum requires the handler future to be `Send`).
139struct InFlightGuard {
140    cache: Arc<CacheState>,
141    key: String,
142    entry: Arc<InFlight>,
143}
144
145impl Drop for InFlightGuard {
146    fn drop(&mut self) {
147        let mut map = self
148            .cache
149            .in_flight
150            .lock()
151            .expect("in_flight mutex poisoned");
152        match map.get(&self.key) {
153            Some(current) if Arc::ptr_eq(current, &self.entry) => {
154                map.remove(&self.key);
155            },
156            _ => {},
157        }
158        // Wake waiters still parked on this entry so they notice it is
159        // gone and fall through to their own request.
160        self.entry.done.notify_waiters();
161    }
162}
163
164/// Which response cache a whitelisted path belongs to. Each bucket has its
165/// own size/TTL: GenAI analytics are the expensive ones (20s), the stats
166/// totals tolerate 60s staleness (a recompute is a full-table index scan),
167/// the sessions list is cheap enough for a 10s TTL, and the metrics list /
168/// metric names / resource-key typeahead are near-static (a few hundred rows
169/// that only grow) so they get longer TTLs.
170#[derive(Clone, Copy, PartialEq, Eq, Debug)]
171enum Bucket {
172    Genai,
173    Stats,
174    Sessions,
175    Metrics,
176    Names,
177    Keys,
178}
179
180impl Bucket {
181    fn ttl(&self) -> Duration {
182        match self {
183            Bucket::Genai => Duration::from_secs(20),
184            Bucket::Stats => Duration::from_secs(60),
185            Bucket::Sessions => Duration::from_secs(10),
186            Bucket::Metrics => Duration::from_secs(60),
187            Bucket::Names => Duration::from_secs(600),
188            Bucket::Keys => Duration::from_secs(300),
189        }
190    }
191}
192
193/// Shared state for the caching middleware.
194pub struct CacheState {
195    genai: LruCache<String, String>,
196    stats: LruCache<String, String>,
197    sessions: LruCache<String, String>,
198    metrics: LruCache<String, String>,
199    names: LruCache<String, String>,
200    keys: LruCache<String, String>,
201    in_flight: Mutex<HashMap<String, Arc<InFlight>>>,
202}
203
204impl CacheState {
205    pub fn new() -> Arc<Self> {
206        Arc::new(Self {
207            genai: LruCache::new(500, Bucket::Genai.ttl()),
208            stats: LruCache::new(50, Bucket::Stats.ttl()),
209            sessions: LruCache::new(200, Bucket::Sessions.ttl()),
210            metrics: LruCache::new(200, Bucket::Metrics.ttl()),
211            names: LruCache::new(50, Bucket::Names.ttl()),
212            keys: LruCache::new(30, Bucket::Keys.ttl()),
213            in_flight: Mutex::new(HashMap::new()),
214        })
215    }
216
217    /// Cache bucket for a request path, or None to pass through uncached.
218    fn policy(path: &str) -> Option<Bucket> {
219        if path == "/api/stats" {
220            Some(Bucket::Stats)
221        } else if path == "/api/sessions" {
222            Some(Bucket::Sessions)
223        } else if path == "/api/metrics/names" {
224            Some(Bucket::Names)
225        } else if path == "/api/metrics/export" {
226            // #197: exports are large one-shot payloads — never cache, and
227            // never buffer for the cache (the 8 MB buffer limit used to
228            // turn every oversize export into a 500).
229            None
230        } else if path == "/api/metrics" || path.starts_with("/api/metrics/") {
231            Some(Bucket::Metrics)
232        } else if path == "/api/resource-keys" {
233            Some(Bucket::Keys)
234        } else if path.starts_with("/api/genai/") {
235            Some(Bucket::Genai)
236        } else {
237            None
238        }
239    }
240
241    fn get(&self, key: &str, bucket: Bucket) -> Option<String> {
242        let k = key.to_string();
243        match bucket {
244            Bucket::Genai => self.genai.get(&k),
245            Bucket::Stats => self.stats.get(&k),
246            Bucket::Sessions => self.sessions.get(&k),
247            Bucket::Metrics => self.metrics.get(&k),
248            Bucket::Names => self.names.get(&k),
249            Bucket::Keys => self.keys.get(&k),
250        }
251    }
252
253    fn insert(&self, key: &str, value: String, bucket: Bucket) {
254        match bucket {
255            Bucket::Genai => self.genai.insert(key.to_string(), value),
256            Bucket::Stats => self.stats.insert(key.to_string(), value),
257            Bucket::Sessions => self.sessions.insert(key.to_string(), value),
258            Bucket::Metrics => self.metrics.insert(key.to_string(), value),
259            Bucket::Names => self.names.insert(key.to_string(), value),
260            Bucket::Keys => self.keys.insert(key.to_string(), value),
261        }
262    }
263
264    /// Clear every cache. Called after data-mutating operations.
265    pub fn clear_all(&self) {
266        self.genai.clear();
267        self.stats.clear();
268        self.sessions.clear();
269        self.metrics.clear();
270        self.names.clear();
271        self.keys.clear();
272    }
273}
274
275/// Tower middleware factory: wrap the API router with
276/// `axum::middleware::from_fn_with_state(state, cache_handler)`.
277pub async fn cache_handler(
278    axum::extract::State(cache): axum::extract::State<Arc<CacheState>>,
279    req: Request,
280    next: Next,
281) -> Response {
282    let path = req.uri().path().to_string();
283    let bucket = match req.method() == Method::GET {
284        true => match CacheState::policy(&path) {
285            Some(b) => b,
286            None => return next.run(req).await,
287        },
288        false => return next.run(req).await,
289    };
290
291    let key = match req.uri().query() {
292        Some(q) => format!("{}?{}", path, q),
293        None => path,
294    };
295
296    // 1. Fresh cache hit.
297    if let Some(cached) = cache.get(&key, bucket) {
298        return json_response(&cached);
299    }
300
301    // 2. In-flight dedup: join an identical request already running.
302    let (entry, is_owner) = {
303        let mut map = cache.in_flight.lock().expect("in_flight mutex poisoned");
304        match map.get(&key) {
305            Some(existing) => (Arc::clone(existing), false),
306            None => {
307                let entry = Arc::new(InFlight {
308                    done: Arc::new(tokio::sync::Notify::new()),
309                    response: Mutex::new(None),
310                });
311                map.insert(key.clone(), Arc::clone(&entry));
312                (entry, true)
313            },
314        }
315    };
316
317    if !is_owner {
318        // Wait for the owner to publish its result. `notify_waiters` only
319        // wakes already-polling waiters, so re-arm the notified future every
320        // second: the response is checked after registering it each round,
321        // which bounds any lost-wakeup delay to one second.
322        let deadline = tokio::time::Instant::now() + DEDUP_WAIT_TIMEOUT;
323        loop {
324            let notified = entry.done.notified();
325            tokio::pin!(notified);
326            if let Some((status, content_type, body)) =
327                entry.response.lock().expect("poisoned").clone()
328            {
329                return rebuild_response(status, content_type, body);
330            }
331            if tokio::time::Instant::now() >= deadline {
332                break;
333            }
334            // The owner's cleanup guard removes the entry when its
335            // request is cancelled or panics. If the entry we are
336            // waiting on is gone, no result is coming — stop waiting
337            // out the full timeout (bounds the delay to ~1 s).
338            let still_present = cache
339                .in_flight
340                .lock()
341                .expect("in_flight mutex poisoned")
342                .get(&key)
343                .is_some_and(|current| Arc::ptr_eq(current, &entry));
344            if !still_present {
345                break;
346            }
347            let _ = tokio::time::timeout(Duration::from_secs(1), notified).await;
348        }
349        // The owner did not finish in time (or was cancelled by a client
350        // disconnect and will never publish). Evict the stale in-flight
351        // entry so later duplicates become fresh owners instead of
352        // waiting out the timeout again, then run this request ourselves.
353        // Only evict if this exact entry is still registered — a fresh
354        // owner may have taken the key in the meantime. Scoped so the
355        // guard does not live across the await below (a held MutexGuard
356        // would make the middleware future !Send).
357        {
358            let mut map = cache.in_flight.lock().expect("in_flight mutex poisoned");
359            match map.get(&key) {
360                Some(current) if Arc::ptr_eq(current, &entry) => {
361                    map.remove(&key);
362                },
363                _ => {},
364            }
365        }
366        entry.done.notify_waiters();
367        return next.run(req).await;
368    }
369
370    // Owner cleanup: removes the entry (and wakes waiters) whenever
371    // this future ends — normal return, client disconnect, or panic.
372    // Declared after the wait branch so waiters' `return` paths never
373    // hold it; it drops when the function unwinds below.
374    let _guard = InFlightGuard {
375        cache: Arc::clone(&cache),
376        key: key.clone(),
377        entry: Arc::clone(&entry),
378    };
379
380    // 3. Owner: run the real handler, capture the body, publish.
381    let response = next.run(req).await;
382    let status = response.status();
383    let response = if status.is_success() {
384        let (parts, body) = response.into_parts();
385        match collect_for_cache(body, MAX_CACHE_BODY_BYTES).await {
386            Ok(CollectedBody::Fitted(bytes)) => {
387                let text = String::from_utf8_lossy(&bytes).into_owned();
388                // Only cache valid JSON; pass anything else through.
389                if serde_json::from_str::<serde_json::Value>(&text).is_ok() {
390                    cache.insert(&key, text, bucket);
391                }
392                let content_type = parts
393                    .headers
394                    .get(axum::http::header::CONTENT_TYPE)
395                    .and_then(|v| v.to_str().ok())
396                    .map(|s| s.to_string());
397                *entry.response.lock().expect("poisoned") =
398                    Some((status.as_u16(), content_type, bytes.to_vec()));
399                Response::from_parts(parts, Body::from(bytes))
400            },
401            Ok(CollectedBody::Oversized(stream)) => {
402                // #197: a whitelisted endpoint can legitimately grow past
403                // the cache limit (wide-window exports, large genai
404                // rollups). Serve the full response — one stream, original
405                // status — uncached, instead of failing it.
406                tracing::debug!(
407                    "Serving {} response uncached (body > {} bytes)",
408                    key,
409                    MAX_CACHE_BODY_BYTES
410                );
411                *entry.response.lock().expect("poisoned") = None;
412                Response::from_parts(parts, Body::from_stream(stream))
413            },
414            Err(err) => {
415                // The body stream genuinely failed (not a size issue): the
416                // bytes are lost, so the only honest response left is an
417                // error.
418                *entry.response.lock().expect("poisoned") = None;
419                tracing::debug!("Not caching {} response: {}", key, err);
420                Response::builder()
421                    .status(StatusCode::INTERNAL_SERVER_ERROR)
422                    .header(axum::http::header::CONTENT_TYPE, "application/json")
423                    .body(Body::from(format!(
424                        r#"{{"error":"failed to read response body: {}"}}"#,
425                        err
426                    )))
427                    .expect("valid error response")
428            },
429        }
430    } else {
431        // Never cache errors; waiters fall through to their own request.
432        *entry.response.lock().expect("poisoned") = None;
433        response
434    };
435
436    // The guard (installed before the wait branch) removes the
437    // in-flight entry and wakes waiters when this function returns.
438    response
439}
440
441/// Rebuild a 200 JSON response from cached bytes.
442fn json_response(body: &str) -> Response {
443    Response::builder()
444        .status(StatusCode::OK)
445        .header(axum::http::header::CONTENT_TYPE, "application/json")
446        .header("x-cache", "hit")
447        .body(Body::from(body.to_string()))
448        .expect("valid cached response")
449}
450
451/// Rebuild a response from a captured (status, content-type, body) triple.
452fn rebuild_response(status: u16, content_type: Option<String>, body: Vec<u8>) -> Response {
453    let mut builder =
454        Response::builder().status(StatusCode::from_u16(status).unwrap_or(StatusCode::OK));
455    if let Some(ct) = content_type {
456        builder = builder.header(axum::http::header::CONTENT_TYPE, ct);
457    }
458    builder
459        .header("x-cache", "dedup")
460        .body(Body::from(body))
461        .expect("valid rebuilt response")
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn test_policy_whitelist() {
470        assert_eq!(CacheState::policy("/api/genai/usage"), Some(Bucket::Genai));
471        assert_eq!(CacheState::policy("/api/stats"), Some(Bucket::Stats));
472        assert_eq!(CacheState::policy("/api/sessions"), Some(Bucket::Sessions));
473        assert_eq!(CacheState::policy("/api/metrics"), Some(Bucket::Metrics));
474        assert_eq!(
475            CacheState::policy("/api/metrics/names"),
476            Some(Bucket::Names)
477        );
478        assert_eq!(
479            CacheState::policy("/api/metrics/timeseries/whatever"),
480            Some(Bucket::Metrics)
481        );
482        assert_eq!(CacheState::policy("/api/resource-keys"), Some(Bucket::Keys));
483        assert_eq!(CacheState::policy("/api/traces"), None);
484        assert_eq!(CacheState::policy("/api/health"), None);
485        assert_eq!(CacheState::policy("/api/admin/purge"), None);
486        // genai subpaths match, but a lookalike prefix does not
487        assert_eq!(
488            CacheState::policy("/api/genai/other/thing"),
489            Some(Bucket::Genai)
490        );
491        assert_eq!(CacheState::policy("/api/genai2/usage"), None);
492        assert_eq!(CacheState::policy("/api/metricstore"), None);
493    }
494
495    #[test]
496    fn test_policy_excludes_metrics_export() {
497        // #197: exports are one-shot payloads that can legitimately
498        // exceed the cache limit — never cache (or buffer for the cache).
499        assert_eq!(CacheState::policy("/api/metrics/export"), None);
500        // Regular metrics queries still cache.
501        assert_eq!(CacheState::policy("/api/metrics"), Some(Bucket::Metrics));
502        assert_eq!(
503            CacheState::policy("/api/metrics/timeseries/whatever"),
504            Some(Bucket::Metrics)
505        );
506    }
507
508    #[test]
509    fn test_bucket_ttls() {
510        assert_eq!(Bucket::Genai.ttl(), Duration::from_secs(20));
511        assert_eq!(Bucket::Stats.ttl(), Duration::from_secs(60));
512        assert_eq!(Bucket::Sessions.ttl(), Duration::from_secs(10));
513        assert_eq!(Bucket::Metrics.ttl(), Duration::from_secs(60));
514        assert_eq!(Bucket::Names.ttl(), Duration::from_secs(600));
515        assert_eq!(Bucket::Keys.ttl(), Duration::from_secs(300));
516    }
517
518    #[test]
519    fn test_cache_clear_all() {
520        let state = CacheState::new();
521        state.insert("k1", "v1".to_string(), Bucket::Genai);
522        state.insert("k2", "v2".to_string(), Bucket::Stats);
523        state.insert("k3", "v3".to_string(), Bucket::Sessions);
524        state.insert("k4", "v4".to_string(), Bucket::Metrics);
525        state.insert("k5", "v5".to_string(), Bucket::Names);
526        state.insert("k6", "v6".to_string(), Bucket::Keys);
527        assert!(state.get("k1", Bucket::Genai).is_some());
528        assert!(state.get("k2", Bucket::Stats).is_some());
529        assert!(state.get("k3", Bucket::Sessions).is_some());
530        assert!(state.get("k4", Bucket::Metrics).is_some());
531        assert!(state.get("k5", Bucket::Names).is_some());
532        assert!(state.get("k6", Bucket::Keys).is_some());
533        state.clear_all();
534        assert!(state.get("k1", Bucket::Genai).is_none());
535        assert!(state.get("k2", Bucket::Stats).is_none());
536        assert!(state.get("k3", Bucket::Sessions).is_none());
537        assert!(state.get("k4", Bucket::Metrics).is_none());
538        assert!(state.get("k5", Bucket::Names).is_none());
539        assert!(state.get("k6", Bucket::Keys).is_none());
540    }
541
542    // ── Middleware integration tests ────────────────────────────────────────
543
544    use axum::{routing::get, Json, Router};
545    use std::sync::atomic::{AtomicUsize, Ordering};
546    use tower::ServiceExt;
547
548    fn get_request(uri: &str) -> Request {
549        Request::builder()
550            .uri(uri)
551            .body(Body::empty())
552            .expect("valid request")
553    }
554
555    async fn body_text(response: Response) -> String {
556        let bytes = http_body_util::BodyExt::collect(response.into_body())
557            .await
558            .expect("body readable")
559            .to_bytes();
560        String::from_utf8(bytes.to_vec()).expect("utf-8 body")
561    }
562
563    /// Handler counting how many times it actually runs.
564    async fn counting_handler(count: Arc<AtomicUsize>) -> Json<serde_json::Value> {
565        let n = count.fetch_add(1, Ordering::SeqCst) + 1;
566        Json(serde_json::json!({ "handler_runs": n }))
567    }
568
569    #[tokio::test]
570    async fn test_ttl_cache_hit_skips_handler() {
571        let count = Arc::new(AtomicUsize::new(0));
572        let c = Arc::clone(&count);
573        let app = Router::new()
574            .route(
575                "/api/genai/usage",
576                get(move |_req: axum::extract::Request| async move { counting_handler(c).await }),
577            )
578            .layer(axum::middleware::from_fn_with_state(
579                CacheState::new(),
580                cache_handler,
581            ));
582
583        let first = app
584            .clone()
585            .oneshot(get_request("/api/genai/usage?start=1&end=2"))
586            .await
587            .unwrap();
588        assert_eq!(first.status(), StatusCode::OK);
589        assert!(first.headers().get("x-cache").is_none());
590
591        // Same path + query within the TTL: served from cache, handler not
592        // re-run.
593        let second = app
594            .clone()
595            .oneshot(get_request("/api/genai/usage?start=1&end=2"))
596            .await
597            .unwrap();
598        assert_eq!(second.status(), StatusCode::OK);
599        assert_eq!(
600            second
601                .headers()
602                .get("x-cache")
603                .and_then(|v| v.to_str().ok()),
604            Some("hit")
605        );
606        assert_eq!(count.load(Ordering::SeqCst), 1);
607        assert_eq!(body_text(second).await, r#"{"handler_runs":1}"#);
608
609        // Different query params → different key → handler runs again.
610        let third = app
611            .clone()
612            .oneshot(get_request("/api/genai/usage?start=1&end=3"))
613            .await
614            .unwrap();
615        assert_eq!(third.status(), StatusCode::OK);
616        assert_eq!(count.load(Ordering::SeqCst), 2);
617    }
618
619    #[tokio::test]
620    async fn test_in_flight_dedup_collapses_concurrent_requests() {
621        let count = Arc::new(AtomicUsize::new(0));
622        let c = Arc::clone(&count);
623        let app = Router::new()
624            .route(
625                "/api/genai/usage",
626                get(move |_req: axum::extract::Request| async move {
627                    // Long enough that two concurrent requests overlap.
628                    tokio::time::sleep(Duration::from_millis(200)).await;
629                    counting_handler(c).await
630                }),
631            )
632            .layer(axum::middleware::from_fn_with_state(
633                CacheState::new(),
634                cache_handler,
635            ));
636
637        let uri = "/api/genai/usage?start=9&end=9";
638        let (first, second) = tokio::join!(
639            app.clone().oneshot(get_request(uri)),
640            app.clone().oneshot(get_request(uri)),
641        );
642        let first = first.unwrap();
643        let second = second.unwrap();
644        assert_eq!(first.status(), StatusCode::OK);
645        assert_eq!(second.status(), StatusCode::OK);
646        // The handler ran exactly once despite two concurrent requests.
647        assert_eq!(count.load(Ordering::SeqCst), 1);
648        assert_eq!(body_text(first).await, body_text(second).await);
649    }
650
651    #[tokio::test]
652    async fn test_non_whitelisted_path_passes_through() {
653        let count = Arc::new(AtomicUsize::new(0));
654        let c = Arc::clone(&count);
655        let app = Router::new()
656            .route(
657                "/api/traces",
658                get(move |_req: axum::extract::Request| async move { counting_handler(c).await }),
659            )
660            .layer(axum::middleware::from_fn_with_state(
661                CacheState::new(),
662                cache_handler,
663            ));
664
665        let _ = app
666            .clone()
667            .oneshot(get_request("/api/traces"))
668            .await
669            .unwrap();
670        let second = app
671            .clone()
672            .oneshot(get_request("/api/traces"))
673            .await
674            .unwrap();
675        // Not whitelisted: every request reaches the handler, no x-cache.
676        assert_eq!(count.load(Ordering::SeqCst), 2);
677        assert!(second.headers().get("x-cache").is_none());
678    }
679
680    /// Regression test: when the owner of an in-flight dedup entry is
681    /// cancelled (client disconnect), its RAII guard must remove the
682    /// entry. Before the fix the entry lingered and every duplicate —
683    /// including waiters already parked on it — had to wait out the
684    /// full 120 s DEDUP_WAIT_TIMEOUT before running its own copy.
685    #[tokio::test]
686    async fn test_oversize_response_served_uncached_not_500() {
687        // #197: a whitelisted response over MAX_CACHE_BODY_BYTES must be
688        // served in full with its original status — not a
689        // "response too large to cache" 500 — and not cached.
690        let count = Arc::new(AtomicUsize::new(0));
691        let c = Arc::clone(&count);
692        let app = Router::new()
693            .route(
694                "/api/genai/usage",
695                get(move |_req: axum::extract::Request| async move {
696                    let payload = "x".repeat(MAX_CACHE_BODY_BYTES + 1024);
697                    let n = c.fetch_add(1, Ordering::SeqCst) + 1;
698                    Json(serde_json::json!({ "handler_runs": n, "data": payload }))
699                }),
700            )
701            .layer(axum::middleware::from_fn_with_state(
702                CacheState::new(),
703                cache_handler,
704            ));
705
706        let first = app
707            .clone()
708            .oneshot(get_request("/api/genai/usage?start=1&end=2"))
709            .await
710            .unwrap();
711        assert_eq!(first.status(), StatusCode::OK, "oversize must not 500");
712        let body = body_text(first).await;
713        assert!(
714            body.len() > MAX_CACHE_BODY_BYTES,
715            "full body must be served: {} bytes",
716            body.len()
717        );
718        assert!(body.contains(&"x".repeat(MAX_CACHE_BODY_BYTES + 1024)));
719
720        // Not cached: a second identical request re-runs the handler.
721        let second = app
722            .clone()
723            .oneshot(get_request("/api/genai/usage?start=1&end=2"))
724            .await
725            .unwrap();
726        assert_eq!(second.status(), StatusCode::OK);
727        assert_eq!(count.load(Ordering::SeqCst), 2);
728    }
729
730    #[tokio::test]
731    async fn test_cancelled_owner_cleans_up_in_flight_entry() {
732        let count = Arc::new(AtomicUsize::new(0));
733        let state = CacheState::new();
734        let c = Arc::clone(&count);
735        let app = Router::new()
736            .route(
737                "/api/genai/usage",
738                get(move |_req: axum::extract::Request| async move {
739                    // Long enough that the owner is cancelled mid-run.
740                    tokio::time::sleep(Duration::from_millis(500)).await;
741                    counting_handler(c).await
742                }),
743            )
744            .layer(axum::middleware::from_fn_with_state(
745                Arc::clone(&state),
746                cache_handler,
747            ));
748
749        let uri = "/api/genai/usage?start=7&end=7";
750
751        // Owner request first, so it is guaranteed to register the
752        // in-flight entry before the duplicate joins it.
753        let owner_app = app.clone();
754        let owner = tokio::spawn(async move { owner_app.oneshot(get_request(uri)).await });
755        tokio::time::sleep(Duration::from_millis(150)).await;
756        eprintln!(
757            "DBG after owner sleep: count={} inflight={}",
758            count.load(Ordering::SeqCst),
759            state.in_flight.lock().expect("poisoned").len()
760        );
761
762        // A concurrent duplicate parks on the owner's entry.
763        let waiter_app = app.clone();
764        let waiter = tokio::spawn(async move { waiter_app.oneshot(get_request(uri)).await });
765        tokio::time::sleep(Duration::from_millis(50)).await;
766        eprintln!(
767            "DBG after waiter sleep: count={} inflight={}",
768            count.load(Ordering::SeqCst),
769            state.in_flight.lock().expect("poisoned").len()
770        );
771
772        // Client disconnect: drop the owner mid-run.
773        owner.abort();
774
775        let started = tokio::time::Instant::now();
776        let waiter_response = waiter
777            .await
778            .expect("waiter task must not abort")
779            .expect("waiter request completes");
780        let waiter_elapsed = started.elapsed();
781
782        // The waiter must get a usable response quickly. Depending on
783        // scheduling, the owner's handler may or may not have started
784        // before the abort, so 1 or 2 handler runs are both correct —
785        // what must NOT happen is the waiter waiting out the 120 s
786        // dedup timeout on the dead entry.
787        let waiter_status = waiter_response.status();
788        let waiter_body = body_text(waiter_response).await;
789        assert_eq!(waiter_status, StatusCode::OK);
790        let runs = count.load(Ordering::SeqCst);
791        assert!(
792            (1..=2).contains(&runs),
793            "expected the owner's and/or waiter's handler run, got {runs}"
794        );
795        let parsed: serde_json::Value =
796            serde_json::from_str(&waiter_body).expect("waiter got valid JSON");
797        assert!(parsed.get("handler_runs").is_some());
798        assert!(
799            waiter_elapsed < Duration::from_secs(5),
800            "waiter waited {waiter_elapsed:?} after the owner's cancellation; a cleaned-up entry lets it run its own copy within ~1 s"
801        );
802
803        // The key is free again: no in-flight entry lingers, and a
804        // follow-up request is served immediately (cache hit on the
805        // waiter's result).
806        assert!(state.in_flight.lock().expect("poisoned").is_empty());
807        let follower = app
808            .clone()
809            .oneshot(get_request(uri))
810            .await
811            .expect("follower request completes");
812        assert_eq!(follower.status(), StatusCode::OK);
813        assert!(state.in_flight.lock().expect("poisoned").is_empty());
814    }
815
816    /// The guard's removal is conditional on the map still holding the
817    /// guard's exact entry: a stale guard whose entry was already
818    /// evicted and replaced must not delete the newcomer's entry.
819    #[test]
820    fn test_stale_in_flight_guard_keeps_new_owner() {
821        let state = CacheState::new();
822        let key = "/api/stats".to_string();
823
824        let old_entry = Arc::new(InFlight {
825            done: Arc::new(tokio::sync::Notify::new()),
826            response: Mutex::new(None),
827        });
828        state
829            .in_flight
830            .lock()
831            .expect("poisoned")
832            .insert(key.clone(), Arc::clone(&old_entry));
833
834        // A fresh owner re-registers the same key, then the stale guard
835        // drops (as an aborted owner's would, after the re-registration).
836        let new_entry = Arc::new(InFlight {
837            done: Arc::new(tokio::sync::Notify::new()),
838            response: Mutex::new(None),
839        });
840        state
841            .in_flight
842            .lock()
843            .expect("poisoned")
844            .insert(key.clone(), Arc::clone(&new_entry));
845
846        let stale_guard = InFlightGuard {
847            cache: Arc::clone(&state),
848            key,
849            entry: old_entry,
850        };
851        drop(stale_guard);
852
853        let map = state.in_flight.lock().expect("poisoned");
854        assert!(
855            map.get("/api/stats")
856                .is_some_and(|current| Arc::ptr_eq(current, &new_entry)),
857            "the stale guard must not evict the new owner's entry"
858        );
859    }
860}