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