Skip to main content

cachekit/backend/
cachekitio.rs

1use std::collections::HashMap;
2use std::sync::OnceLock;
3use std::time::Duration;
4
5use async_trait::async_trait;
6use bytes::Bytes;
7use reqwest::header::{HeaderName, HeaderValue, InvalidHeaderValue};
8use zeroize::Zeroizing;
9
10use crate::backend::{
11    delete_succeeded, encode_key, ttl_header, Backend, Freshness, HealthStatus, LockableBackend,
12};
13use crate::error::{BackendError, BackendErrorKind};
14use crate::metrics::{metrics_headers_named, MetricsProvider, METRICS_HEADER_NAMES};
15use crate::session::session_headers;
16use crate::url_validator::cachekitio_base_url;
17
18// ── CachekitIO ────────────────────────────────────────────────────────────────
19
20/// HTTP backend that talks to the cachekit.io SaaS API.
21pub struct CachekitIO {
22    client: reqwest::Client,
23    api_key: Zeroizing<String>,
24    api_url: String,
25    /// `Authorization` and the session headers, built once so no request
26    /// re-formats or re-parses them. The bearer value is marked sensitive, so
27    /// a request's `Debug` output redacts it, and its buffer is wiped when the
28    /// last clone drops.
29    static_headers: [(HeaderName, HeaderValue); 3],
30    /// The telemetry header names, parsed once, in [`METRICS_HEADER_NAMES`] order.
31    metrics_header_names: [HeaderName; 5],
32    /// Source of the `X-CacheKit-*` telemetry headers. Set once: by the
33    /// builder when the user supplies one, otherwise by the client at build
34    /// time via [`Backend::attach_metrics`] (first writer wins).
35    metrics_provider: OnceLock<MetricsProvider>,
36}
37
38/// Redact `api_key` from debug output.
39impl std::fmt::Debug for CachekitIO {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.debug_struct("CachekitIO")
42            .field("api_url", &self.api_url)
43            .field("api_key", &"<redacted>")
44            .finish()
45    }
46}
47
48impl CachekitIO {
49    /// Start building a [`CachekitIO`] instance.
50    pub fn builder() -> CachekitIOBuilder {
51        CachekitIOBuilder::default()
52    }
53
54    /// Return the configured API URL (useful in tests / introspection).
55    pub fn api_url(&self) -> &str {
56        &self.api_url
57    }
58
59    /// Return a reference to the underlying HTTP client.
60    pub(crate) fn client(&self) -> &reqwest::Client {
61        &self.client
62    }
63
64    /// Return the API key as a string slice (for error sanitizing in sibling modules).
65    pub(crate) fn api_key_str(&self) -> &str {
66        self.api_key.as_str()
67    }
68
69    /// Build the full URL for a cache key path segment.
70    ///
71    /// Keys are percent-encoded via [`encode_key`](crate::backend::encode_key) so
72    /// slashes or special characters do not break the URL structure. Fallible: it
73    /// rejects exactly the keys `encode_key` rejects (CWE-22, spec rule 2).
74    fn url(&self, key: &str) -> Result<String, BackendError> {
75        Ok(format!("{}/v1/cache/{}", self.api_url, encode_key(key)?))
76    }
77
78    /// Build the TTL URL for a cache key (`…/ttl`). Composes on [`Self::url`], so
79    /// it inherits the same [`encode_key`](crate::backend::encode_key) guard and
80    /// the `/v1/cache/` prefix lives in one place (matching the wasm `workers`
81    /// backend). `pub(crate)` so the [`TtlInspectable`](super::TtlInspectable)
82    /// impl in the sibling `cachekitio_ttl` module builds its path through it.
83    pub(crate) fn ttl_url(&self, key: &str) -> Result<String, BackendError> {
84        Ok(format!("{}/ttl", self.url(key)?))
85    }
86
87    /// Build the lock URL for a cache key (`…/lock`). Composes on [`Self::url`]
88    /// (same guard, same single prefix). `pub(crate)` so the
89    /// [`LockableBackend`](super::LockableBackend) impl in the sibling
90    /// `cachekitio_lock` module builds its path through it.
91    pub(crate) fn lock_url(&self, key: &str) -> Result<String, BackendError> {
92        Ok(format!("{}/lock", self.url(key)?))
93    }
94
95    /// Build the health-check URL.
96    fn health_url(&self) -> String {
97        format!("{}/v1/cache/health", self.api_url)
98    }
99
100    /// Apply the bearer token, session and metrics headers to a request builder.
101    pub(crate) fn with_standard_headers(
102        &self,
103        mut req: reqwest::RequestBuilder,
104    ) -> reqwest::RequestBuilder {
105        for (name, value) in &self.static_headers {
106            req = req.header(name.clone(), value.clone());
107        }
108        for (name, value) in
109            metrics_headers_named(self.metrics_provider.get(), &self.metrics_header_names)
110        {
111            req = req.header(name, value);
112        }
113        req
114    }
115
116    /// Build the `PUT` for [`Backend::set`] without sending it, so tests can
117    /// pin its headers.
118    fn put_request(
119        &self,
120        key: &str,
121        value: Vec<u8>,
122        ttl: Option<Duration>,
123    ) -> Result<reqwest::RequestBuilder, BackendError> {
124        let mut req = self
125            .client
126            .put(self.url(key)?)
127            .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
128            .body(value);
129
130        if let Some(ttl) = ttl {
131            let (name, value) = ttl_header(ttl);
132            req = req.header(name, value);
133        }
134
135        Ok(self.with_standard_headers(req))
136    }
137
138    /// Read error body from response and build a sanitized BackendError.
139    pub(crate) async fn error_from_response(&self, resp: reqwest::Response) -> BackendError {
140        let status = resp.status().as_u16();
141        let quota_denied = status == 429 && resp.headers().contains_key(DENY_REASON_HEADER);
142        let body = resp.bytes().await.unwrap_or_default();
143        let err = from_http_status_sanitized(status, &body, self.api_key.as_str());
144        if quota_denied {
145            err.with_quota_denied()
146        } else {
147            err
148        }
149    }
150}
151
152// ── Error helpers ────────────────────────────────────────────────────────────
153
154/// Convert a reqwest error into a BackendError, sanitizing the API key from the message.
155pub(crate) fn reqwest_err_sanitized(e: reqwest::Error, api_key: &str) -> BackendError {
156    let kind = if e.is_timeout() {
157        BackendErrorKind::Timeout
158    } else {
159        BackendErrorKind::Transient
160    };
161    BackendError {
162        kind,
163        message: BackendError::sanitize_message(&e.to_string(), api_key),
164        source: Some(Box::new(e)),
165    }
166}
167
168/// Build a [`BackendError`] from an HTTP status + body, sanitizing the API key from output.
169pub(crate) fn from_http_status_sanitized(status: u16, body: &[u8], api_key: &str) -> BackendError {
170    let sanitized =
171        BackendError::sanitize_message(std::str::from_utf8(body).unwrap_or(""), api_key);
172    BackendError::from_http_status(status, sanitized.as_bytes())
173}
174
175// ── Freshness headers ────────────────────────────────────────────────────────
176
177/// Read a `GET 200`'s [`Freshness`] from its headers (`spec/saas-api.md`
178/// § Stale-While-Revalidate, § Remaining Freshness).
179///
180/// An absent `X-CacheKit-Freshness` means fresh (a pre-SWR server). Any value
181/// other than exactly `fresh` means stale, because revalidating is the safe
182/// reading of a token the client does not know; every copy of a repeated
183/// header must say `fresh`. An absent `X-CacheKit-Fresh-For` means no server
184/// bound. A repeated one has no single bound, so it counts as `0`.
185#[cfg(not(target_arch = "wasm32"))]
186pub(crate) fn freshness_from_headers(headers: &reqwest::header::HeaderMap) -> Freshness {
187    let is_stale = headers
188        .get_all("x-cachekit-freshness")
189        .iter()
190        .any(|value| value != "fresh");
191    let mut fresh_for = headers.get_all("x-cachekit-fresh-for").iter();
192    let fresh_for = match (fresh_for.next(), fresh_for.next()) {
193        (None, _) => None,
194        (Some(value), None) => Some(parse_fresh_for(value.as_bytes())),
195        (Some(_), Some(_)) => Some(Duration::ZERO),
196    };
197    Freshness {
198        is_stale,
199        fresh_for,
200    }
201}
202
203/// `X-CacheKit-Fresh-For` must be 1–7 ASCII digits and at most 2,592,000 (the
204/// 30-day TTL cap); anything else counts as `0`. The length check runs first,
205/// so the range check never sees a value that a fixed-width parse could wrap
206/// back into range (`4297559296` as a `u32` is `2592000`).
207#[cfg(not(target_arch = "wasm32"))]
208fn parse_fresh_for(value: &[u8]) -> Duration {
209    const MAX_FRESH_FOR_SECS: u64 = 2_592_000;
210    if !(1..=7).contains(&value.len()) || !value.iter().all(u8::is_ascii_digit) {
211        return Duration::ZERO;
212    }
213    match std::str::from_utf8(value).map(str::parse::<u64>) {
214        Ok(Ok(secs)) if secs <= MAX_FRESH_FOR_SECS => Duration::from_secs(secs),
215        _ => Duration::ZERO,
216    }
217}
218
219// ── Backend impl ──────────────────────────────────────────────────────────────
220
221#[cfg(not(target_arch = "wasm32"))]
222#[cfg_attr(not(feature = "unsync"), async_trait)]
223#[cfg_attr(feature = "unsync", async_trait(?Send))]
224impl Backend for CachekitIO {
225    fn attach_metrics(&self, provider: MetricsProvider) {
226        // A provider supplied on the builder is already set and wins.
227        self.metrics_provider.get_or_init(|| provider);
228    }
229
230    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError> {
231        Ok(self.get_with_freshness(key).await?.map(|(bytes, _)| bytes))
232    }
233
234    async fn get_with_freshness(
235        &self,
236        key: &str,
237    ) -> Result<Option<(Vec<u8>, Freshness)>, BackendError> {
238        let req = self.with_standard_headers(self.client.get(self.url(key)?));
239
240        let resp = req
241            .send()
242            .await
243            .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
244
245        match resp.status().as_u16() {
246            200 => {
247                let freshness = freshness_from_headers(resp.headers());
248                let bytes = resp
249                    .bytes()
250                    .await
251                    .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
252                Ok(Some((Vec::from(bytes), freshness)))
253            }
254            404 => Ok(None),
255            _ => Err(self.error_from_response(resp).await),
256        }
257    }
258
259    async fn set(
260        &self,
261        key: &str,
262        value: Vec<u8>,
263        ttl: Option<Duration>,
264    ) -> Result<(), BackendError> {
265        let resp = self
266            .put_request(key, value, ttl)?
267            .timeout(WRITE_TIMEOUT)
268            .send()
269            .await
270            .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
271
272        let status = resp.status().as_u16();
273        if (200..300).contains(&status) {
274            Ok(())
275        } else {
276            Err(self.error_from_response(resp).await)
277        }
278    }
279
280    /// `true` on every successful delete, whether or not the key existed: the
281    /// server does not report existence on `DELETE`.
282    async fn delete(&self, key: &str) -> Result<bool, BackendError> {
283        let req = self.with_standard_headers(self.client.delete(self.url(key)?));
284
285        let resp = req
286            .timeout(WRITE_TIMEOUT)
287            .send()
288            .await
289            .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
290
291        if delete_succeeded(resp.status().as_u16()) {
292            Ok(true)
293        } else {
294            Err(self.error_from_response(resp).await)
295        }
296    }
297
298    async fn exists(&self, key: &str) -> Result<bool, BackendError> {
299        let req = self.with_standard_headers(self.client.head(self.url(key)?));
300
301        let resp = req
302            .send()
303            .await
304            .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
305
306        match resp.status().as_u16() {
307            200 => Ok(true),
308            404 => Ok(false),
309            // Through the header-aware path, so a quota-deny `429` is marked.
310            _ => Err(self.error_from_response(resp).await),
311        }
312    }
313
314    async fn health(&self) -> Result<HealthStatus, BackendError> {
315        let start = std::time::Instant::now();
316
317        let req = self.with_standard_headers(self.client.get(self.health_url()));
318
319        let resp = req
320            .send()
321            .await
322            .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
323
324        let latency = start.elapsed();
325        let status = resp.status().as_u16();
326
327        if (200..300).contains(&status) {
328            let mut details = HashMap::new();
329            details.insert("http_status".to_string(), status.to_string());
330            Ok(HealthStatus {
331                is_healthy: true,
332                latency_ms: latency.as_secs_f64() * 1000.0,
333                backend_type: "cachekitio".to_string(),
334                details,
335            })
336        } else {
337            Err(self.error_from_response(resp).await)
338        }
339    }
340
341    fn as_lockable(&self) -> Option<&dyn LockableBackend> {
342        Some(self)
343    }
344}
345
346// ── Prebuilt headers ──────────────────────────────────────────────────────────
347
348/// Build the per-client constant headers: `Authorization: Bearer <key>`,
349/// marked sensitive as `bearer_auth` would, and the process's session headers.
350/// The bearer outlives every request and each request clones it, so its buffer
351/// is a zeroising owner those clones share: the last drop wipes the key.
352///
353/// # Errors
354///
355/// [`CachekitError::Config`](crate::error::CachekitError::Config) when the key
356/// holds bytes an HTTP header cannot carry. The message never echoes the key.
357fn static_headers(
358    api_key: &str,
359) -> Result<[(HeaderName, HeaderValue); 3], crate::error::CachekitError> {
360    use crate::error::CachekitError;
361
362    let mut bearer = Zeroizing::new(Vec::with_capacity("Bearer ".len() + api_key.len()));
363    bearer.extend_from_slice(b"Bearer ");
364    bearer.extend_from_slice(api_key.as_bytes());
365    let mut auth = header_over(bearer).map_err(|_| {
366        CachekitError::Config("api_key holds characters an HTTP header cannot carry".to_string())
367    })?;
368    auth.set_sensitive(true);
369
370    let [(id_name, id), (start_name, start)] = session_headers();
371    let header = |name: &str, value: &str| {
372        Ok::<_, CachekitError>((
373            HeaderName::from_bytes(name.as_bytes())
374                .map_err(|e| CachekitError::Config(format!("header name {name}: {e}")))?,
375            HeaderValue::from_str(value)
376                .map_err(|e| CachekitError::Config(format!("header {name}: {e}")))?,
377        ))
378    };
379    Ok([
380        (reqwest::header::AUTHORIZATION, auth),
381        header(id_name, id)?,
382        header(start_name, start)?,
383    ])
384}
385
386/// A header value over `owner`'s buffer, not a copy of it: clones share the
387/// buffer, and `owner` drops with the last of them.
388fn header_over<T: AsRef<[u8]> + Send + 'static>(
389    owner: T,
390) -> Result<HeaderValue, InvalidHeaderValue> {
391    HeaderValue::from_maybe_shared(Bytes::from_owner(owner))
392}
393
394/// Parse [`METRICS_HEADER_NAMES`] once per client.
395fn metrics_header_names() -> Result<[HeaderName; 5], crate::error::CachekitError> {
396    let [a, b, c, d, e] = METRICS_HEADER_NAMES.map(|name| {
397        HeaderName::from_bytes(name.as_bytes())
398            .map_err(|e| crate::error::CachekitError::Config(format!("header name {name}: {e}")))
399    });
400    Ok([a?, b?, c?, d?, e?])
401}
402
403// ── Builder ───────────────────────────────────────────────────────────────────
404
405/// `User-Agent` on every native CachekitIO request, so the service and its edge
406/// can tell this SDK and version apart from other HTTP clients.
407#[cfg(not(target_arch = "wasm32"))]
408const USER_AGENT: &str = concat!("cachekit-rs/", env!("CARGO_PKG_VERSION"));
409
410/// How long an idle pooled connection is kept for reuse. Cloudflare closes an
411/// idle client HTTP/1.1 connection after 400 s, not configurable; reqwest's
412/// 90 s default dropped connections the edge would still serve, so a request
413/// after a 90-390 s gap paid DNS, TCP and TLS again. 390 s leaves a 10 s
414/// margin, and reqwest's 15 s TCP keepalive keeps NAT mappings alive meanwhile.
415/// Per-attempt budget for every request but a write, connect and TLS
416/// included: the protocol's `CACHEKIT_TIMEOUT` default (`spec/saas-api.md`),
417/// as in cachekit-py and cachekit-ts. It was 30 s, so under the retrying
418/// presets a request the service accepted and never answered held a call for
419/// ~90 s before it failed open.
420#[cfg(not(target_arch = "wasm32"))]
421const READ_TIMEOUT: Duration = Duration::from_secs(5);
422
423/// Per-attempt budget for a write (`PUT`, `DELETE`): twice the read budget,
424/// because a write carries the payload and the service commits it before it
425/// answers, so a slow but healthy write must not time out and be re-sent.
426#[cfg(not(target_arch = "wasm32"))]
427const WRITE_TIMEOUT: Duration = Duration::from_secs(10);
428
429/// Header on a `429` that denies a spent quota or balance rather than a rate
430/// limit; such an error is never retried within the call (see
431/// [`crate::error::QuotaDenied`]).
432#[cfg_attr(target_arch = "wasm32", allow(dead_code))] // wasm32 uses the Workers backend
433const DENY_REASON_HEADER: &str = "x-cachekit-deny-reason";
434
435#[cfg(not(target_arch = "wasm32"))]
436const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(390);
437
438/// The settings behind every native [`CachekitIO`] client, unbuilt so tests can
439/// add `.no_proxy()` and reach a loopback stub whatever the proxy env says.
440#[cfg(not(target_arch = "wasm32"))]
441fn http_client_builder() -> reqwest::ClientBuilder {
442    reqwest::Client::builder()
443        .use_rustls_tls()
444        .user_agent(USER_AGENT)
445        .redirect(reqwest::redirect::Policy::none())
446        .timeout(READ_TIMEOUT)
447        .connect_timeout(READ_TIMEOUT)
448        .pool_idle_timeout(POOL_IDLE_TIMEOUT)
449}
450
451/// The HTTP client every native [`CachekitIO`] uses.
452#[cfg(not(target_arch = "wasm32"))]
453fn http_client() -> reqwest::Result<reqwest::Client> {
454    http_client_builder().build()
455}
456
457/// wasm32: the platform's `fetch` owns pooling and timeouts; no User-Agent is set.
458/// It also cannot refuse redirects (reqwest's wasm32 client sets no redirect
459/// mode), so `CachekitIO` must not gain a wasm32 `Backend` impl; `WorkersCachekitIO`
460/// is the wasm32 backend.
461#[cfg(target_arch = "wasm32")]
462fn http_client() -> reqwest::Result<reqwest::Client> {
463    reqwest::Client::builder().build()
464}
465
466/// Builder for [`CachekitIO`].
467#[derive(Default)]
468#[must_use]
469pub struct CachekitIOBuilder {
470    api_key: Option<Zeroizing<String>>,
471    api_url: Option<String>,
472    allow_custom_host: bool,
473    metrics_provider: Option<MetricsProvider>,
474}
475
476impl CachekitIOBuilder {
477    /// Set the API key (required).
478    pub fn api_key(mut self, key: impl Into<String>) -> Self {
479        self.api_key = Some(Zeroizing::new(key.into()));
480        self
481    }
482
483    /// Override the API base URL (default: `https://api.cachekit.io`).
484    pub fn api_url(mut self, url: impl Into<String>) -> Self {
485        self.api_url = Some(url.into());
486        self
487    }
488
489    /// Allow non-standard hostnames (e.g. custom proxies). Private IPs are still blocked.
490    pub fn allow_custom_host(mut self, allow: bool) -> Self {
491        self.allow_custom_host = allow;
492        self
493    }
494
495    /// Override the source of the `X-CacheKit-*` telemetry headers.
496    ///
497    /// Not needed for normal use: `CacheKitBuilder::build` attaches the
498    /// client's own live counters to any backend without one. Set this only
499    /// to report numbers from somewhere else; it takes precedence.
500    pub fn metrics_provider(mut self, provider: MetricsProvider) -> Self {
501        self.metrics_provider = Some(provider);
502        self
503    }
504
505    /// Consume the builder and construct a [`CachekitIO`].
506    ///
507    /// # Errors
508    ///
509    /// Returns an error if:
510    /// - `api_key` was not set, or holds bytes an HTTP header cannot carry
511    ///   (a control character such as a newline).
512    /// - the resolved URL scheme is not `https`, or the URL carries a query or
513    ///   a fragment.
514    /// - the URL carries credentials, or its hostname is not permitted (see
515    ///   [`validate_cachekitio_url`](crate::url_validator::validate_cachekitio_url)).
516    pub fn build(self) -> Result<CachekitIO, crate::error::CachekitError> {
517        use crate::error::CachekitError;
518
519        let api_key = self
520            .api_key
521            .filter(|k| !k.is_empty())
522            .ok_or_else(|| CachekitError::Config("api_key is required".to_string()))?;
523
524        let api_url = self
525            .api_url
526            .unwrap_or_else(|| "https://api.cachekit.io".to_string());
527
528        let api_url = cachekitio_base_url(&api_url, self.allow_custom_host)?;
529
530        let client = http_client()
531            .map_err(|e| CachekitError::Config(format!("failed to build HTTP client: {e}")))?;
532
533        let static_headers = static_headers(&api_key)?;
534        let metrics_header_names = metrics_header_names()?;
535
536        Ok(CachekitIO {
537            client,
538            api_key,
539            api_url,
540            static_headers,
541            metrics_header_names,
542            metrics_provider: self
543                .metrics_provider
544                .map_or_else(OnceLock::new, OnceLock::from),
545        })
546    }
547}
548
549// ── Freshness-header parsing tests ───────────────────────────────────────────
550
551#[cfg(all(test, not(target_arch = "wasm32")))]
552#[allow(clippy::expect_used)] // test-only: a malformed fixture header should panic loudly
553mod freshness_header_tests {
554    use std::time::Duration;
555
556    use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
557
558    use super::freshness_from_headers;
559
560    /// Build a header map with the wire's mixed-case names; `append` keeps
561    /// repeats, as a proxy that duplicates a header would.
562    fn headers(pairs: &[(&str, &[u8])]) -> HeaderMap {
563        let mut map = HeaderMap::new();
564        for (name, value) in pairs {
565            map.append(
566                HeaderName::from_bytes(name.as_bytes()).expect("valid header name"),
567                HeaderValue::from_bytes(value).expect("valid header value"),
568            );
569        }
570        map
571    }
572
573    fn label(values: &[&[u8]]) -> bool {
574        let pairs: Vec<_> = values
575            .iter()
576            .map(|v| ("X-CacheKit-Freshness", *v))
577            .collect();
578        freshness_from_headers(&headers(&pairs)).is_stale
579    }
580
581    fn fresh_for(values: &[&[u8]]) -> Option<Duration> {
582        let pairs: Vec<_> = values
583            .iter()
584            .map(|v| ("X-CacheKit-Fresh-For", *v))
585            .collect();
586        freshness_from_headers(&headers(&pairs)).fresh_for
587    }
588
589    // ── Protocol vectors ─────────────────────────────────────────────────────
590    //
591    // `tests/vectors/freshness-headers.json`, vendored verbatim from
592    // cachekit-io/protocol `test-vectors/freshness-headers.json` 1.0.0 (merge
593    // commit `f35635445a6a7461f93061aa51332e4f0d25c614`). Do not edit the JSON
594    // here; change it upstream and re-vendor, then update `FIXTURE_SHA256`.
595
596    const FIXTURE: &str = include_str!("../../tests/vectors/freshness-headers.json");
597
598    /// sha256 of the vendored file, pinned so a local edit cannot drift from the
599    /// protocol copy unnoticed.
600    const FIXTURE_SHA256: &str = "74beb975b6855f52d9dd453279873faf8048a9c3c79a8167cfda099beec5fcd3"; // pragma: allowlist secret
601
602    /// One fixture row. `deny_unknown_fields` makes a new row field upstream
603    /// fail loudly here rather than be silently ignored.
604    #[derive(serde::Deserialize)]
605    #[serde(deny_unknown_fields)]
606    struct Row<T> {
607        name: String,
608        value: Option<String>,
609        #[serde(alias = "stale", alias = "fresh_for")]
610        expected: T,
611        #[serde(rename = "note")]
612        _note: String,
613    }
614
615    #[derive(serde::Deserialize)]
616    struct Fixture {
617        freshness_vectors: Vec<Row<bool>>,
618        fresh_for_vectors: Vec<Row<Option<u64>>>,
619    }
620
621    /// The headers a `GET 200` carries for one row: none when `value` is null,
622    /// otherwise one field line whose bytes are the value's characters, each
623    /// U+0000..=U+00FF standing for one byte (the fixture's contract).
624    fn row_headers(name: &str, value: Option<&str>) -> HeaderMap {
625        let Some(value) = value else {
626            return HeaderMap::new();
627        };
628        let bytes: Vec<u8> = value
629            .chars()
630            .map(|c| u8::try_from(u32::from(c)).expect("fixture value char above U+00FF"))
631            .collect();
632        headers(&[(name, &bytes)])
633    }
634
635    fn fixture() -> Fixture {
636        serde_json::from_str(FIXTURE)
637            .expect("vendored freshness-headers.json must match the fixture schema")
638    }
639
640    #[test]
641    fn vendored_fixture_matches_the_pinned_sha256() {
642        use sha2::{Digest, Sha256};
643        assert_eq!(
644            hex::encode(Sha256::digest(FIXTURE.as_bytes())),
645            FIXTURE_SHA256,
646            "tests/vectors/freshness-headers.json differs from the pinned protocol copy: \
647             re-vendor it from protocol and update FIXTURE_SHA256"
648        );
649    }
650
651    #[test]
652    fn fixture_freshness_rows_classify_as_specified() {
653        let rows = fixture().freshness_vectors;
654        assert!(!rows.is_empty(), "fixture has no freshness rows");
655        for row in &rows {
656            let map = row_headers("X-CacheKit-Freshness", row.value.as_deref());
657            assert_eq!(
658                freshness_from_headers(&map).is_stale,
659                row.expected,
660                "row {}",
661                row.name
662            );
663        }
664    }
665
666    #[test]
667    fn fixture_fresh_for_rows_read_as_specified() {
668        let rows = fixture().fresh_for_vectors;
669        assert!(!rows.is_empty(), "fixture has no fresh_for rows");
670        for row in &rows {
671            let map = row_headers("X-CacheKit-Fresh-For", row.value.as_deref());
672            assert_eq!(
673                freshness_from_headers(&map).fresh_for,
674                row.expected.map(Duration::from_secs),
675                "row {}",
676                row.name
677            );
678        }
679    }
680
681    // ── rs-local cases the fixture cannot carry ──────────────────────────────
682    //
683    // The fixture's rows are one field line each, and none starts or ends with
684    // white space (stacks differ on it). reqwest keeps both repeated lines and
685    // edge white space, so rs pins its own reading of them here.
686
687    #[test]
688    fn repeated_lines_are_fresh_only_when_every_copy_is_fresh() {
689        assert!(!label(&[b"fresh", b"fresh"]));
690        assert!(label(&[b"fresh", b"stale"]));
691        assert_eq!(
692            fresh_for(&[b"10", b"10"]),
693            Some(Duration::ZERO),
694            "a repeated bound counts as 0"
695        );
696    }
697
698    #[test]
699    fn edge_white_space_is_not_stripped() {
700        assert!(label(&[b"fresh "]), "`fresh ` must read as stale");
701        assert_eq!(
702            fresh_for(&[b" 5"]),
703            Some(Duration::ZERO),
704            "` 5` must count as 0"
705        );
706    }
707}
708
709// ── Telemetry-header wiring tests ─────────────────────────────────────────────
710
711#[cfg(test)]
712#[allow(clippy::expect_used)] // test-only: a builder failure on a fixture should panic loudly
713mod metrics_wiring_tests {
714    use std::sync::Arc;
715
716    use super::CachekitIO;
717    use crate::backend::Backend;
718    use crate::metrics::{metrics_headers, L1Stats, MetricsProvider};
719
720    fn provider(l1_hits: u64) -> MetricsProvider {
721        Arc::new(move || {
722            Some(L1Stats {
723                l1_hits,
724                l2_hits: 0,
725                misses: 0,
726                l1_enabled: true,
727            })
728        })
729    }
730
731    fn l1_hits_header(backend: &CachekitIO) -> Option<String> {
732        metrics_headers(backend.metrics_provider.get())
733            .into_iter()
734            .find(|h| h.0 == "X-CacheKit-L1-Hits")
735            .map(|h| h.1)
736    }
737
738    #[test]
739    fn attach_fills_an_unset_provider() {
740        let backend = CachekitIO::builder()
741            .api_key("ck_test_key")
742            .build()
743            .expect("builder succeeds");
744        assert_eq!(l1_hits_header(&backend), None, "nothing attached yet");
745
746        backend.attach_metrics(provider(7));
747        assert_eq!(l1_hits_header(&backend).as_deref(), Some("7"));
748    }
749
750    #[test]
751    fn builder_provider_is_retained_over_attach() {
752        let backend = CachekitIO::builder()
753            .api_key("ck_test_key")
754            .metrics_provider(provider(1))
755            .build()
756            .expect("builder succeeds");
757
758        backend.attach_metrics(provider(99));
759        assert_eq!(
760            l1_hits_header(&backend).as_deref(),
761            Some("1"),
762            "user-supplied provider wins"
763        );
764    }
765}
766
767// ── Cache-key path encoding tests (CWE-22) ────────────────────────────────────
768
769#[cfg(test)]
770#[allow(clippy::expect_used)] // test-only: a builder/parse failure on a fixture should panic loudly
771mod path_encoding_tests {
772    use super::CachekitIO;
773    use crate::backend::path_encoding_vectors::{reject_keys, transmittable, RS_NEAR_MISSES};
774    use url::Url;
775
776    const API: &str = "https://api.cachekit.io";
777
778    fn backend() -> CachekitIO {
779        CachekitIO::builder()
780            .api_url(API)
781            .api_key("ck_test_key")
782            .build()
783            .expect("builder should succeed for the canonical host")
784    }
785
786    /// Repro. Before any guard, a raw-encoded `.`/`..` key collapses in
787    /// rust-url (the parser `reqwest` uses) *before* the request leaves the
788    /// process: the segment is stripped and the path escapes `/v1/cache/`.
789    /// `%2E%2E` collapses identically, which is why the fix rejects rather than
790    /// re-encodes (WHATWG treats `%2e%2e` as a dot-segment too).
791    #[test]
792    fn repro_raw_dot_key_escapes_the_cache_prefix() {
793        let cases = [
794            ("..", "", "/v1/"),
795            ("..", "/ttl", "/v1/ttl"),
796            ("..", "/lock", "/v1/lock"),
797            (".", "", "/v1/cache/"),
798        ];
799        for (key, suffix, escaped) in cases {
800            let raw = format!("{API}/v1/cache/{}{suffix}", urlencoding::encode(key));
801            let parsed = Url::parse(&raw).expect("parses");
802            assert_eq!(
803                parsed.path(),
804                escaped,
805                "raw {key:?}{suffix} should collapse to {escaped}"
806            );
807            // The %2E form the Python SDK emits collapses just the same in rust-url.
808            let pct = key.replace('.', "%2E");
809            let enc = format!("{API}/v1/cache/{pct}{suffix}");
810            assert_eq!(
811                Url::parse(&enc).expect("parses").path(),
812                escaped,
813                "%2E-encoded {key:?}{suffix} also collapses — encoding cannot fix this in rust-url"
814            );
815        }
816    }
817
818    /// Spec rule 2 — every fixture reject row (the empty key, `.`, `..`,
819    /// `health`, `ttl`, `lock`) is rejected by every builder (base, ttl, lock):
820    /// no URL is produced, so no rewritten or mis-routed request can be sent.
821    #[test]
822    fn reserved_segments_rejected_by_every_builder() {
823        let b = backend();
824        let keys = reject_keys();
825        assert!(!keys.is_empty(), "fixture has no reject rows");
826        for key in &keys {
827            assert!(b.url(key).is_err(), "url({key:?}) must be rejected");
828            assert!(b.ttl_url(key).is_err(), "ttl_url({key:?}) must be rejected");
829            assert!(
830                b.lock_url(key).is_err(),
831                "lock_url({key:?}) must be rejected"
832            );
833        }
834    }
835
836    /// Every transmittable fixture row, plus the rs near-misses, builds a URL
837    /// whose *parsed* path (the real wire path, post-normalisation) stays inside
838    /// `/v1/cache/`. Asserting on the unparsed `format!` output would pass while
839    /// still shipping a traversal, so we parse with the same `url` crate
840    /// `reqwest` uses.
841    #[test]
842    fn safe_keys_never_escape_the_cache_prefix() {
843        let b = backend();
844        let fixture_keys: Vec<String> = transmittable().into_iter().map(|v| v.key).collect();
845        let vectors = fixture_keys
846            .iter()
847            .map(String::as_str)
848            .chain(RS_NEAR_MISSES.iter().copied());
849        for key in vectors {
850            let base = Url::parse(&b.url(key).expect("url")).expect("parse base");
851            let ttl = Url::parse(&b.ttl_url(key).expect("ttl_url")).expect("parse ttl");
852            let lock = Url::parse(&b.lock_url(key).expect("lock_url")).expect("parse lock");
853
854            assert!(
855                base.path().starts_with("/v1/cache/") && base.path().len() > "/v1/cache/".len(),
856                "base path {} escaped prefix for {key:?}",
857                base.path()
858            );
859            assert_eq!(
860                base.path(),
861                format!("/v1/cache/{}", urlencoding::encode(key)),
862                "base wire path mismatch for {key:?}"
863            );
864            assert!(
865                ttl.path().starts_with("/v1/cache/") && ttl.path().ends_with("/ttl"),
866                "ttl path {} escaped prefix for {key:?}",
867                ttl.path()
868            );
869            assert!(
870                lock.path().starts_with("/v1/cache/") && lock.path().ends_with("/lock"),
871                "lock path {} escaped prefix for {key:?}",
872                lock.path()
873            );
874        }
875    }
876}
877
878// ── PUT TTL header tests ──────────────────────────────────────────────────────
879
880#[cfg(test)]
881#[allow(clippy::expect_used)] // test-only: a builder failure on a fixture should panic loudly
882mod put_ttl_header_tests {
883    use std::time::Duration;
884
885    use super::CachekitIO;
886
887    fn put(ttl: Option<Duration>) -> reqwest::Request {
888        CachekitIO::builder()
889            .api_key("ck_test_key")
890            .build()
891            .expect("builder succeeds")
892            .put_request("k", b"v".to_vec(), ttl)
893            .expect("key encodes")
894            .build()
895            .expect("request builds")
896    }
897
898    #[test]
899    fn put_sends_x_cachekit_ttl_and_no_legacy_x_ttl() {
900        let req = put(Some(Duration::from_secs(300)));
901        assert_eq!(
902            req.headers()
903                .get("X-CacheKit-TTL")
904                .and_then(|v| v.to_str().ok()),
905            Some("300")
906        );
907        assert!(req.headers().get("X-TTL").is_none(), "legacy header sent");
908    }
909
910    #[test]
911    fn put_without_ttl_sends_no_ttl_header() {
912        let req = put(None);
913        assert!(req.headers().get("X-CacheKit-TTL").is_none());
914        assert!(req.headers().get("X-TTL").is_none());
915    }
916}
917
918// ── Prebuilt header tests ─────────────────────────────────────────────────────
919
920#[cfg(test)]
921#[allow(clippy::expect_used)] // test-only: a builder failure on a fixture should panic loudly
922mod prebuilt_header_tests {
923    use std::sync::atomic::{AtomicBool, Ordering};
924    use std::sync::Arc;
925
926    use super::{header_over, CachekitIO};
927    use crate::metrics::{metrics_headers, L1Stats, MetricsProvider};
928    use crate::session::session_headers;
929
930    const KEY: &str = "ck_live_prebuilt_secret";
931
932    fn put(provider: Option<MetricsProvider>) -> reqwest::Request {
933        let mut builder = CachekitIO::builder().api_key(KEY);
934        if let Some(provider) = provider {
935            builder = builder.metrics_provider(provider);
936        }
937        builder
938            .build()
939            .expect("builder succeeds")
940            .put_request("k", b"v".to_vec(), None)
941            .expect("key encodes")
942            .build()
943            .expect("request builds")
944    }
945
946    #[test]
947    fn bearer_is_sent_and_redacted_from_debug() {
948        let req = put(None);
949        let auth = req
950            .headers()
951            .get(reqwest::header::AUTHORIZATION)
952            .expect("authorization header");
953        assert_eq!(auth.as_bytes(), format!("Bearer {KEY}").as_bytes());
954        assert!(auth.is_sensitive(), "prebuilt bearer must be sensitive");
955        let debug = format!("{req:?}");
956        assert!(!debug.contains(KEY), "api key leaked into Debug: {debug}");
957    }
958
959    /// The bearer's zeroising owner is the header's buffer, not a copy of it:
960    /// clones share it, and it drops (wiping the key) only with the last one.
961    #[test]
962    fn header_clones_share_the_owner_until_the_last_drops() {
963        struct Owner(Vec<u8>, Arc<AtomicBool>);
964        impl AsRef<[u8]> for Owner {
965            fn as_ref(&self) -> &[u8] {
966                &self.0
967            }
968        }
969        impl Drop for Owner {
970            fn drop(&mut self) {
971                self.1.store(true, Ordering::SeqCst);
972            }
973        }
974
975        let dropped = Arc::new(AtomicBool::new(false));
976        let header = header_over(Owner(b"Bearer k".to_vec(), Arc::clone(&dropped))).expect("valid");
977        let clone = header.clone();
978        drop(header);
979        assert!(
980            !dropped.load(Ordering::SeqCst),
981            "owner dropped while a clone lives"
982        );
983        assert_eq!(clone.as_bytes(), b"Bearer k");
984        drop(clone);
985        assert!(
986            dropped.load(Ordering::SeqCst),
987            "owner outlived its last clone"
988        );
989    }
990
991    #[test]
992    fn a_key_no_header_can_carry_fails_build_without_echoing_it() {
993        let err = CachekitIO::builder()
994            .api_key("ck_bad\nkey")
995            .build()
996            .expect_err("newline in key");
997        assert!(!err.to_string().contains("ck_bad"), "{err}");
998    }
999
1000    #[test]
1001    fn requests_go_to_the_validated_url_as_serialized() {
1002        let backend = CachekitIO::builder()
1003            .api_key("ck_test_key")
1004            .api_url("https://api.cachekit.io\\@evil.example")
1005            .build()
1006            .expect("allowlisted host");
1007        assert_eq!(backend.api_url(), "https://api.cachekit.io/@evil.example");
1008    }
1009
1010    /// The prebuilt headers carry the same names and values the string
1011    /// builders produce (header names compare case-insensitively).
1012    #[test]
1013    fn session_and_metrics_headers_match_their_string_builders() {
1014        let provider: MetricsProvider = Arc::new(|| {
1015            Some(L1Stats {
1016                l1_hits: 3,
1017                l2_hits: 2,
1018                misses: 5,
1019                l1_enabled: true,
1020            })
1021        });
1022        let req = put(Some(provider.clone()));
1023        let expected = session_headers()
1024            .into_iter()
1025            .map(|(n, v)| (n, v.to_string()))
1026            .chain(metrics_headers(Some(&provider)));
1027        let mut count = 0;
1028        for (name, value) in expected {
1029            let got = req.headers().get_all(name).iter().collect::<Vec<_>>();
1030            assert_eq!(got.len(), 1, "{name} sent {} times", got.len());
1031            assert_eq!(got[0].as_bytes(), value.as_bytes(), "{name}");
1032            count += 1;
1033        }
1034        assert_eq!(count, 7);
1035    }
1036
1037    #[test]
1038    fn disabled_metrics_send_only_the_status_header() {
1039        let req = put(None);
1040        assert_eq!(
1041            req.headers()
1042                .get("X-CacheKit-L1-Status")
1043                .map(|v| v.as_bytes()),
1044            Some(&b"disabled"[..])
1045        );
1046        assert!(req.headers().get("X-CacheKit-L1-Hits").is_none());
1047    }
1048}
1049
1050// ── HTTP client settings tests ────────────────────────────────────────────────
1051
1052#[cfg(all(test, not(target_arch = "wasm32")))]
1053#[allow(clippy::expect_used)] // test-only: a stub-server failure should panic loudly
1054mod http_client_tests {
1055    use std::io::{BufRead, BufReader, Read, Write};
1056    use std::net::TcpListener;
1057    use std::sync::{Arc, Mutex};
1058    use std::time::Duration;
1059
1060    use super::{http_client_builder, CachekitIO, READ_TIMEOUT, WRITE_TIMEOUT};
1061    use crate::backend::Backend;
1062    use crate::error::BackendErrorKind;
1063
1064    /// The production client minus the system proxy: an `HTTP_PROXY` or
1065    /// `ALL_PROXY` that does not exempt loopback would otherwise take the
1066    /// stub's requests.
1067    fn client() -> reqwest::Client {
1068        http_client_builder()
1069            .no_proxy()
1070            .build()
1071            .expect("client builds")
1072    }
1073
1074    /// A keep-alive HTTP/1.1 stub on loopback: answers every request `200`,
1075    /// records each request's header lines and body, and counts accepted
1076    /// connections.
1077    struct Stub {
1078        url: String,
1079        connections: Arc<Mutex<usize>>,
1080        requests: Arc<Mutex<Vec<Vec<String>>>>,
1081        bodies: Arc<Mutex<Vec<Vec<u8>>>>,
1082    }
1083
1084    const OK: &[u8] = b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n";
1085
1086    fn stub() -> Stub {
1087        stub_with(|_| Some(OK))
1088    }
1089
1090    /// [`stub`], but `answer(n)` gives the reply to the stub's `n`th request
1091    /// (from 0); `None` reads the request and never answers it.
1092    fn stub_with(answer: fn(usize) -> Option<&'static [u8]>) -> Stub {
1093        let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub");
1094        let url = format!("http://{}/", listener.local_addr().expect("addr"));
1095        let connections = Arc::new(Mutex::new(0));
1096        let requests = Arc::new(Mutex::new(Vec::new()));
1097        let bodies = Arc::new(Mutex::new(Vec::new()));
1098        let (conns, reqs, bods) = (connections.clone(), requests.clone(), bodies.clone());
1099        std::thread::spawn(move || {
1100            for stream in listener.incoming() {
1101                let Ok(mut stream) = stream else { return };
1102                *conns.lock().expect("lock") += 1;
1103                let (reqs, bods) = (reqs.clone(), bods.clone());
1104                std::thread::spawn(move || {
1105                    let mut reader = BufReader::new(stream.try_clone().expect("clone"));
1106                    loop {
1107                        let mut head = Vec::new();
1108                        loop {
1109                            let mut line = String::new();
1110                            if reader.read_line(&mut line).unwrap_or(0) == 0 {
1111                                return;
1112                            }
1113                            if line == "\r\n" {
1114                                break;
1115                            }
1116                            head.push(line.trim_end().to_owned());
1117                        }
1118                        let len = head
1119                            .iter()
1120                            .find_map(|h| {
1121                                let (name, value) = h.split_once(':')?;
1122                                name.eq_ignore_ascii_case("content-length")
1123                                    .then(|| value.trim().parse::<usize>().ok())?
1124                            })
1125                            .unwrap_or(0);
1126                        let mut body = vec![0; len];
1127                        if reader.read_exact(&mut body).is_err() {
1128                            return;
1129                        }
1130                        bods.lock().expect("lock").push(body);
1131                        let n = {
1132                            let mut reqs = reqs.lock().expect("lock");
1133                            reqs.push(head);
1134                            reqs.len() - 1
1135                        };
1136                        let Some(reply) = answer(n) else {
1137                            // Hold the connection open, unanswered, for good.
1138                            loop {
1139                                std::thread::park();
1140                            }
1141                        };
1142                        if stream.write_all(reply).is_err() {
1143                            return;
1144                        }
1145                    }
1146                });
1147            }
1148        });
1149        Stub {
1150            url,
1151            connections,
1152            requests,
1153            bodies,
1154        }
1155    }
1156
1157    /// Connections the stub accepted for two GETs `idle` apart, on the
1158    /// tokio clock reqwest's pool reads.
1159    async fn connections_after_idle(idle: Duration) -> usize {
1160        let stub = stub();
1161        let client = client();
1162        client.get(&stub.url).send().await.expect("first GET");
1163        tokio::time::pause();
1164        tokio::time::advance(idle).await;
1165        tokio::time::resume();
1166        client.get(&stub.url).send().await.expect("second GET");
1167        let n = *stub.connections.lock().expect("lock");
1168        n
1169    }
1170
1171    #[tokio::test]
1172    async fn refresh_ttl_sends_a_sub_second_ttl_as_one() {
1173        use crate::backend::TtlInspectable;
1174        let stub = stub();
1175        let backend = backend_at(&stub);
1176
1177        let refreshed = backend
1178            .refresh_ttl("k", Duration::from_millis(500))
1179            .await
1180            .expect("a sub-second TTL is sent, not rejected");
1181
1182        assert!(refreshed);
1183        let requests = stub.requests.lock().expect("lock");
1184        let head = &requests[0];
1185        assert!(head[0].starts_with("PATCH /v1/cache/k/ttl "), "{head:?}");
1186        assert_eq!(stub.bodies.lock().expect("lock")[0], br#"{"ttl":1}"#);
1187    }
1188
1189    #[tokio::test]
1190    async fn refresh_ttl_rejects_zero_before_sending() {
1191        use crate::backend::TtlInspectable;
1192        let stub = stub();
1193        let backend = backend_at(&stub);
1194
1195        let err = backend
1196            .refresh_ttl("k", Duration::ZERO)
1197            .await
1198            .expect_err("TTL 0 is an error");
1199
1200        assert_eq!(err.kind, BackendErrorKind::Permanent);
1201        assert!(stub.requests.lock().expect("lock").is_empty());
1202    }
1203
1204    #[tokio::test]
1205    async fn sends_cachekit_rs_user_agent() {
1206        let stub = stub();
1207        client().get(&stub.url).send().await.expect("GET");
1208        let want = format!("user-agent: cachekit-rs/{}", env!("CARGO_PKG_VERSION"));
1209        let requests = stub.requests.lock().expect("lock");
1210        assert!(
1211            requests[0].iter().any(|h| h.eq_ignore_ascii_case(&want)),
1212            "no `{want}` in {:?}",
1213            requests[0]
1214        );
1215    }
1216
1217    #[tokio::test]
1218    async fn reuses_a_connection_idle_389_s() {
1219        assert_eq!(connections_after_idle(Duration::from_secs(389)).await, 1);
1220    }
1221
1222    #[tokio::test]
1223    async fn redials_a_connection_idle_past_390_s() {
1224        assert_eq!(connections_after_idle(Duration::from_secs(391)).await, 2);
1225    }
1226
1227    /// A production [`CachekitIO`] aimed at `stub` over plain HTTP: the
1228    /// builder only accepts `https`, so the test swaps the client and URL.
1229    fn backend_at(stub: &Stub) -> CachekitIO {
1230        let mut backend = CachekitIO::builder()
1231            .api_key("ck_test_key")
1232            .build()
1233            .expect("builder succeeds for the default host");
1234        backend.client = client();
1235        backend.api_url = stub.url.trim_end_matches('/').to_owned();
1236        backend
1237    }
1238
1239    /// How long `op` takes on the tokio clock, paused once the stub's first
1240    /// request has opened the pooled connection: an unanswered request then
1241    /// advances virtual time straight to whichever timer fires first.
1242    async fn paused_elapsed<T>(op: impl std::future::Future<Output = T>) -> (T, Duration) {
1243        tokio::time::pause();
1244        let start = tokio::time::Instant::now();
1245        let out = op.await;
1246        let elapsed = start.elapsed();
1247        tokio::time::resume();
1248        (out, elapsed)
1249    }
1250
1251    /// `elapsed` is `budget`, give or take the tokio timer's 1 ms resolution.
1252    fn assert_budget(elapsed: Duration, budget: Duration) {
1253        assert!(
1254            elapsed >= budget && elapsed <= budget + Duration::from_millis(2),
1255            "took {elapsed:?}, budget {budget:?}"
1256        );
1257    }
1258
1259    /// The stub's request count once it reaches `at_least`, or after 2 s.
1260    ///
1261    /// The stub records a request on its own thread; a stalled request may
1262    /// time out on the paused clock before that thread has read it.
1263    #[cfg(feature = "reliability")]
1264    fn recorded(stub: &Stub, at_least: usize) -> usize {
1265        let deadline = std::time::Instant::now() + Duration::from_secs(2);
1266        loop {
1267            let n = stub.requests.lock().expect("lock").len();
1268            if n >= at_least || std::time::Instant::now() >= deadline {
1269                return n;
1270            }
1271            std::thread::sleep(Duration::from_millis(5));
1272        }
1273    }
1274
1275    /// Answers the first request (to pool a connection), stalls every later one.
1276    fn first_only(n: usize) -> Option<&'static [u8]> {
1277        (n == 0).then_some(OK)
1278    }
1279
1280    #[tokio::test]
1281    async fn an_unanswered_read_times_out_after_5_s() {
1282        let stub = stub_with(first_only);
1283        let backend = backend_at(&stub);
1284        backend
1285            .exists("warm")
1286            .await
1287            .expect("first request pools a connection");
1288
1289        let (result, elapsed) = paused_elapsed(backend.get("k")).await;
1290
1291        let err = result.expect_err("an unanswered GET times out");
1292        assert_eq!(err.kind, BackendErrorKind::Timeout);
1293        assert_budget(elapsed, READ_TIMEOUT);
1294    }
1295
1296    #[tokio::test]
1297    async fn an_unanswered_write_times_out_after_10_s() {
1298        let stub = stub_with(first_only);
1299        let backend = backend_at(&stub);
1300        backend
1301            .exists("warm")
1302            .await
1303            .expect("first request pools a connection");
1304
1305        let (result, elapsed) = paused_elapsed(backend.set("k", b"v".to_vec(), None)).await;
1306
1307        let err = result.expect_err("an unanswered PUT times out");
1308        assert_eq!(err.kind, BackendErrorKind::Timeout);
1309        assert_budget(elapsed, WRITE_TIMEOUT);
1310    }
1311
1312    #[cfg(feature = "reliability")]
1313    mod through_the_reliability_stack {
1314        use super::*;
1315        use crate::client::SharedBackend;
1316        use crate::reliability::{
1317            wrap_reliable, ReliabilityConfig, RetryConfig, CACHEKITIO_DEADLINES,
1318        };
1319
1320        /// The default stack with jitter off, so virtual time is exact.
1321        fn reliable(backend: CachekitIO) -> SharedBackend {
1322            reliable_with_breaker(backend).0
1323        }
1324
1325        fn reliable_with_breaker(
1326            backend: CachekitIO,
1327        ) -> (
1328            SharedBackend,
1329            Option<Arc<crate::reliability::CircuitBreaker>>,
1330        ) {
1331            #[cfg(not(feature = "unsync"))]
1332            let shared: SharedBackend = Arc::new(backend);
1333            #[cfg(feature = "unsync")]
1334            let shared: SharedBackend = std::rc::Rc::new(backend);
1335            let config = ReliabilityConfig {
1336                retry: Some(RetryConfig {
1337                    jitter: false,
1338                    ..RetryConfig::default()
1339                }),
1340                ..ReliabilityConfig::default()
1341            };
1342            wrap_reliable(shared, config, Some(CACHEKITIO_DEADLINES))
1343        }
1344
1345        const QUOTA_DENY: &[u8] = b"HTTP/1.1 429 Too Many Requests\r\n\
1346            x-cachekit-deny-reason: quota\r\ncontent-length: 0\r\n\r\n";
1347        const RATE_LIMITED: &[u8] = b"HTTP/1.1 429 Too Many Requests\r\n\
1348            retry-after: 1\r\ncontent-length: 0\r\n\r\n";
1349
1350        /// Was 3 x 30 s + backoff = 90.3 s and 3 requests.
1351        #[tokio::test]
1352        async fn an_unanswered_get_costs_one_attempt_and_5_s() {
1353            let stub = stub_with(first_only);
1354            let backend = reliable(backend_at(&stub));
1355            backend
1356                .exists("warm")
1357                .await
1358                .expect("first request pools a connection");
1359
1360            let (result, elapsed) = paused_elapsed(backend.get("k")).await;
1361
1362            assert_eq!(result.expect_err("stalled").kind, BackendErrorKind::Timeout);
1363            assert_budget(elapsed, Duration::from_secs(5));
1364            assert_eq!(recorded(&stub, 2), 2, "warm-up + one GET");
1365        }
1366
1367        #[tokio::test]
1368        async fn a_quota_denied_exists_is_sent_once() {
1369            let stub = stub_with(|_| Some(QUOTA_DENY));
1370            let backend = reliable(backend_at(&stub));
1371
1372            let err = backend.exists("k").await.expect_err("denied");
1373
1374            assert_eq!(err.kind, BackendErrorKind::Transient);
1375            assert!(err.is_quota_denied());
1376            assert_eq!(recorded(&stub, 1), 1, "no in-call retry");
1377        }
1378
1379        #[tokio::test]
1380        async fn a_quota_deny_is_sent_once_and_stays_transient() {
1381            let stub = stub_with(|_| Some(QUOTA_DENY));
1382            let backend = reliable(backend_at(&stub));
1383
1384            let err = backend.get("k").await.expect_err("denied");
1385
1386            assert_eq!(
1387                err.kind,
1388                BackendErrorKind::Transient,
1389                "fails open, counts toward the breaker"
1390            );
1391            assert!(err.is_quota_denied());
1392            assert_eq!(
1393                stub.requests.lock().expect("lock").len(),
1394                1,
1395                "no in-call retry"
1396            );
1397        }
1398
1399        #[tokio::test]
1400        async fn quota_denies_still_open_the_breaker() {
1401            let stub = stub_with(|_| Some(QUOTA_DENY));
1402            let (backend, breaker) = reliable_with_breaker(backend_at(&stub));
1403
1404            for _ in 0..5 {
1405                backend.get("k").await.expect_err("denied");
1406            }
1407
1408            let breaker = breaker.expect("default stack has a breaker");
1409            assert_eq!(breaker.state(), crate::reliability::CircuitState::Open);
1410            assert_eq!(
1411                stub.requests.lock().expect("lock").len(),
1412                5,
1413                "one request per op"
1414            );
1415        }
1416
1417        #[tokio::test]
1418        async fn a_rate_limit_429_still_retries() {
1419            let stub = stub_with(|_| Some(RATE_LIMITED));
1420            let backend = reliable(backend_at(&stub));
1421
1422            let err = backend.get("k").await.expect_err("rate limited");
1423
1424            assert_eq!(err.kind, BackendErrorKind::Transient);
1425            assert!(!err.is_quota_denied());
1426            assert_eq!(
1427                stub.requests.lock().expect("lock").len(),
1428                3,
1429                "every attempt is sent"
1430            );
1431        }
1432    }
1433}