1use std::collections::HashMap;
2use std::sync::OnceLock;
3use std::time::Duration;
4
5use async_trait::async_trait;
6use bytes::Bytes;
7use reqwest::header::{HeaderName, HeaderValue, InvalidHeaderValue};
8use zeroize::Zeroizing;
9
10use crate::backend::{
11 delete_succeeded, encode_key, ttl_header, Backend, Freshness, HealthStatus, LockableBackend,
12};
13use crate::error::{BackendError, BackendErrorKind};
14use crate::metrics::{metrics_headers_named, MetricsProvider, METRICS_HEADER_NAMES};
15use crate::session::session_headers;
16use crate::url_validator::cachekitio_base_url;
17
18pub struct CachekitIO {
22 client: reqwest::Client,
23 api_key: Zeroizing<String>,
24 api_url: String,
25 static_headers: [(HeaderName, HeaderValue); 3],
30 metrics_header_names: [HeaderName; 5],
32 metrics_provider: OnceLock<MetricsProvider>,
36}
37
38impl std::fmt::Debug for CachekitIO {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 f.debug_struct("CachekitIO")
42 .field("api_url", &self.api_url)
43 .field("api_key", &"<redacted>")
44 .finish()
45 }
46}
47
48impl CachekitIO {
49 pub fn builder() -> CachekitIOBuilder {
51 CachekitIOBuilder::default()
52 }
53
54 pub fn api_url(&self) -> &str {
56 &self.api_url
57 }
58
59 pub(crate) fn client(&self) -> &reqwest::Client {
61 &self.client
62 }
63
64 pub(crate) fn api_key_str(&self) -> &str {
66 self.api_key.as_str()
67 }
68
69 fn url(&self, key: &str) -> Result<String, BackendError> {
75 Ok(format!("{}/v1/cache/{}", self.api_url, encode_key(key)?))
76 }
77
78 pub(crate) fn ttl_url(&self, key: &str) -> Result<String, BackendError> {
84 Ok(format!("{}/ttl", self.url(key)?))
85 }
86
87 pub(crate) fn lock_url(&self, key: &str) -> Result<String, BackendError> {
92 Ok(format!("{}/lock", self.url(key)?))
93 }
94
95 fn health_url(&self) -> String {
97 format!("{}/v1/cache/health", self.api_url)
98 }
99
100 pub(crate) fn with_standard_headers(
102 &self,
103 mut req: reqwest::RequestBuilder,
104 ) -> reqwest::RequestBuilder {
105 for (name, value) in &self.static_headers {
106 req = req.header(name.clone(), value.clone());
107 }
108 for (name, value) in
109 metrics_headers_named(self.metrics_provider.get(), &self.metrics_header_names)
110 {
111 req = req.header(name, value);
112 }
113 req
114 }
115
116 fn put_request(
119 &self,
120 key: &str,
121 value: Vec<u8>,
122 ttl: Option<Duration>,
123 ) -> Result<reqwest::RequestBuilder, BackendError> {
124 let mut req = self
125 .client
126 .put(self.url(key)?)
127 .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
128 .body(value);
129
130 if let Some(ttl) = ttl {
131 let (name, value) = ttl_header(ttl);
132 req = req.header(name, value);
133 }
134
135 Ok(self.with_standard_headers(req))
136 }
137
138 pub(crate) async fn error_from_response(&self, resp: reqwest::Response) -> BackendError {
140 let status = resp.status().as_u16();
141 let quota_denied = status == 429 && resp.headers().contains_key(DENY_REASON_HEADER);
142 let body = resp.bytes().await.unwrap_or_default();
143 let err = from_http_status_sanitized(status, &body, self.api_key.as_str());
144 if quota_denied {
145 err.with_quota_denied()
146 } else {
147 err
148 }
149 }
150}
151
152pub(crate) fn reqwest_err_sanitized(e: reqwest::Error, api_key: &str) -> BackendError {
156 let kind = if e.is_timeout() {
157 BackendErrorKind::Timeout
158 } else {
159 BackendErrorKind::Transient
160 };
161 BackendError {
162 kind,
163 message: BackendError::sanitize_message(&e.to_string(), api_key),
164 source: Some(Box::new(e)),
165 }
166}
167
168pub(crate) fn from_http_status_sanitized(status: u16, body: &[u8], api_key: &str) -> BackendError {
170 let sanitized =
171 BackendError::sanitize_message(std::str::from_utf8(body).unwrap_or(""), api_key);
172 BackendError::from_http_status(status, sanitized.as_bytes())
173}
174
175#[cfg(not(target_arch = "wasm32"))]
186pub(crate) fn freshness_from_headers(headers: &reqwest::header::HeaderMap) -> Freshness {
187 let is_stale = headers
188 .get_all("x-cachekit-freshness")
189 .iter()
190 .any(|value| value != "fresh");
191 let mut fresh_for = headers.get_all("x-cachekit-fresh-for").iter();
192 let fresh_for = match (fresh_for.next(), fresh_for.next()) {
193 (None, _) => None,
194 (Some(value), None) => Some(parse_fresh_for(value.as_bytes())),
195 (Some(_), Some(_)) => Some(Duration::ZERO),
196 };
197 Freshness {
198 is_stale,
199 fresh_for,
200 }
201}
202
203#[cfg(not(target_arch = "wasm32"))]
208fn parse_fresh_for(value: &[u8]) -> Duration {
209 const MAX_FRESH_FOR_SECS: u64 = 2_592_000;
210 if !(1..=7).contains(&value.len()) || !value.iter().all(u8::is_ascii_digit) {
211 return Duration::ZERO;
212 }
213 match std::str::from_utf8(value).map(str::parse::<u64>) {
214 Ok(Ok(secs)) if secs <= MAX_FRESH_FOR_SECS => Duration::from_secs(secs),
215 _ => Duration::ZERO,
216 }
217}
218
219#[cfg(not(target_arch = "wasm32"))]
222#[cfg_attr(not(feature = "unsync"), async_trait)]
223#[cfg_attr(feature = "unsync", async_trait(?Send))]
224impl Backend for CachekitIO {
225 fn attach_metrics(&self, provider: MetricsProvider) {
226 self.metrics_provider.get_or_init(|| provider);
228 }
229
230 async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, BackendError> {
231 Ok(self.get_with_freshness(key).await?.map(|(bytes, _)| bytes))
232 }
233
234 async fn get_with_freshness(
235 &self,
236 key: &str,
237 ) -> Result<Option<(Vec<u8>, Freshness)>, BackendError> {
238 let req = self.with_standard_headers(self.client.get(self.url(key)?));
239
240 let resp = req
241 .send()
242 .await
243 .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
244
245 match resp.status().as_u16() {
246 200 => {
247 let freshness = freshness_from_headers(resp.headers());
248 let bytes = resp
249 .bytes()
250 .await
251 .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
252 Ok(Some((Vec::from(bytes), freshness)))
253 }
254 404 => Ok(None),
255 _ => Err(self.error_from_response(resp).await),
256 }
257 }
258
259 async fn set(
260 &self,
261 key: &str,
262 value: Vec<u8>,
263 ttl: Option<Duration>,
264 ) -> Result<(), BackendError> {
265 let resp = self
266 .put_request(key, value, ttl)?
267 .timeout(WRITE_TIMEOUT)
268 .send()
269 .await
270 .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
271
272 let status = resp.status().as_u16();
273 if (200..300).contains(&status) {
274 Ok(())
275 } else {
276 Err(self.error_from_response(resp).await)
277 }
278 }
279
280 async fn delete(&self, key: &str) -> Result<bool, BackendError> {
283 let req = self.with_standard_headers(self.client.delete(self.url(key)?));
284
285 let resp = req
286 .timeout(WRITE_TIMEOUT)
287 .send()
288 .await
289 .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
290
291 if delete_succeeded(resp.status().as_u16()) {
292 Ok(true)
293 } else {
294 Err(self.error_from_response(resp).await)
295 }
296 }
297
298 async fn exists(&self, key: &str) -> Result<bool, BackendError> {
299 let req = self.with_standard_headers(self.client.head(self.url(key)?));
300
301 let resp = req
302 .send()
303 .await
304 .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
305
306 match resp.status().as_u16() {
307 200 => Ok(true),
308 404 => Ok(false),
309 _ => Err(self.error_from_response(resp).await),
311 }
312 }
313
314 async fn health(&self) -> Result<HealthStatus, BackendError> {
315 let start = std::time::Instant::now();
316
317 let req = self.with_standard_headers(self.client.get(self.health_url()));
318
319 let resp = req
320 .send()
321 .await
322 .map_err(|e| reqwest_err_sanitized(e, self.api_key.as_str()))?;
323
324 let latency = start.elapsed();
325 let status = resp.status().as_u16();
326
327 if (200..300).contains(&status) {
328 let mut details = HashMap::new();
329 details.insert("http_status".to_string(), status.to_string());
330 Ok(HealthStatus {
331 is_healthy: true,
332 latency_ms: latency.as_secs_f64() * 1000.0,
333 backend_type: "cachekitio".to_string(),
334 details,
335 })
336 } else {
337 Err(self.error_from_response(resp).await)
338 }
339 }
340
341 fn as_lockable(&self) -> Option<&dyn LockableBackend> {
342 Some(self)
343 }
344}
345
346fn static_headers(
358 api_key: &str,
359) -> Result<[(HeaderName, HeaderValue); 3], crate::error::CachekitError> {
360 use crate::error::CachekitError;
361
362 let mut bearer = Zeroizing::new(Vec::with_capacity("Bearer ".len() + api_key.len()));
363 bearer.extend_from_slice(b"Bearer ");
364 bearer.extend_from_slice(api_key.as_bytes());
365 let mut auth = header_over(bearer).map_err(|_| {
366 CachekitError::Config("api_key holds characters an HTTP header cannot carry".to_string())
367 })?;
368 auth.set_sensitive(true);
369
370 let [(id_name, id), (start_name, start)] = session_headers();
371 let header = |name: &str, value: &str| {
372 Ok::<_, CachekitError>((
373 HeaderName::from_bytes(name.as_bytes())
374 .map_err(|e| CachekitError::Config(format!("header name {name}: {e}")))?,
375 HeaderValue::from_str(value)
376 .map_err(|e| CachekitError::Config(format!("header {name}: {e}")))?,
377 ))
378 };
379 Ok([
380 (reqwest::header::AUTHORIZATION, auth),
381 header(id_name, id)?,
382 header(start_name, start)?,
383 ])
384}
385
386fn header_over<T: AsRef<[u8]> + Send + 'static>(
389 owner: T,
390) -> Result<HeaderValue, InvalidHeaderValue> {
391 HeaderValue::from_maybe_shared(Bytes::from_owner(owner))
392}
393
394fn metrics_header_names() -> Result<[HeaderName; 5], crate::error::CachekitError> {
396 let [a, b, c, d, e] = METRICS_HEADER_NAMES.map(|name| {
397 HeaderName::from_bytes(name.as_bytes())
398 .map_err(|e| crate::error::CachekitError::Config(format!("header name {name}: {e}")))
399 });
400 Ok([a?, b?, c?, d?, e?])
401}
402
403#[cfg(not(target_arch = "wasm32"))]
408const USER_AGENT: &str = concat!("cachekit-rs/", env!("CARGO_PKG_VERSION"));
409
410#[cfg(not(target_arch = "wasm32"))]
421const READ_TIMEOUT: Duration = Duration::from_secs(5);
422
423#[cfg(not(target_arch = "wasm32"))]
427const WRITE_TIMEOUT: Duration = Duration::from_secs(10);
428
429#[cfg_attr(target_arch = "wasm32", allow(dead_code))] const DENY_REASON_HEADER: &str = "x-cachekit-deny-reason";
434
435#[cfg(not(target_arch = "wasm32"))]
436const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(390);
437
438#[cfg(not(target_arch = "wasm32"))]
441fn http_client_builder() -> reqwest::ClientBuilder {
442 reqwest::Client::builder()
443 .use_rustls_tls()
444 .user_agent(USER_AGENT)
445 .redirect(reqwest::redirect::Policy::none())
446 .timeout(READ_TIMEOUT)
447 .connect_timeout(READ_TIMEOUT)
448 .pool_idle_timeout(POOL_IDLE_TIMEOUT)
449}
450
451#[cfg(not(target_arch = "wasm32"))]
453fn http_client() -> reqwest::Result<reqwest::Client> {
454 http_client_builder().build()
455}
456
457#[cfg(target_arch = "wasm32")]
462fn http_client() -> reqwest::Result<reqwest::Client> {
463 reqwest::Client::builder().build()
464}
465
466#[derive(Default)]
468#[must_use]
469pub struct CachekitIOBuilder {
470 api_key: Option<Zeroizing<String>>,
471 api_url: Option<String>,
472 allow_custom_host: bool,
473 metrics_provider: Option<MetricsProvider>,
474}
475
476impl CachekitIOBuilder {
477 pub fn api_key(mut self, key: impl Into<String>) -> Self {
479 self.api_key = Some(Zeroizing::new(key.into()));
480 self
481 }
482
483 pub fn api_url(mut self, url: impl Into<String>) -> Self {
485 self.api_url = Some(url.into());
486 self
487 }
488
489 pub fn allow_custom_host(mut self, allow: bool) -> Self {
491 self.allow_custom_host = allow;
492 self
493 }
494
495 pub fn metrics_provider(mut self, provider: MetricsProvider) -> Self {
501 self.metrics_provider = Some(provider);
502 self
503 }
504
505 pub fn build(self) -> Result<CachekitIO, crate::error::CachekitError> {
517 use crate::error::CachekitError;
518
519 let api_key = self
520 .api_key
521 .filter(|k| !k.is_empty())
522 .ok_or_else(|| CachekitError::Config("api_key is required".to_string()))?;
523
524 let api_url = self
525 .api_url
526 .unwrap_or_else(|| "https://api.cachekit.io".to_string());
527
528 let api_url = cachekitio_base_url(&api_url, self.allow_custom_host)?;
529
530 let client = http_client()
531 .map_err(|e| CachekitError::Config(format!("failed to build HTTP client: {e}")))?;
532
533 let static_headers = static_headers(&api_key)?;
534 let metrics_header_names = metrics_header_names()?;
535
536 Ok(CachekitIO {
537 client,
538 api_key,
539 api_url,
540 static_headers,
541 metrics_header_names,
542 metrics_provider: self
543 .metrics_provider
544 .map_or_else(OnceLock::new, OnceLock::from),
545 })
546 }
547}
548
549#[cfg(all(test, not(target_arch = "wasm32")))]
552#[allow(clippy::expect_used)] mod freshness_header_tests {
554 use std::time::Duration;
555
556 use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
557
558 use super::freshness_from_headers;
559
560 fn headers(pairs: &[(&str, &[u8])]) -> HeaderMap {
563 let mut map = HeaderMap::new();
564 for (name, value) in pairs {
565 map.append(
566 HeaderName::from_bytes(name.as_bytes()).expect("valid header name"),
567 HeaderValue::from_bytes(value).expect("valid header value"),
568 );
569 }
570 map
571 }
572
573 fn label(values: &[&[u8]]) -> bool {
574 let pairs: Vec<_> = values
575 .iter()
576 .map(|v| ("X-CacheKit-Freshness", *v))
577 .collect();
578 freshness_from_headers(&headers(&pairs)).is_stale
579 }
580
581 fn fresh_for(values: &[&[u8]]) -> Option<Duration> {
582 let pairs: Vec<_> = values
583 .iter()
584 .map(|v| ("X-CacheKit-Fresh-For", *v))
585 .collect();
586 freshness_from_headers(&headers(&pairs)).fresh_for
587 }
588
589 const FIXTURE: &str = include_str!("../../tests/vectors/freshness-headers.json");
597
598 const FIXTURE_SHA256: &str = "74beb975b6855f52d9dd453279873faf8048a9c3c79a8167cfda099beec5fcd3"; #[derive(serde::Deserialize)]
605 #[serde(deny_unknown_fields)]
606 struct Row<T> {
607 name: String,
608 value: Option<String>,
609 #[serde(alias = "stale", alias = "fresh_for")]
610 expected: T,
611 #[serde(rename = "note")]
612 _note: String,
613 }
614
615 #[derive(serde::Deserialize)]
616 struct Fixture {
617 freshness_vectors: Vec<Row<bool>>,
618 fresh_for_vectors: Vec<Row<Option<u64>>>,
619 }
620
621 fn row_headers(name: &str, value: Option<&str>) -> HeaderMap {
625 let Some(value) = value else {
626 return HeaderMap::new();
627 };
628 let bytes: Vec<u8> = value
629 .chars()
630 .map(|c| u8::try_from(u32::from(c)).expect("fixture value char above U+00FF"))
631 .collect();
632 headers(&[(name, &bytes)])
633 }
634
635 fn fixture() -> Fixture {
636 serde_json::from_str(FIXTURE)
637 .expect("vendored freshness-headers.json must match the fixture schema")
638 }
639
640 #[test]
641 fn vendored_fixture_matches_the_pinned_sha256() {
642 use sha2::{Digest, Sha256};
643 assert_eq!(
644 hex::encode(Sha256::digest(FIXTURE.as_bytes())),
645 FIXTURE_SHA256,
646 "tests/vectors/freshness-headers.json differs from the pinned protocol copy: \
647 re-vendor it from protocol and update FIXTURE_SHA256"
648 );
649 }
650
651 #[test]
652 fn fixture_freshness_rows_classify_as_specified() {
653 let rows = fixture().freshness_vectors;
654 assert!(!rows.is_empty(), "fixture has no freshness rows");
655 for row in &rows {
656 let map = row_headers("X-CacheKit-Freshness", row.value.as_deref());
657 assert_eq!(
658 freshness_from_headers(&map).is_stale,
659 row.expected,
660 "row {}",
661 row.name
662 );
663 }
664 }
665
666 #[test]
667 fn fixture_fresh_for_rows_read_as_specified() {
668 let rows = fixture().fresh_for_vectors;
669 assert!(!rows.is_empty(), "fixture has no fresh_for rows");
670 for row in &rows {
671 let map = row_headers("X-CacheKit-Fresh-For", row.value.as_deref());
672 assert_eq!(
673 freshness_from_headers(&map).fresh_for,
674 row.expected.map(Duration::from_secs),
675 "row {}",
676 row.name
677 );
678 }
679 }
680
681 #[test]
688 fn repeated_lines_are_fresh_only_when_every_copy_is_fresh() {
689 assert!(!label(&[b"fresh", b"fresh"]));
690 assert!(label(&[b"fresh", b"stale"]));
691 assert_eq!(
692 fresh_for(&[b"10", b"10"]),
693 Some(Duration::ZERO),
694 "a repeated bound counts as 0"
695 );
696 }
697
698 #[test]
699 fn edge_white_space_is_not_stripped() {
700 assert!(label(&[b"fresh "]), "`fresh ` must read as stale");
701 assert_eq!(
702 fresh_for(&[b" 5"]),
703 Some(Duration::ZERO),
704 "` 5` must count as 0"
705 );
706 }
707}
708
709#[cfg(test)]
712#[allow(clippy::expect_used)] mod metrics_wiring_tests {
714 use std::sync::Arc;
715
716 use super::CachekitIO;
717 use crate::backend::Backend;
718 use crate::metrics::{metrics_headers, L1Stats, MetricsProvider};
719
720 fn provider(l1_hits: u64) -> MetricsProvider {
721 Arc::new(move || {
722 Some(L1Stats {
723 l1_hits,
724 l2_hits: 0,
725 misses: 0,
726 l1_enabled: true,
727 })
728 })
729 }
730
731 fn l1_hits_header(backend: &CachekitIO) -> Option<String> {
732 metrics_headers(backend.metrics_provider.get())
733 .into_iter()
734 .find(|h| h.0 == "X-CacheKit-L1-Hits")
735 .map(|h| h.1)
736 }
737
738 #[test]
739 fn attach_fills_an_unset_provider() {
740 let backend = CachekitIO::builder()
741 .api_key("ck_test_key")
742 .build()
743 .expect("builder succeeds");
744 assert_eq!(l1_hits_header(&backend), None, "nothing attached yet");
745
746 backend.attach_metrics(provider(7));
747 assert_eq!(l1_hits_header(&backend).as_deref(), Some("7"));
748 }
749
750 #[test]
751 fn builder_provider_is_retained_over_attach() {
752 let backend = CachekitIO::builder()
753 .api_key("ck_test_key")
754 .metrics_provider(provider(1))
755 .build()
756 .expect("builder succeeds");
757
758 backend.attach_metrics(provider(99));
759 assert_eq!(
760 l1_hits_header(&backend).as_deref(),
761 Some("1"),
762 "user-supplied provider wins"
763 );
764 }
765}
766
767#[cfg(test)]
770#[allow(clippy::expect_used)] mod path_encoding_tests {
772 use super::CachekitIO;
773 use crate::backend::path_encoding_vectors::{reject_keys, transmittable, RS_NEAR_MISSES};
774 use url::Url;
775
776 const API: &str = "https://api.cachekit.io";
777
778 fn backend() -> CachekitIO {
779 CachekitIO::builder()
780 .api_url(API)
781 .api_key("ck_test_key")
782 .build()
783 .expect("builder should succeed for the canonical host")
784 }
785
786 #[test]
792 fn repro_raw_dot_key_escapes_the_cache_prefix() {
793 let cases = [
794 ("..", "", "/v1/"),
795 ("..", "/ttl", "/v1/ttl"),
796 ("..", "/lock", "/v1/lock"),
797 (".", "", "/v1/cache/"),
798 ];
799 for (key, suffix, escaped) in cases {
800 let raw = format!("{API}/v1/cache/{}{suffix}", urlencoding::encode(key));
801 let parsed = Url::parse(&raw).expect("parses");
802 assert_eq!(
803 parsed.path(),
804 escaped,
805 "raw {key:?}{suffix} should collapse to {escaped}"
806 );
807 let pct = key.replace('.', "%2E");
809 let enc = format!("{API}/v1/cache/{pct}{suffix}");
810 assert_eq!(
811 Url::parse(&enc).expect("parses").path(),
812 escaped,
813 "%2E-encoded {key:?}{suffix} also collapses — encoding cannot fix this in rust-url"
814 );
815 }
816 }
817
818 #[test]
822 fn reserved_segments_rejected_by_every_builder() {
823 let b = backend();
824 let keys = reject_keys();
825 assert!(!keys.is_empty(), "fixture has no reject rows");
826 for key in &keys {
827 assert!(b.url(key).is_err(), "url({key:?}) must be rejected");
828 assert!(b.ttl_url(key).is_err(), "ttl_url({key:?}) must be rejected");
829 assert!(
830 b.lock_url(key).is_err(),
831 "lock_url({key:?}) must be rejected"
832 );
833 }
834 }
835
836 #[test]
842 fn safe_keys_never_escape_the_cache_prefix() {
843 let b = backend();
844 let fixture_keys: Vec<String> = transmittable().into_iter().map(|v| v.key).collect();
845 let vectors = fixture_keys
846 .iter()
847 .map(String::as_str)
848 .chain(RS_NEAR_MISSES.iter().copied());
849 for key in vectors {
850 let base = Url::parse(&b.url(key).expect("url")).expect("parse base");
851 let ttl = Url::parse(&b.ttl_url(key).expect("ttl_url")).expect("parse ttl");
852 let lock = Url::parse(&b.lock_url(key).expect("lock_url")).expect("parse lock");
853
854 assert!(
855 base.path().starts_with("/v1/cache/") && base.path().len() > "/v1/cache/".len(),
856 "base path {} escaped prefix for {key:?}",
857 base.path()
858 );
859 assert_eq!(
860 base.path(),
861 format!("/v1/cache/{}", urlencoding::encode(key)),
862 "base wire path mismatch for {key:?}"
863 );
864 assert!(
865 ttl.path().starts_with("/v1/cache/") && ttl.path().ends_with("/ttl"),
866 "ttl path {} escaped prefix for {key:?}",
867 ttl.path()
868 );
869 assert!(
870 lock.path().starts_with("/v1/cache/") && lock.path().ends_with("/lock"),
871 "lock path {} escaped prefix for {key:?}",
872 lock.path()
873 );
874 }
875 }
876}
877
878#[cfg(test)]
881#[allow(clippy::expect_used)] mod put_ttl_header_tests {
883 use std::time::Duration;
884
885 use super::CachekitIO;
886
887 fn put(ttl: Option<Duration>) -> reqwest::Request {
888 CachekitIO::builder()
889 .api_key("ck_test_key")
890 .build()
891 .expect("builder succeeds")
892 .put_request("k", b"v".to_vec(), ttl)
893 .expect("key encodes")
894 .build()
895 .expect("request builds")
896 }
897
898 #[test]
899 fn put_sends_x_cachekit_ttl_and_no_legacy_x_ttl() {
900 let req = put(Some(Duration::from_secs(300)));
901 assert_eq!(
902 req.headers()
903 .get("X-CacheKit-TTL")
904 .and_then(|v| v.to_str().ok()),
905 Some("300")
906 );
907 assert!(req.headers().get("X-TTL").is_none(), "legacy header sent");
908 }
909
910 #[test]
911 fn put_without_ttl_sends_no_ttl_header() {
912 let req = put(None);
913 assert!(req.headers().get("X-CacheKit-TTL").is_none());
914 assert!(req.headers().get("X-TTL").is_none());
915 }
916}
917
918#[cfg(test)]
921#[allow(clippy::expect_used)] mod prebuilt_header_tests {
923 use std::sync::atomic::{AtomicBool, Ordering};
924 use std::sync::Arc;
925
926 use super::{header_over, CachekitIO};
927 use crate::metrics::{metrics_headers, L1Stats, MetricsProvider};
928 use crate::session::session_headers;
929
930 const KEY: &str = "ck_live_prebuilt_secret";
931
932 fn put(provider: Option<MetricsProvider>) -> reqwest::Request {
933 let mut builder = CachekitIO::builder().api_key(KEY);
934 if let Some(provider) = provider {
935 builder = builder.metrics_provider(provider);
936 }
937 builder
938 .build()
939 .expect("builder succeeds")
940 .put_request("k", b"v".to_vec(), None)
941 .expect("key encodes")
942 .build()
943 .expect("request builds")
944 }
945
946 #[test]
947 fn bearer_is_sent_and_redacted_from_debug() {
948 let req = put(None);
949 let auth = req
950 .headers()
951 .get(reqwest::header::AUTHORIZATION)
952 .expect("authorization header");
953 assert_eq!(auth.as_bytes(), format!("Bearer {KEY}").as_bytes());
954 assert!(auth.is_sensitive(), "prebuilt bearer must be sensitive");
955 let debug = format!("{req:?}");
956 assert!(!debug.contains(KEY), "api key leaked into Debug: {debug}");
957 }
958
959 #[test]
962 fn header_clones_share_the_owner_until_the_last_drops() {
963 struct Owner(Vec<u8>, Arc<AtomicBool>);
964 impl AsRef<[u8]> for Owner {
965 fn as_ref(&self) -> &[u8] {
966 &self.0
967 }
968 }
969 impl Drop for Owner {
970 fn drop(&mut self) {
971 self.1.store(true, Ordering::SeqCst);
972 }
973 }
974
975 let dropped = Arc::new(AtomicBool::new(false));
976 let header = header_over(Owner(b"Bearer k".to_vec(), Arc::clone(&dropped))).expect("valid");
977 let clone = header.clone();
978 drop(header);
979 assert!(
980 !dropped.load(Ordering::SeqCst),
981 "owner dropped while a clone lives"
982 );
983 assert_eq!(clone.as_bytes(), b"Bearer k");
984 drop(clone);
985 assert!(
986 dropped.load(Ordering::SeqCst),
987 "owner outlived its last clone"
988 );
989 }
990
991 #[test]
992 fn a_key_no_header_can_carry_fails_build_without_echoing_it() {
993 let err = CachekitIO::builder()
994 .api_key("ck_bad\nkey")
995 .build()
996 .expect_err("newline in key");
997 assert!(!err.to_string().contains("ck_bad"), "{err}");
998 }
999
1000 #[test]
1001 fn requests_go_to_the_validated_url_as_serialized() {
1002 let backend = CachekitIO::builder()
1003 .api_key("ck_test_key")
1004 .api_url("https://api.cachekit.io\\@evil.example")
1005 .build()
1006 .expect("allowlisted host");
1007 assert_eq!(backend.api_url(), "https://api.cachekit.io/@evil.example");
1008 }
1009
1010 #[test]
1013 fn session_and_metrics_headers_match_their_string_builders() {
1014 let provider: MetricsProvider = Arc::new(|| {
1015 Some(L1Stats {
1016 l1_hits: 3,
1017 l2_hits: 2,
1018 misses: 5,
1019 l1_enabled: true,
1020 })
1021 });
1022 let req = put(Some(provider.clone()));
1023 let expected = session_headers()
1024 .into_iter()
1025 .map(|(n, v)| (n, v.to_string()))
1026 .chain(metrics_headers(Some(&provider)));
1027 let mut count = 0;
1028 for (name, value) in expected {
1029 let got = req.headers().get_all(name).iter().collect::<Vec<_>>();
1030 assert_eq!(got.len(), 1, "{name} sent {} times", got.len());
1031 assert_eq!(got[0].as_bytes(), value.as_bytes(), "{name}");
1032 count += 1;
1033 }
1034 assert_eq!(count, 7);
1035 }
1036
1037 #[test]
1038 fn disabled_metrics_send_only_the_status_header() {
1039 let req = put(None);
1040 assert_eq!(
1041 req.headers()
1042 .get("X-CacheKit-L1-Status")
1043 .map(|v| v.as_bytes()),
1044 Some(&b"disabled"[..])
1045 );
1046 assert!(req.headers().get("X-CacheKit-L1-Hits").is_none());
1047 }
1048}
1049
1050#[cfg(all(test, not(target_arch = "wasm32")))]
1053#[allow(clippy::expect_used)] mod http_client_tests {
1055 use std::io::{BufRead, BufReader, Read, Write};
1056 use std::net::TcpListener;
1057 use std::sync::{Arc, Mutex};
1058 use std::time::Duration;
1059
1060 use super::{http_client_builder, CachekitIO, READ_TIMEOUT, WRITE_TIMEOUT};
1061 use crate::backend::Backend;
1062 use crate::error::BackendErrorKind;
1063
1064 fn client() -> reqwest::Client {
1068 http_client_builder()
1069 .no_proxy()
1070 .build()
1071 .expect("client builds")
1072 }
1073
1074 struct Stub {
1078 url: String,
1079 connections: Arc<Mutex<usize>>,
1080 requests: Arc<Mutex<Vec<Vec<String>>>>,
1081 bodies: Arc<Mutex<Vec<Vec<u8>>>>,
1082 }
1083
1084 const OK: &[u8] = b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n";
1085
1086 fn stub() -> Stub {
1087 stub_with(|_| Some(OK))
1088 }
1089
1090 fn stub_with(answer: fn(usize) -> Option<&'static [u8]>) -> Stub {
1093 let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub");
1094 let url = format!("http://{}/", listener.local_addr().expect("addr"));
1095 let connections = Arc::new(Mutex::new(0));
1096 let requests = Arc::new(Mutex::new(Vec::new()));
1097 let bodies = Arc::new(Mutex::new(Vec::new()));
1098 let (conns, reqs, bods) = (connections.clone(), requests.clone(), bodies.clone());
1099 std::thread::spawn(move || {
1100 for stream in listener.incoming() {
1101 let Ok(mut stream) = stream else { return };
1102 *conns.lock().expect("lock") += 1;
1103 let (reqs, bods) = (reqs.clone(), bods.clone());
1104 std::thread::spawn(move || {
1105 let mut reader = BufReader::new(stream.try_clone().expect("clone"));
1106 loop {
1107 let mut head = Vec::new();
1108 loop {
1109 let mut line = String::new();
1110 if reader.read_line(&mut line).unwrap_or(0) == 0 {
1111 return;
1112 }
1113 if line == "\r\n" {
1114 break;
1115 }
1116 head.push(line.trim_end().to_owned());
1117 }
1118 let len = head
1119 .iter()
1120 .find_map(|h| {
1121 let (name, value) = h.split_once(':')?;
1122 name.eq_ignore_ascii_case("content-length")
1123 .then(|| value.trim().parse::<usize>().ok())?
1124 })
1125 .unwrap_or(0);
1126 let mut body = vec![0; len];
1127 if reader.read_exact(&mut body).is_err() {
1128 return;
1129 }
1130 bods.lock().expect("lock").push(body);
1131 let n = {
1132 let mut reqs = reqs.lock().expect("lock");
1133 reqs.push(head);
1134 reqs.len() - 1
1135 };
1136 let Some(reply) = answer(n) else {
1137 loop {
1139 std::thread::park();
1140 }
1141 };
1142 if stream.write_all(reply).is_err() {
1143 return;
1144 }
1145 }
1146 });
1147 }
1148 });
1149 Stub {
1150 url,
1151 connections,
1152 requests,
1153 bodies,
1154 }
1155 }
1156
1157 async fn connections_after_idle(idle: Duration) -> usize {
1160 let stub = stub();
1161 let client = client();
1162 client.get(&stub.url).send().await.expect("first GET");
1163 tokio::time::pause();
1164 tokio::time::advance(idle).await;
1165 tokio::time::resume();
1166 client.get(&stub.url).send().await.expect("second GET");
1167 let n = *stub.connections.lock().expect("lock");
1168 n
1169 }
1170
1171 #[tokio::test]
1172 async fn refresh_ttl_sends_a_sub_second_ttl_as_one() {
1173 use crate::backend::TtlInspectable;
1174 let stub = stub();
1175 let backend = backend_at(&stub);
1176
1177 let refreshed = backend
1178 .refresh_ttl("k", Duration::from_millis(500))
1179 .await
1180 .expect("a sub-second TTL is sent, not rejected");
1181
1182 assert!(refreshed);
1183 let requests = stub.requests.lock().expect("lock");
1184 let head = &requests[0];
1185 assert!(head[0].starts_with("PATCH /v1/cache/k/ttl "), "{head:?}");
1186 assert_eq!(stub.bodies.lock().expect("lock")[0], br#"{"ttl":1}"#);
1187 }
1188
1189 #[tokio::test]
1190 async fn refresh_ttl_rejects_zero_before_sending() {
1191 use crate::backend::TtlInspectable;
1192 let stub = stub();
1193 let backend = backend_at(&stub);
1194
1195 let err = backend
1196 .refresh_ttl("k", Duration::ZERO)
1197 .await
1198 .expect_err("TTL 0 is an error");
1199
1200 assert_eq!(err.kind, BackendErrorKind::Permanent);
1201 assert!(stub.requests.lock().expect("lock").is_empty());
1202 }
1203
1204 #[tokio::test]
1205 async fn sends_cachekit_rs_user_agent() {
1206 let stub = stub();
1207 client().get(&stub.url).send().await.expect("GET");
1208 let want = format!("user-agent: cachekit-rs/{}", env!("CARGO_PKG_VERSION"));
1209 let requests = stub.requests.lock().expect("lock");
1210 assert!(
1211 requests[0].iter().any(|h| h.eq_ignore_ascii_case(&want)),
1212 "no `{want}` in {:?}",
1213 requests[0]
1214 );
1215 }
1216
1217 #[tokio::test]
1218 async fn reuses_a_connection_idle_389_s() {
1219 assert_eq!(connections_after_idle(Duration::from_secs(389)).await, 1);
1220 }
1221
1222 #[tokio::test]
1223 async fn redials_a_connection_idle_past_390_s() {
1224 assert_eq!(connections_after_idle(Duration::from_secs(391)).await, 2);
1225 }
1226
1227 fn backend_at(stub: &Stub) -> CachekitIO {
1230 let mut backend = CachekitIO::builder()
1231 .api_key("ck_test_key")
1232 .build()
1233 .expect("builder succeeds for the default host");
1234 backend.client = client();
1235 backend.api_url = stub.url.trim_end_matches('/').to_owned();
1236 backend
1237 }
1238
1239 async fn paused_elapsed<T>(op: impl std::future::Future<Output = T>) -> (T, Duration) {
1243 tokio::time::pause();
1244 let start = tokio::time::Instant::now();
1245 let out = op.await;
1246 let elapsed = start.elapsed();
1247 tokio::time::resume();
1248 (out, elapsed)
1249 }
1250
1251 fn assert_budget(elapsed: Duration, budget: Duration) {
1253 assert!(
1254 elapsed >= budget && elapsed <= budget + Duration::from_millis(2),
1255 "took {elapsed:?}, budget {budget:?}"
1256 );
1257 }
1258
1259 #[cfg(feature = "reliability")]
1264 fn recorded(stub: &Stub, at_least: usize) -> usize {
1265 let deadline = std::time::Instant::now() + Duration::from_secs(2);
1266 loop {
1267 let n = stub.requests.lock().expect("lock").len();
1268 if n >= at_least || std::time::Instant::now() >= deadline {
1269 return n;
1270 }
1271 std::thread::sleep(Duration::from_millis(5));
1272 }
1273 }
1274
1275 fn first_only(n: usize) -> Option<&'static [u8]> {
1277 (n == 0).then_some(OK)
1278 }
1279
1280 #[tokio::test]
1281 async fn an_unanswered_read_times_out_after_5_s() {
1282 let stub = stub_with(first_only);
1283 let backend = backend_at(&stub);
1284 backend
1285 .exists("warm")
1286 .await
1287 .expect("first request pools a connection");
1288
1289 let (result, elapsed) = paused_elapsed(backend.get("k")).await;
1290
1291 let err = result.expect_err("an unanswered GET times out");
1292 assert_eq!(err.kind, BackendErrorKind::Timeout);
1293 assert_budget(elapsed, READ_TIMEOUT);
1294 }
1295
1296 #[tokio::test]
1297 async fn an_unanswered_write_times_out_after_10_s() {
1298 let stub = stub_with(first_only);
1299 let backend = backend_at(&stub);
1300 backend
1301 .exists("warm")
1302 .await
1303 .expect("first request pools a connection");
1304
1305 let (result, elapsed) = paused_elapsed(backend.set("k", b"v".to_vec(), None)).await;
1306
1307 let err = result.expect_err("an unanswered PUT times out");
1308 assert_eq!(err.kind, BackendErrorKind::Timeout);
1309 assert_budget(elapsed, WRITE_TIMEOUT);
1310 }
1311
1312 #[cfg(feature = "reliability")]
1313 mod through_the_reliability_stack {
1314 use super::*;
1315 use crate::client::SharedBackend;
1316 use crate::reliability::{
1317 wrap_reliable, ReliabilityConfig, RetryConfig, CACHEKITIO_DEADLINES,
1318 };
1319
1320 fn reliable(backend: CachekitIO) -> SharedBackend {
1322 reliable_with_breaker(backend).0
1323 }
1324
1325 fn reliable_with_breaker(
1326 backend: CachekitIO,
1327 ) -> (
1328 SharedBackend,
1329 Option<Arc<crate::reliability::CircuitBreaker>>,
1330 ) {
1331 #[cfg(not(feature = "unsync"))]
1332 let shared: SharedBackend = Arc::new(backend);
1333 #[cfg(feature = "unsync")]
1334 let shared: SharedBackend = std::rc::Rc::new(backend);
1335 let config = ReliabilityConfig {
1336 retry: Some(RetryConfig {
1337 jitter: false,
1338 ..RetryConfig::default()
1339 }),
1340 ..ReliabilityConfig::default()
1341 };
1342 wrap_reliable(shared, config, Some(CACHEKITIO_DEADLINES))
1343 }
1344
1345 const QUOTA_DENY: &[u8] = b"HTTP/1.1 429 Too Many Requests\r\n\
1346 x-cachekit-deny-reason: quota\r\ncontent-length: 0\r\n\r\n";
1347 const RATE_LIMITED: &[u8] = b"HTTP/1.1 429 Too Many Requests\r\n\
1348 retry-after: 1\r\ncontent-length: 0\r\n\r\n";
1349
1350 #[tokio::test]
1352 async fn an_unanswered_get_costs_one_attempt_and_5_s() {
1353 let stub = stub_with(first_only);
1354 let backend = reliable(backend_at(&stub));
1355 backend
1356 .exists("warm")
1357 .await
1358 .expect("first request pools a connection");
1359
1360 let (result, elapsed) = paused_elapsed(backend.get("k")).await;
1361
1362 assert_eq!(result.expect_err("stalled").kind, BackendErrorKind::Timeout);
1363 assert_budget(elapsed, Duration::from_secs(5));
1364 assert_eq!(recorded(&stub, 2), 2, "warm-up + one GET");
1365 }
1366
1367 #[tokio::test]
1368 async fn a_quota_denied_exists_is_sent_once() {
1369 let stub = stub_with(|_| Some(QUOTA_DENY));
1370 let backend = reliable(backend_at(&stub));
1371
1372 let err = backend.exists("k").await.expect_err("denied");
1373
1374 assert_eq!(err.kind, BackendErrorKind::Transient);
1375 assert!(err.is_quota_denied());
1376 assert_eq!(recorded(&stub, 1), 1, "no in-call retry");
1377 }
1378
1379 #[tokio::test]
1380 async fn a_quota_deny_is_sent_once_and_stays_transient() {
1381 let stub = stub_with(|_| Some(QUOTA_DENY));
1382 let backend = reliable(backend_at(&stub));
1383
1384 let err = backend.get("k").await.expect_err("denied");
1385
1386 assert_eq!(
1387 err.kind,
1388 BackendErrorKind::Transient,
1389 "fails open, counts toward the breaker"
1390 );
1391 assert!(err.is_quota_denied());
1392 assert_eq!(
1393 stub.requests.lock().expect("lock").len(),
1394 1,
1395 "no in-call retry"
1396 );
1397 }
1398
1399 #[tokio::test]
1400 async fn quota_denies_still_open_the_breaker() {
1401 let stub = stub_with(|_| Some(QUOTA_DENY));
1402 let (backend, breaker) = reliable_with_breaker(backend_at(&stub));
1403
1404 for _ in 0..5 {
1405 backend.get("k").await.expect_err("denied");
1406 }
1407
1408 let breaker = breaker.expect("default stack has a breaker");
1409 assert_eq!(breaker.state(), crate::reliability::CircuitState::Open);
1410 assert_eq!(
1411 stub.requests.lock().expect("lock").len(),
1412 5,
1413 "one request per op"
1414 );
1415 }
1416
1417 #[tokio::test]
1418 async fn a_rate_limit_429_still_retries() {
1419 let stub = stub_with(|_| Some(RATE_LIMITED));
1420 let backend = reliable(backend_at(&stub));
1421
1422 let err = backend.get("k").await.expect_err("rate limited");
1423
1424 assert_eq!(err.kind, BackendErrorKind::Transient);
1425 assert!(!err.is_quota_denied());
1426 assert_eq!(
1427 stub.requests.lock().expect("lock").len(),
1428 3,
1429 "every attempt is sent"
1430 );
1431 }
1432 }
1433}