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