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
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 #[cfg(not(target_arch = "wasm32"))]
41 read_timeout: Duration,
42 #[cfg(not(target_arch = "wasm32"))]
45 write_timeout: Duration,
46}
47
48impl 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 pub fn builder() -> CachekitIOBuilder {
61 CachekitIOBuilder::default()
62 }
63
64 pub fn api_url(&self) -> &str {
66 &self.api_url
67 }
68
69 pub(crate) fn client(&self) -> &reqwest::Client {
71 &self.client
72 }
73
74 pub(crate) fn api_key_str(&self) -> &str {
76 self.api_key.as_str()
77 }
78
79 fn url(&self, key: &str) -> Result<String, BackendError> {
85 Ok(format!("{}/v1/cache/{}", self.api_url, encode_key(key)?))
86 }
87
88 pub(crate) fn ttl_url(&self, key: &str) -> Result<String, BackendError> {
94 Ok(format!("{}/ttl", self.url(key)?))
95 }
96
97 pub(crate) fn lock_url(&self, key: &str) -> Result<String, BackendError> {
102 Ok(format!("{}/lock", self.url(key)?))
103 }
104
105 fn health_url(&self) -> String {
107 format!("{}/v1/cache/health", self.api_url)
108 }
109
110 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 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 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
167pub(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
183pub(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#[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#[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#[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 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 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 _ => 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
361fn 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
401fn 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
409fn 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#[cfg(not(target_arch = "wasm32"))]
423const USER_AGENT: &str = concat!("cachekit-rs/", env!("CARGO_PKG_VERSION"));
424
425#[cfg_attr(target_arch = "wasm32", allow(dead_code))] const DENY_REASON_HEADER: &str = "x-cachekit-deny-reason";
430
431#[cfg(not(target_arch = "wasm32"))]
437const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(390);
438
439#[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#[cfg(not(target_arch = "wasm32"))]
454fn http_client() -> reqwest::Result<reqwest::Client> {
455 http_client_builder().build()
456}
457
458#[cfg(target_arch = "wasm32")]
463fn http_client() -> reqwest::Result<reqwest::Client> {
464 reqwest::Client::builder().build()
465}
466
467#[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 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 pub fn api_url(mut self, url: impl Into<String>) -> Self {
490 self.api_url = Some(url.into());
491 self
492 }
493
494 pub fn allow_custom_host(mut self, allow: bool) -> Self {
496 self.allow_custom_host = allow;
497 self
498 }
499
500 pub fn metrics_provider(mut self, provider: MetricsProvider) -> Self {
506 self.metrics_provider = Some(provider);
507 self
508 }
509
510 #[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 #[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 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#[cfg(all(test, not(target_arch = "wasm32")))]
607#[allow(clippy::expect_used)] mod 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 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 const FIXTURE: &str = include_str!("../../tests/vectors/freshness-headers.json");
652
653 const FIXTURE_SHA256: &str = "74beb975b6855f52d9dd453279873faf8048a9c3c79a8167cfda099beec5fcd3"; #[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 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 #[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#[cfg(test)]
767#[allow(clippy::expect_used)] mod 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#[cfg(test)]
825#[allow(clippy::expect_used)] mod 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 #[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 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 #[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 #[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#[cfg(test)]
936#[allow(clippy::expect_used)] mod 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#[cfg(test)]
976#[allow(clippy::expect_used)] mod 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 #[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 #[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#[cfg(all(test, not(target_arch = "wasm32")))]
1108#[allow(clippy::expect_used)] mod 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 fn client() -> reqwest::Client {
1124 http_client_builder()
1125 .no_proxy()
1126 .build()
1127 .expect("client builds")
1128 }
1129
1130 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 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 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 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 fn backend_at(stub: &Stub) -> CachekitIO {
1286 backend_built_at(stub, CachekitIO::builder())
1287 }
1288
1289 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 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 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 #[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 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 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 #[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 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 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 #[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 #[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 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}