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