• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jzombie / rust-reqwest-drive / 23169651656

16 Mar 2026 10:51PM UTC coverage: 82.742%. First build
23169651656

Pull #10

github

web-flow
Merge 26ce5915f into 062de37c6
Pull Request #10: Throttle-only store bypass; optional auto-process-managed stores; bump deps

396 of 485 new or added lines in 3 files covered. (81.65%)

513 of 620 relevant lines covered (82.74%)

9635.22 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

81.78
/src/cache_middleware.rs
1
use async_trait::async_trait;
2
// Binary serialization
3
use bitcode::{Decode, Encode};
4
use bytes::Bytes;
5
use cache_manager::{CacheRoot, ProcessScopedCacheGroup};
6
use chrono::{DateTime, Utc};
7
use http::{Extensions, HeaderMap, HeaderValue, StatusCode};
8
use reqwest::{Request, Response};
9
use reqwest_middleware::{Middleware, Next, Result};
10
use simd_r_drive::traits::{DataStoreReader, DataStoreWriter};
11
use simd_r_drive::{DataStore, compute_hash};
12
use std::io;
13
use std::path::Path;
14
use std::sync::Arc;
15
use std::time::{Duration, SystemTime, UNIX_EPOCH}; // For parsing `Expires` headers
16

17
/// Per-request control for bypassing cache behavior.
18
///
19
/// When set to `CacheBypass(true)` in request extensions, the cache middleware
20
/// will skip both cache reads and cache writes for that request.
21
///
22
/// This is useful when you want a one-off fresh fetch while still reusing the
23
/// same client, cache store, and throttle middleware stack.
24
///
25
/// # Example
26
///
27
/// ```rust
28
/// use reqwest_drive::{CacheBypass, CachePolicy, ThrottlePolicy, init_cache_with_throttle};
29
/// use reqwest_middleware::ClientBuilder;
30
/// use tempfile::tempdir;
31
///
32
/// # #[tokio::main]
33
/// # async fn main() {
34
/// let temp_dir = tempdir().unwrap();
35
/// let cache_path = temp_dir.path().join("cache_storage.bin");
36
///
37
/// let (cache, throttle) = init_cache_with_throttle(
38
///     &cache_path,
39
///     CachePolicy::default(),
40
///     ThrottlePolicy::default(),
41
/// );
42
///
43
/// let client = ClientBuilder::new(reqwest::Client::new())
44
///     .with_arc(cache)
45
///     .with_arc(throttle)
46
///     .build();
47
///
48
/// let mut request = client.get("https://example.com");
49
/// request.extensions().insert(CacheBypass(true));
50
/// let _ = request.send().await;
51
/// # }
52
/// ```
53
#[derive(Clone, Copy, Debug, Default)]
54
pub struct CacheBypass(pub bool);
55

56
/// Per-request control for busting and refreshing cache behavior.
57
///
58
/// When set to `CacheBust(true)` in request extensions, the cache middleware
59
/// skips cache reads for that request, forces a fresh network fetch, and then
60
/// writes the new response back to cache (subject to `CachePolicy`).
61
///
62
/// This is useful when you want to refresh a stale entry and make future
63
/// non-busted requests use the updated cached response.
64
///
65
/// # Example
66
///
67
/// ```rust
68
/// use reqwest_drive::{CacheBust, CachePolicy, ThrottlePolicy, init_cache_with_throttle};
69
/// use reqwest_middleware::ClientBuilder;
70
/// use tempfile::tempdir;
71
///
72
/// # #[tokio::main]
73
/// # async fn main() {
74
/// let temp_dir = tempdir().unwrap();
75
/// let cache_path = temp_dir.path().join("cache_storage.bin");
76
///
77
/// let (cache, throttle) = init_cache_with_throttle(
78
///     &cache_path,
79
///     CachePolicy::default(),
80
///     ThrottlePolicy::default(),
81
/// );
82
///
83
/// let client = ClientBuilder::new(reqwest::Client::new())
84
///     .with_arc(cache)
85
///     .with_arc(throttle)
86
///     .build();
87
///
88
/// let mut request = client.get("https://example.com");
89
/// request.extensions().insert(CacheBust(true));
90
/// let _ = request.send().await;
91
/// # }
92
/// ```
93
#[derive(Clone, Copy, Debug, Default)]
94
pub struct CacheBust(pub bool);
95

96
/// Defines the caching policy for storing and retrieving responses.
97
#[derive(Clone, Debug)]
98
pub struct CachePolicy {
99
    /// Defines the caching policy for storing and retrieving responses.
100
    pub default_ttl: Duration,
101
    /// Determines whether cache expiration should respect HTTP headers.
102
    pub respect_headers: bool,
103
    /// Optional override for caching specific HTTP status codes.
104
    /// - If `None`, only success responses (`2xx`) are cached.
105
    /// - If `Some(Vec<u16>)`, only the specified status codes are cached.
106
    pub cache_status_override: Option<Vec<u16>>,
107
}
108

109
impl Default for CachePolicy {
110
    fn default() -> Self {
17✔
111
        Self {
17✔
112
            default_ttl: Duration::from_secs(60 * 60 * 24), // Default 1 day TTL
17✔
113
            respect_headers: true,                          // Use headers if available
17✔
114
            cache_status_override: None, // Default behavior: Cache only 2xx responses
17✔
115
        }
17✔
116
    }
17✔
117
}
118

119
/// Represents a cached HTTP response.
NEW
120
#[derive(Encode, Decode)]
×
121
struct CachedResponse {
122
    /// HTTP status code of the cached response.
123
    status: u16,
124
    /// HTTP headers stored as key-value pairs, where values are raw bytes.
125
    headers: Vec<(String, Vec<u8>)>,
126
    /// Response body stored as raw bytes.
127
    body: Vec<u8>,
128
    /// Unix timestamp (in milliseconds) indicating when the cache entry expires.
129
    expiration_timestamp: u64,
130
}
131

132
/// Provides an HTTP cache layer backed by a `SIMD R Drive` data store.
133
///
134
/// ## Concurrency model
135
///
136
/// - Thread-safe for concurrent access within a single process.
137
/// - Not multi-process safe for concurrent access to the same backing file.
138
///
139
/// If multiple processes need caching, use process-level coordination
140
/// (e.g., external locking/ownership) or separate cache files per process.
141
#[derive(Clone)]
142
pub struct DriveCache {
143
    store: Arc<DataStore>,
144
    policy: CachePolicy, // Configurable policy
145
    _process_scoped_group: Option<Arc<ProcessScopedCacheGroup>>,
146
}
147

148
impl DriveCache {
149
    /// Creates a new cache backed by a file-based data store.
150
    ///
151
    /// # Arguments
152
    ///
153
    /// * `cache_storage_file` - Path to the file where cached responses are stored.
154
    /// * `policy` - Configuration specifying cache expiration behavior.
155
    ///
156
    /// # Concurrency
157
    ///
158
    /// The cache is thread-safe within a process, but the backing file should
159
    /// not be shared for concurrent reads/writes across multiple processes.
160
    ///
161
    /// # Panics
162
    ///
163
    /// This function will panic if the `DataStore` fails to initialize.
164
    pub fn new(cache_storage_file: &Path, policy: CachePolicy) -> Self {
19✔
165
        Self {
19✔
166
            store: Arc::new(DataStore::open(cache_storage_file).unwrap()),
19✔
167
            policy,
19✔
168
            _process_scoped_group: None,
19✔
169
        }
19✔
170
    }
19✔
171

172
    /// Creates a new cache using discovered `.cache` root and a process-scoped storage bin.
173
    ///
174
    /// The cache group is derived from this crate name (`reqwest-drive`), and the entry
175
    /// file is created under a process/thread scoped subdirectory so callers do not need
176
    /// to manually provide a cache path.
177
    ///
178
    /// # Errors
179
    ///
180
    /// Returns an error if discovery or process-scoped directory/file initialization fails.
181
    pub fn new_process_scoped(policy: CachePolicy) -> io::Result<Self> {
2✔
182
        let cache_root = CacheRoot::from_discovery()?;
2✔
183
        let scoped_group = Arc::new(ProcessScopedCacheGroup::new(
2✔
184
            &cache_root,
2✔
185
            env!("CARGO_PKG_NAME"),
NEW
186
        )?);
×
187
        let cache_storage_file = scoped_group.touch_thread_entry("cache_storage.bin")?;
2✔
188
        let store = DataStore::open(&cache_storage_file).map_err(|err| {
2✔
NEW
189
            io::Error::other(format!(
×
190
                "failed to open DataStore at {}: {err}",
NEW
191
                cache_storage_file.display()
×
192
            ))
NEW
193
        })?;
×
194

195
        Ok(Self {
2✔
196
            store: Arc::new(store),
2✔
197
            policy,
2✔
198
            _process_scoped_group: Some(scoped_group),
2✔
199
        })
2✔
200
    }
2✔
201

202
    /// Creates a new cache using an existing `Arc<DataStore>`.
203
    ///
204
    /// This allows sharing the cache store across multiple components.
205
    ///
206
    /// # Arguments
207
    ///
208
    /// * `store` - A shared `Arc<DataStore>` instance.
209
    /// * `policy` - Cache expiration configuration.
210
    ///
211
    /// # Concurrency
212
    ///
213
    /// This is thread-safe within a process. Avoid concurrent multi-process
214
    /// access to the same underlying store/file.
215
    pub fn with_drive_arc(store: Arc<DataStore>, policy: CachePolicy) -> Self {
2✔
216
        Self {
2✔
217
            store,
2✔
218
            policy,
2✔
219
            _process_scoped_group: None,
2✔
220
        }
2✔
221
    }
2✔
222

223
    /// Checks whether a request is cached and still valid.
224
    ///
225
    /// This method retrieves the cache entry associated with the request
226
    /// and determines if it is still within its valid TTL.
227
    ///
228
    /// # Arguments
229
    ///
230
    /// * `req` - The HTTP request to check for a cached response.
231
    ///
232
    /// # Returns
233
    ///
234
    /// Returns `true` if the request has a valid cached response; otherwise, `false`.
235
    pub async fn is_cached(&self, req: &Request) -> bool {
63✔
236
        let store = self.store.as_ref();
63✔
237

238
        let cache_key = self.generate_cache_key(req);
63✔
239
        let cache_key_bytes = cache_key.as_bytes();
63✔
240

241
        // let store = self.store.read().await;
242
        if let Ok(Some(entry_handle)) = store.read(cache_key_bytes) {
63✔
243
            tracing::debug!("Entry handle: {:?}", entry_handle);
15✔
244

245
            if let Ok(cached) = bitcode::decode::<CachedResponse>(entry_handle.as_slice()) {
15✔
246
                let now = SystemTime::now()
15✔
247
                    .duration_since(UNIX_EPOCH)
15✔
248
                    .expect("Time went backwards")
15✔
249
                    .as_millis() as u64;
15✔
250

251
                // Extract TTL based on the policy (either from headers or default)
252
                let ttl = if self.policy.respect_headers {
15✔
253
                    // Convert headers back to HeaderMap to extract TTL
254
                    let mut headers = HeaderMap::new();
15✔
255
                    for (k, v) in cached.headers.iter() {
57✔
256
                        if let Ok(header_name) = k.parse::<http::HeaderName>()
57✔
257
                            && let Ok(header_value) = HeaderValue::from_bytes(v)
57✔
258
                        {
57✔
259
                            headers.insert(header_name, header_value);
57✔
260
                        }
57✔
261
                    }
262
                    Self::extract_ttl(&headers, &self.policy)
15✔
263
                } else {
264
                    self.policy.default_ttl
×
265
                };
266

267
                let expected_expiration = cached.expiration_timestamp + ttl.as_millis() as u64;
15✔
268

269
                // If expired, remove from cache
270
                if now >= expected_expiration {
15✔
271
                    // tracing::debug!("Determined cache is expired. now - expected_expiration: {:?}", now - expected_expiration);
272
                    tracing::debug!(
1✔
273
                        "Cache expires at: {}",
274
                        chrono::DateTime::from_timestamp_millis(expected_expiration as i64)
×
275
                            .unwrap()
×
276
                    );
277
                    tracing::debug!(
1✔
278
                        "Expiration timestamp: {}",
279
                        chrono::DateTime::from_timestamp_millis(cached.expiration_timestamp as i64)
×
280
                            .unwrap()
×
281
                    );
282
                    tracing::debug!(
1✔
283
                        "Now: {}",
284
                        chrono::DateTime::from_timestamp_millis(now as i64).unwrap()
×
285
                    );
286

287
                    store.delete(cache_key_bytes).ok();
1✔
288
                    return false;
1✔
289
                }
14✔
290

291
                return true;
14✔
292
            }
×
293
        }
48✔
294
        false
48✔
295
    }
63✔
296

297
    /// Generates a cache key based on request method, canonicalized URL, and relevant headers.
298
    ///
299
    /// The generated key is used to uniquely identify cached responses.
300
    ///
301
    /// Key strategy:
302
    /// - Includes request method.
303
    /// - Canonicalizes URL query parameters by sorting them by key/value.
304
    /// - Includes selected representation-affecting headers.
305
    /// - Hashes sensitive header values (e.g. Authorization) before adding them to key material.
306
    ///
307
    /// # Arguments
308
    ///
309
    /// * `req` - The HTTP request for which to generate a cache key.
310
    ///
311
    /// # Returns
312
    ///
313
    /// A string representing the cache key.
314
    fn generate_cache_key(&self, req: &Request) -> String {
17,400✔
315
        let method = req.method();
17,400✔
316
        let url = Self::canonicalize_url(req.url());
17,400✔
317
        let headers = req.headers();
17,400✔
318

319
        let relevant_headers = [
17,400✔
320
            "accept",
17,400✔
321
            "accept-language",
17,400✔
322
            "content-type",
17,400✔
323
            "authorization",
17,400✔
324
            "x-api-key",
17,400✔
325
        ];
17,400✔
326

327
        let header_string = relevant_headers
17,400✔
328
            .iter()
17,400✔
329
            .filter_map(|name| {
87,000✔
330
                headers.get(*name).map(|value| {
87,000✔
331
                    let value_str = if Self::is_sensitive_header(name) {
49,302✔
332
                        format!("h:{:016x}", compute_hash(value.as_bytes()))
19,707✔
333
                    } else {
334
                        value.to_str().unwrap_or_default().to_string()
29,595✔
335
                    };
336

337
                    format!("{}={}", name, value_str)
49,302✔
338
                })
49,302✔
339
            })
87,000✔
340
            .collect::<Vec<_>>()
17,400✔
341
            .join("&");
17,400✔
342

343
        format!("{} {} {}", method, url, header_string)
17,400✔
344
    }
17,400✔
345

346
    fn canonicalize_url(url: &reqwest::Url) -> String {
17,400✔
347
        let mut normalized = url.clone();
17,400✔
348

349
        let mut query_pairs = url
17,400✔
350
            .query_pairs()
17,400✔
351
            .map(|(k, v)| (k.into_owned(), v.into_owned()))
39,470✔
352
            .collect::<Vec<_>>();
17,400✔
353

354
        if !query_pairs.is_empty() {
17,400✔
355
            query_pairs.sort_by(|(k1, v1), (k2, v2)| k1.cmp(k2).then_with(|| v1.cmp(v2)));
42,961✔
356

357
            {
358
                let mut serializer = normalized.query_pairs_mut();
14,542✔
359
                serializer.clear();
14,542✔
360
                for (key, value) in query_pairs.iter() {
39,470✔
361
                    serializer.append_pair(key, value);
39,470✔
362
                }
39,470✔
363
            }
364
        }
2,858✔
365

366
        normalized.to_string()
17,400✔
367
    }
17,400✔
368

369
    fn is_sensitive_header(name: &str) -> bool {
49,302✔
370
        matches!(
19,707✔
371
            name,
49,302✔
372
            "authorization" | "proxy-authorization" | "cookie" | "x-api-key"
49,302✔
373
        )
374
    }
49,302✔
375

376
    /// Extracts the TTL from HTTP headers or falls back to the default TTL.
377
    ///
378
    /// # Arguments
379
    ///
380
    /// * `headers` - The HTTP headers to inspect.
381
    /// * `policy` - The cache policy specifying TTL behavior.
382
    ///
383
    /// # Returns
384
    ///
385
    /// A `Duration` indicating the cache expiration time.
386
    fn extract_ttl(headers: &HeaderMap, policy: &CachePolicy) -> Duration {
37✔
387
        if !policy.respect_headers {
37✔
388
            return policy.default_ttl;
×
389
        }
37✔
390

391
        if let Some(cache_control) = headers.get("cache-control")
37✔
392
            && let Ok(cache_control) = cache_control.to_str()
26✔
393
        {
394
            for directive in cache_control.split(',') {
26✔
395
                if let Some(max_age) = directive.trim().strip_prefix("max-age=")
26✔
396
                    && let Ok(seconds) = max_age.parse::<u64>()
26✔
397
                {
398
                    return Duration::from_secs(seconds);
26✔
399
                }
×
400
            }
401
        }
11✔
402

403
        if let Some(expires) = headers.get("expires")
11✔
NEW
404
            && let Ok(expires) = expires.to_str()
×
NEW
405
            && let Ok(expiry_time) = DateTime::parse_from_rfc2822(expires)
×
NEW
406
            && let Some(duration) = expiry_time.timestamp().checked_sub(Utc::now().timestamp())
×
NEW
407
            && duration > 0
×
408
        {
NEW
409
            return Duration::from_secs(duration as u64);
×
410
        }
11✔
411

412
        policy.default_ttl
11✔
413
    }
37✔
414
}
415

416
#[async_trait]
417
impl Middleware for DriveCache {
418
    /// Intercepts HTTP requests to apply caching behavior.
419
    ///
420
    /// This method first checks if a valid cached response exists for the incoming request.
421
    /// - If a cached response is found and still valid, it is returned immediately.
422
    /// - If no cache entry exists, the request is forwarded to the next middleware or backend.
423
    /// - If a response is received, it is cached according to the defined `CachePolicy`.
424
    ///
425
    /// This middleware **only caches GET and HEAD requests**. Other HTTP methods are passed through without caching.
426
    ///
427
    /// # Arguments
428
    ///
429
    /// * `req` - The incoming HTTP request.
430
    /// * `extensions` - A mutable reference to request extensions, which may store metadata.
431
    /// * `next` - The next middleware in the processing chain.
432
    ///
433
    /// # Returns
434
    ///
435
    /// A `Result<Response, reqwest_middleware::Error>` that contains either:
436
    /// - A cached response (if available).
437
    /// - A fresh response from the backend, which is then cached (if applicable).
438
    ///
439
    /// # Behavior
440
    ///
441
    /// - If the request is **already cached and valid**, returns the cached response.
442
    /// - If **no cache is found**, the request is sent to the backend, and the response is cached.
443
    /// - If **the cache has expired**, the old entry is deleted, and a fresh request is made.
444
    async fn handle(
445
        &self,
446
        req: Request,
447
        extensions: &mut Extensions,
448
        next: Next<'_>,
449
    ) -> Result<Response> {
36✔
450
        let bypass_cache = extensions
451
            .get::<CacheBypass>()
452
            .map(|flag| flag.0)
453
            .unwrap_or(false);
454
        let bust_cache = extensions
455
            .get::<CacheBust>()
456
            .map(|flag| flag.0)
457
            .unwrap_or(false);
458

459
        let cache_key = self.generate_cache_key(&req);
460

461
        tracing::debug!("Handle cache key: {}", cache_key);
462

463
        let store = self.store.as_ref();
464
        let cache_key_bytes = cache_key.as_bytes();
465

466
        if req.method() == "GET" || req.method() == "HEAD" {
467
            if !bypass_cache
468
                && !bust_cache
469
                && self.is_cached(&req).await
470
                && let Ok(Some(entry_handle)) = store.read(cache_key_bytes)
471
                && let Ok(cached) = bitcode::decode::<CachedResponse>(entry_handle.as_slice())
472
            {
473
                let mut headers = HeaderMap::new();
474
                for (k, v) in cached.headers {
475
                    if let Ok(header_name) = k.parse::<http::HeaderName>()
476
                        && let Ok(header_value) = HeaderValue::from_bytes(&v)
477
                    {
478
                        headers.insert(header_name, header_value);
479
                    }
480
                }
481
                let status = StatusCode::from_u16(cached.status).unwrap_or(StatusCode::OK);
482
                return Ok(build_response(status, headers, Bytes::from(cached.body)));
483
            }
484

485
            let response = next.run(req, extensions).await?;
486
            let status = response.status();
487
            let headers = response.headers().clone();
488
            let body = response.bytes().await?.to_vec();
489

490
            let ttl = Self::extract_ttl(&headers, &self.policy);
491
            let expiration_timestamp = SystemTime::now()
492
                .duration_since(UNIX_EPOCH)
493
                .expect("Time went backwards")
494
                .as_millis() as u64
495
                + ttl.as_millis() as u64;
496

497
            let body_clone = body.clone();
498

499
            let should_cache = match &self.policy.cache_status_override {
500
                Some(status_codes) => status_codes.contains(&status.as_u16()),
501
                None => status.is_success(),
502
            };
503

504
            if should_cache && !bypass_cache {
505
                let serialized = bitcode::encode(&CachedResponse {
506
                    status: status.as_u16(),
507
                    headers: headers
508
                        .iter()
509
                        .map(|(k, v)| (k.to_string(), v.as_bytes().to_vec()))
70✔
510
                        .collect(),
511
                    body,
512
                    expiration_timestamp,
513
                });
514

515
                tracing::debug!("Writing cache with key: {}", cache_key);
516
                store.write(cache_key_bytes, serialized.as_slice()).ok();
517
            }
518

519
            return Ok(build_response(status, headers, Bytes::from(body_clone)));
520
        }
521

522
        next.run(req, extensions).await
523
    }
36✔
524
}
525

526
/// Constructs a `reqwest::Response` from a given status code, headers, and body.
527
///
528
/// This function is used to rebuild an HTTP response from cached data,
529
/// ensuring that it correctly retains headers and status information.
530
///
531
/// # Arguments
532
///
533
/// * `status` - The HTTP status code of the response.
534
/// * `headers` - A `HeaderMap` containing response headers.
535
/// * `body` - A `Bytes` object containing the response body.
536
///
537
/// # Returns
538
///
539
/// A `reqwest::Response` representing the reconstructed HTTP response.
540
///
541
/// # Panics
542
///
543
/// This function will panic if the response body fails to be constructed.
544
fn build_response(status: StatusCode, headers: HeaderMap, body: Bytes) -> Response {
36✔
545
    let mut response_builder = http::Response::builder().status(status);
36✔
546

547
    for (key, value) in headers.iter() {
133✔
548
        response_builder = response_builder.header(key, value);
133✔
549
    }
133✔
550

551
    let http_response = response_builder
36✔
552
        .body(body)
36✔
553
        .expect("Failed to create HTTP response");
36✔
554

555
    Response::from(http_response)
36✔
556
}
36✔
557

558
#[cfg(test)]
559
mod tests {
560
    use super::*;
561
    use rand::rngs::StdRng;
562
    use rand::{RngExt, SeedableRng};
563
    use reqwest::Method;
564
    use std::collections::{HashMap, HashSet};
565
    use std::env;
566
    use std::sync::Once;
567
    use tempfile::TempDir;
568

569
    // These two collision tests are consistently much slower on GitHub-hosted Ubuntu
570
    // runners than on macOS/Windows. Keep full workload everywhere else and
571
    // reduce only in Linux CI to stabilize pipeline runtime.
572
    //
573
    // Rationale:
574
    // - GitHub-hosted Ubuntu runners have shown noticeably higher overhead
575
    //   for many small allocations and for creating `reqwest::Client` repeatedly
576
    //   inside tight loops. To avoid flakiness and long CI times we only apply
577
    //   the reduced workload when running in CI on Linux.
578
    fn is_ci_slow_environment() -> bool {
2✔
579
        let is_ci = env::var("CI")
2✔
580
            .map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
2✔
581
            .unwrap_or(false)
2✔
NEW
582
            || env::var("GITHUB_ACTIONS")
×
NEW
583
                .map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
×
NEW
584
                .unwrap_or(false);
×
585

586
        is_ci && cfg!(target_os = "linux")
2✔
587
    }
2✔
588

589
    #[allow(dead_code)]
NEW
590
    fn init_test_tracing() {
×
591
        static INIT: Once = Once::new();
592

NEW
593
        INIT.call_once(|| {
×
NEW
594
            let _ = tracing_subscriber::fmt()
×
NEW
595
                .with_test_writer()
×
NEW
596
                .with_env_filter(
×
NEW
597
                    tracing_subscriber::EnvFilter::try_from_default_env()
×
NEW
598
                        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
×
599
                )
NEW
600
                .try_init();
×
NEW
601
        });
×
NEW
602
    }
×
603

604
    fn build_request(method: Method, url: &str, headers: &[(&str, Option<&str>)]) -> Request {
7,301✔
605
        // Construct `reqwest::Request` directly rather than building a
606
        // `reqwest::Client` per-iteration. Creating a `Client` repeatedly in
607
        // tight loops was the dominant cost on some CI runners; building the
608
        // `Request` directly avoids that overhead while remaining functionally
609
        // equivalent for these key-generation tests.
610
        let mut request = Request::new(
7,301✔
611
            method,
7,301✔
612
            reqwest::Url::parse(url).expect("failed to parse request URL"),
7,301✔
613
        );
614

615
        for (name, value) in headers {
36,497✔
616
            if let Some(value) = value {
36,497✔
617
                let header_name = http::header::HeaderName::from_bytes(name.as_bytes())
24,347✔
618
                    .expect("invalid header name");
24,347✔
619
                let header_value =
24,347✔
620
                    http::header::HeaderValue::from_str(value).expect("invalid header value");
24,347✔
621
                request.headers_mut().insert(header_name, header_value);
24,347✔
622
            }
24,347✔
623
        }
624

625
        request
7,301✔
626
    }
7,301✔
627

628
    fn build_cache_for_tests() -> DriveCache {
3✔
629
        let temp_dir = TempDir::new().expect("failed to create temp dir");
3✔
630
        let cache_path = temp_dir.path().join("cache_key_matrix.bin");
3✔
631
        DriveCache::new(&cache_path, CachePolicy::default())
3✔
632
    }
3✔
633

634
    #[allow(dead_code)]
NEW
635
    fn build_unique_request_from_index(index: u64) -> Request {
×
NEW
636
        let methods = [
×
NEW
637
            Method::GET,
×
NEW
638
            Method::HEAD,
×
NEW
639
            Method::POST,
×
NEW
640
            Method::PUT,
×
NEW
641
            Method::PATCH,
×
NEW
642
            Method::DELETE,
×
NEW
643
        ];
×
644

NEW
645
        let method = methods[(index % methods.len() as u64) as usize].clone();
×
NEW
646
        let path = format!("/resource/{}/{}/{}", index % 97, index % 503, index % 9973);
×
647

NEW
648
        let query = format!(
×
649
            "a={}&b={}&c={}&d={}",
650
            index,
NEW
651
            index.wrapping_mul(31),
×
NEW
652
            index.rotate_left(7),
×
NEW
653
            index ^ 0xA5A5_A5A5_A5A5_A5A5
×
654
        );
655

NEW
656
        let url = format!("https://example.test{}?{}", path, query);
×
657

NEW
658
        let accept_values = ["application/json", "text/plain", "*/*"];
×
NEW
659
        let language_values = ["en-US", "fr-FR", "es-ES", "de-DE"];
×
NEW
660
        let content_type_values = ["application/json", "application/xml", "text/plain"];
×
661

NEW
662
        let mut request = Request::new(
×
NEW
663
            method,
×
NEW
664
            reqwest::Url::parse(&url).expect("failed to parse stress URL"),
×
665
        );
666

NEW
667
        request.headers_mut().insert(
×
NEW
668
            http::header::ACCEPT,
×
NEW
669
            http::header::HeaderValue::from_str(
×
NEW
670
                accept_values[(index % accept_values.len() as u64) as usize],
×
671
            )
NEW
672
            .expect("invalid accept header value"),
×
673
        );
NEW
674
        request.headers_mut().insert(
×
NEW
675
            http::header::ACCEPT_LANGUAGE,
×
NEW
676
            http::header::HeaderValue::from_str(
×
NEW
677
                language_values[(index % language_values.len() as u64) as usize],
×
678
            )
NEW
679
            .expect("invalid accept-language header value"),
×
680
        );
NEW
681
        request.headers_mut().insert(
×
NEW
682
            http::header::CONTENT_TYPE,
×
NEW
683
            http::header::HeaderValue::from_str(
×
NEW
684
                content_type_values[(index % content_type_values.len() as u64) as usize],
×
685
            )
NEW
686
            .expect("invalid content-type header value"),
×
687
        );
688

NEW
689
        let authorization_value = format!("Bearer token-{:016x}", index);
×
NEW
690
        request.headers_mut().insert(
×
NEW
691
            http::header::AUTHORIZATION,
×
NEW
692
            http::header::HeaderValue::from_str(&authorization_value)
×
NEW
693
                .expect("invalid authorization header value"),
×
694
        );
695

NEW
696
        let api_key_value = format!("api-key-{:016x}", index.rotate_right(11));
×
NEW
697
        request.headers_mut().insert(
×
NEW
698
            http::header::HeaderName::from_static("x-api-key"),
×
NEW
699
            http::header::HeaderValue::from_str(&api_key_value)
×
NEW
700
                .expect("invalid x-api-key header value"),
×
701
        );
702

NEW
703
        request
×
NEW
704
    }
×
705

706
    fn random_token(rng: &mut StdRng, min_len: usize, max_len: usize) -> String {
91,353✔
707
        let alphabet = b"abcdefghijklmnopqrstuvwxyz0123456789";
91,353✔
708
        let token_len = rng.random_range(min_len..=max_len);
91,353✔
709

710
        (0..token_len)
91,353✔
711
            .map(|_| {
723,750✔
712
                let index = rng.random_range(0..alphabet.len());
723,750✔
713
                alphabet[index] as char
723,750✔
714
            })
723,750✔
715
            .collect()
91,353✔
716
    }
91,353✔
717

718
    fn build_random_request(rng: &mut StdRng) -> Request {
10,000✔
719
        let methods = [
10,000✔
720
            Method::GET,
10,000✔
721
            Method::HEAD,
10,000✔
722
            Method::POST,
10,000✔
723
            Method::PUT,
10,000✔
724
            Method::PATCH,
10,000✔
725
            Method::DELETE,
10,000✔
726
        ];
10,000✔
727

728
        let method = methods[rng.random_range(0..methods.len())].clone();
10,000✔
729
        let mut url = format!(
10,000✔
730
            "https://example.test/{}/{}",
731
            random_token(rng, 3, 10),
10,000✔
732
            random_token(rng, 3, 10)
10,000✔
733
        );
734

735
        let query_pair_count = rng.random_range(0..=6);
10,000✔
736
        if query_pair_count > 0 {
10,000✔
737
            url.push('?');
8,695✔
738
            for query_index in 0..query_pair_count {
30,692✔
739
                if query_index > 0 {
30,692✔
740
                    url.push('&');
21,997✔
741
                }
21,997✔
742

743
                let query_key = random_token(rng, 1, 8);
30,692✔
744
                let query_value = random_token(rng, 0, 12);
30,692✔
745
                url.push_str(&query_key);
30,692✔
746
                url.push('=');
30,692✔
747
                url.push_str(&query_value);
30,692✔
748
            }
749
        }
1,305✔
750

751
        let mut request = Request::new(
10,000✔
752
            method,
10,000✔
753
            reqwest::Url::parse(&url).expect("failed to parse randomized URL"),
10,000✔
754
        );
755

756
        if rng.random::<bool>() {
10,000✔
757
            let accept_values = ["application/json", "text/plain", "*/*"];
5,096✔
758
            request.headers_mut().insert(
5,096✔
759
                http::header::ACCEPT,
5,096✔
760
                http::header::HeaderValue::from_str(accept_values[rng.random_range(0..3)])
5,096✔
761
                    .expect("invalid accept header value"),
5,096✔
762
            );
5,096✔
763
        }
5,096✔
764

765
        if rng.random::<bool>() {
10,000✔
766
            let language_values = ["en-US", "fr-FR", "es-ES", "de-DE"];
4,866✔
767
            request.headers_mut().insert(
4,866✔
768
                http::header::ACCEPT_LANGUAGE,
4,866✔
769
                http::header::HeaderValue::from_str(language_values[rng.random_range(0..4)])
4,866✔
770
                    .expect("invalid accept-language header value"),
4,866✔
771
            );
4,866✔
772
        }
5,134✔
773

774
        if rng.random::<bool>() {
10,000✔
775
            let content_type_values = ["application/json", "application/xml", "text/plain"];
5,018✔
776
            request.headers_mut().insert(
5,018✔
777
                http::header::CONTENT_TYPE,
5,018✔
778
                http::header::HeaderValue::from_str(content_type_values[rng.random_range(0..3)])
5,018✔
779
                    .expect("invalid content-type header value"),
5,018✔
780
            );
5,018✔
781
        }
5,018✔
782

783
        if rng.random::<bool>() {
10,000✔
784
            let authorization_value = format!("Bearer {}", random_token(rng, 16, 48));
5,032✔
785
            request.headers_mut().insert(
5,032✔
786
                http::header::AUTHORIZATION,
5,032✔
787
                http::header::HeaderValue::from_str(&authorization_value)
5,032✔
788
                    .expect("invalid authorization header value"),
5,032✔
789
            );
5,032✔
790
        }
5,032✔
791

792
        if rng.random::<bool>() {
10,000✔
793
            let api_key_value = random_token(rng, 12, 32);
4,937✔
794
            request.headers_mut().insert(
4,937✔
795
                http::header::HeaderName::from_static("x-api-key"),
4,937✔
796
                http::header::HeaderValue::from_str(&api_key_value)
4,937✔
797
                    .expect("invalid x-api-key header value"),
4,937✔
798
            );
4,937✔
799
        }
5,063✔
800

801
        request
10,000✔
802
    }
10,000✔
803

804
    #[test]
805
    fn fuzz_cache_key_hash_collisions_uses_library_key_generator() {
1✔
806
        let temp_dir = TempDir::new().expect("failed to create temp dir");
1✔
807
        let cache_path = temp_dir.path().join("cache_key_fuzz.bin");
1✔
808
        let cache = DriveCache::new(&cache_path, CachePolicy::default());
1✔
809

810
        let mut observed_hash_to_key: HashMap<u64, String> = HashMap::new();
1✔
811
        let mut random_generator = StdRng::seed_from_u64(0xD15EA5E5);
1✔
812

813
        let sample_count = if is_ci_slow_environment() {
1✔
814
            10_000
1✔
815
        } else {
NEW
816
            50_000
×
817
        };
818
        let mut distinct_key_count = 0usize;
1✔
819

820
        for _ in 0..sample_count {
1✔
821
            let request = build_random_request(&mut random_generator);
10,000✔
822

823
            let cache_key = cache.generate_cache_key(&request);
10,000✔
824
            let hash = compute_hash(cache_key.as_bytes());
10,000✔
825

826
            if let Some(existing_key) = observed_hash_to_key.get(&hash) {
10,000✔
NEW
827
                assert_eq!(
×
NEW
828
                    existing_key, &cache_key,
×
829
                    "hash collision detected for distinct cache keys"
830
                );
831
            } else {
10,000✔
832
                observed_hash_to_key.insert(hash, cache_key);
10,000✔
833
                distinct_key_count += 1;
10,000✔
834
            }
10,000✔
835
        }
836

837
        assert!(
1✔
838
            distinct_key_count > sample_count / 2,
1✔
839
            "random generation produced too few distinct keys"
840
        );
841
    }
1✔
842

843
    #[test]
844
    fn exhaustive_cache_key_matrix_no_hash_collisions_for_distinct_keys() {
1✔
845
        let cache = build_cache_for_tests();
1✔
846

847
        let (methods, paths, queries) = if is_ci_slow_environment() {
1✔
848
            (
1✔
849
                vec![Method::GET, Method::HEAD, Method::POST],
1✔
850
                vec!["/resource", "/resource/v2"],
1✔
851
                vec!["", "?a=1", "?a=2", "?a=1&b=2", "?b=2&a=1"],
1✔
852
            )
1✔
853
        } else {
NEW
854
            (
×
NEW
855
                vec![
×
NEW
856
                    Method::GET,
×
NEW
857
                    Method::HEAD,
×
NEW
858
                    Method::POST,
×
NEW
859
                    Method::PUT,
×
NEW
860
                    Method::PATCH,
×
NEW
861
                    Method::DELETE,
×
NEW
862
                ],
×
NEW
863
                vec!["/resource", "/resource/v2", "/resource/deep/path"],
×
NEW
864
                vec![
×
NEW
865
                    "", "?a=1", "?a=2", "?a=1&b=2", "?b=2&a=1", "?a=1&a=2", "?a=1&a=3", "?z=9",
×
NEW
866
                ],
×
NEW
867
            )
×
868
        };
869

870
        let accept_values = [None, Some("application/json"), Some("text/plain")];
1✔
871
        let language_values = [None, Some("en-US"), Some("fr-FR")];
1✔
872
        let content_type_values = [None, Some("application/json"), Some("application/xml")];
1✔
873
        let authorization_values = [None, Some("Bearer alpha-token"), Some("Bearer beta-token")];
1✔
874
        let api_key_values = [None, Some("alpha-api-key"), Some("beta-api-key")];
1✔
875

876
        let mut hash_to_key: HashMap<u64, String> = HashMap::new();
1✔
877
        let mut distinct_keys: HashSet<String> = HashSet::new();
1✔
878
        let mut sample_count = 0usize;
1✔
879

880
        for method in &methods {
3✔
881
            for path in &paths {
6✔
882
                for query in &queries {
30✔
883
                    for accept in accept_values {
90✔
884
                        for accept_language in language_values {
270✔
885
                            for content_type in content_type_values {
810✔
886
                                for authorization in authorization_values {
2,430✔
887
                                    for api_key in api_key_values {
7,290✔
888
                                        sample_count += 1;
7,290✔
889

890
                                        let url = format!("https://example.test{}{}", path, query);
7,290✔
891
                                        let request = build_request(
7,290✔
892
                                            method.clone(),
7,290✔
893
                                            &url,
7,290✔
894
                                            &[
7,290✔
895
                                                ("accept", accept),
7,290✔
896
                                                ("accept-language", accept_language),
7,290✔
897
                                                ("content-type", content_type),
7,290✔
898
                                                ("authorization", authorization),
7,290✔
899
                                                ("x-api-key", api_key),
7,290✔
900
                                            ],
7,290✔
901
                                        );
902

903
                                        let cache_key = cache.generate_cache_key(&request);
7,290✔
904
                                        let hash = compute_hash(cache_key.as_bytes());
7,290✔
905

906
                                        if let Some(existing_key) = hash_to_key.get(&hash) {
7,290✔
907
                                            assert_eq!(
1,458✔
908
                                                existing_key, &cache_key,
1,458✔
909
                                                "hash collision detected for distinct cache keys"
910
                                            );
911
                                        } else {
5,832✔
912
                                            hash_to_key.insert(hash, cache_key.clone());
5,832✔
913
                                        }
5,832✔
914

915
                                        distinct_keys.insert(cache_key);
7,290✔
916
                                    }
917
                                }
918
                            }
919
                        }
920
                    }
921
                }
922
            }
923
        }
924

925
        let expected_sample_count = methods.len()
1✔
926
            * paths.len()
1✔
927
            * queries.len()
1✔
928
            * accept_values.len()
1✔
929
            * language_values.len()
1✔
930
            * content_type_values.len()
1✔
931
            * authorization_values.len()
1✔
932
            * api_key_values.len();
1✔
933
        assert_eq!(sample_count, expected_sample_count);
1✔
934
        assert!(
1✔
935
            distinct_keys.len() > sample_count / 2,
1✔
936
            "matrix generation produced too few distinct keys"
937
        );
938
    }
1✔
939

940
    #[test]
941
    fn cache_key_query_reordering_is_canonical_and_hash_stable() {
1✔
942
        let cache = build_cache_for_tests();
1✔
943

944
        let request_a = build_request(
1✔
945
            Method::GET,
1✔
946
            "https://example.test/resource?a=1&b=2",
1✔
947
            &[("accept", Some("application/json"))],
1✔
948
        );
949
        let request_b = build_request(
1✔
950
            Method::GET,
1✔
951
            "https://example.test/resource?b=2&a=1",
1✔
952
            &[("accept", Some("application/json"))],
1✔
953
        );
954

955
        let key_a = cache.generate_cache_key(&request_a);
1✔
956
        let key_b = cache.generate_cache_key(&request_b);
1✔
957

958
        assert_eq!(key_a, key_b);
1✔
959
        assert_eq!(
1✔
960
            compute_hash(key_a.as_bytes()),
1✔
961
            compute_hash(key_b.as_bytes())
1✔
962
        );
963
    }
1✔
964

965
    #[test]
966
    fn cache_key_changes_for_each_response_affecting_dimension() {
1✔
967
        let cache = build_cache_for_tests();
1✔
968

969
        let base_request = build_request(
1✔
970
            Method::GET,
1✔
971
            "https://example.test/resource?a=1&b=2",
1✔
972
            &[
1✔
973
                ("accept", Some("application/json")),
1✔
974
                ("accept-language", Some("en-US")),
1✔
975
                ("content-type", Some("application/json")),
1✔
976
                ("authorization", Some("Bearer alpha-token")),
1✔
977
                ("x-api-key", Some("alpha-api-key")),
1✔
978
            ],
1✔
979
        );
980
        let base_key = cache.generate_cache_key(&base_request);
1✔
981
        let base_hash = compute_hash(base_key.as_bytes());
1✔
982

983
        let variants = vec![
1✔
984
            build_request(
1✔
985
                Method::POST,
1✔
986
                "https://example.test/resource?a=1&b=2",
1✔
987
                &[
1✔
988
                    ("accept", Some("application/json")),
1✔
989
                    ("accept-language", Some("en-US")),
1✔
990
                    ("content-type", Some("application/json")),
1✔
991
                    ("authorization", Some("Bearer alpha-token")),
1✔
992
                    ("x-api-key", Some("alpha-api-key")),
1✔
993
                ],
1✔
994
            ),
995
            build_request(
1✔
996
                Method::GET,
1✔
997
                "https://example.test/resource/v2?a=1&b=2",
1✔
998
                &[
1✔
999
                    ("accept", Some("application/json")),
1✔
1000
                    ("accept-language", Some("en-US")),
1✔
1001
                    ("content-type", Some("application/json")),
1✔
1002
                    ("authorization", Some("Bearer alpha-token")),
1✔
1003
                    ("x-api-key", Some("alpha-api-key")),
1✔
1004
                ],
1✔
1005
            ),
1006
            build_request(
1✔
1007
                Method::GET,
1✔
1008
                "https://example.test/resource?a=99&b=2",
1✔
1009
                &[
1✔
1010
                    ("accept", Some("application/json")),
1✔
1011
                    ("accept-language", Some("en-US")),
1✔
1012
                    ("content-type", Some("application/json")),
1✔
1013
                    ("authorization", Some("Bearer alpha-token")),
1✔
1014
                    ("x-api-key", Some("alpha-api-key")),
1✔
1015
                ],
1✔
1016
            ),
1017
            build_request(
1✔
1018
                Method::GET,
1✔
1019
                "https://example.test/resource?a=1&b=2",
1✔
1020
                &[
1✔
1021
                    ("accept", Some("text/plain")),
1✔
1022
                    ("accept-language", Some("en-US")),
1✔
1023
                    ("content-type", Some("application/json")),
1✔
1024
                    ("authorization", Some("Bearer alpha-token")),
1✔
1025
                    ("x-api-key", Some("alpha-api-key")),
1✔
1026
                ],
1✔
1027
            ),
1028
            build_request(
1✔
1029
                Method::GET,
1✔
1030
                "https://example.test/resource?a=1&b=2",
1✔
1031
                &[
1✔
1032
                    ("accept", Some("application/json")),
1✔
1033
                    ("accept-language", Some("fr-FR")),
1✔
1034
                    ("content-type", Some("application/json")),
1✔
1035
                    ("authorization", Some("Bearer alpha-token")),
1✔
1036
                    ("x-api-key", Some("alpha-api-key")),
1✔
1037
                ],
1✔
1038
            ),
1039
            build_request(
1✔
1040
                Method::GET,
1✔
1041
                "https://example.test/resource?a=1&b=2",
1✔
1042
                &[
1✔
1043
                    ("accept", Some("application/json")),
1✔
1044
                    ("accept-language", Some("en-US")),
1✔
1045
                    ("content-type", Some("application/xml")),
1✔
1046
                    ("authorization", Some("Bearer alpha-token")),
1✔
1047
                    ("x-api-key", Some("alpha-api-key")),
1✔
1048
                ],
1✔
1049
            ),
1050
            build_request(
1✔
1051
                Method::GET,
1✔
1052
                "https://example.test/resource?a=1&b=2",
1✔
1053
                &[
1✔
1054
                    ("accept", Some("application/json")),
1✔
1055
                    ("accept-language", Some("en-US")),
1✔
1056
                    ("content-type", Some("application/json")),
1✔
1057
                    ("authorization", Some("Bearer beta-token")),
1✔
1058
                    ("x-api-key", Some("alpha-api-key")),
1✔
1059
                ],
1✔
1060
            ),
1061
            build_request(
1✔
1062
                Method::GET,
1✔
1063
                "https://example.test/resource?a=1&b=2",
1✔
1064
                &[
1✔
1065
                    ("accept", Some("application/json")),
1✔
1066
                    ("accept-language", Some("en-US")),
1✔
1067
                    ("content-type", Some("application/json")),
1✔
1068
                    ("authorization", Some("Bearer alpha-token")),
1✔
1069
                    ("x-api-key", Some("beta-api-key")),
1✔
1070
                ],
1✔
1071
            ),
1072
        ];
1073

1074
        for variant in variants {
8✔
1075
            let variant_key = cache.generate_cache_key(&variant);
8✔
1076
            let variant_hash = compute_hash(variant_key.as_bytes());
8✔
1077

1078
            assert_ne!(
8✔
1079
                variant_key, base_key,
1080
                "variant unexpectedly produced same key"
1081
            );
1082
            assert_ne!(
8✔
1083
                variant_hash, base_hash,
1084
                "variant unexpectedly produced same hash"
1085
            );
1086
        }
1087
    }
1✔
1088

1089
    /*
1090
    Stress experiment (disabled):
1091
    - This 100,000,000-sample collision test worked (no collisions observed).
1092
    - End-to-end runtime was several hours, even in `--release` mode.
1093
    - A Rayon parallelization attempt did not produce a meaningful speedup for
1094
      this workload, so the test is commented out to keep normal test cycles fast.
1095

1096
    #[test]
1097
    #[ignore = "expensive: runs 100,000,000 samples"]
1098
    fn cache_key_hash_collision_stress_100_million() {
1099
        init_test_tracing();
1100

1101
        let cache = build_cache_for_tests();
1102

1103
        let samples = 100_000_000_u64;
1104
        let mut seen_hashes: HashSet<u64> = HashSet::new();
1105
        let started_at = Instant::now();
1106

1107
        for index in 0..samples {
1108
            let request = build_unique_request_from_index(index);
1109
            let cache_key = cache.generate_cache_key(&request);
1110
            let hash = compute_hash(cache_key.as_bytes());
1111

1112
            assert!(
1113
                seen_hashes.insert(hash),
1114
                "hash collision detected in stress test at sample index {} (hash={})",
1115
                index,
1116
                hash
1117
            );
1118

1119
            let completed = index + 1;
1120
            if completed % 10_000 == 0 {
1121
                let elapsed = started_at.elapsed();
1122
                let pct = (completed as f64 / samples as f64) * 100.0;
1123
                tracing::info!(
1124
                    "stress progress: {}/{} ({:.4}%) elapsed={:?}",
1125
                    completed,
1126
                    samples,
1127
                    pct,
1128
                    elapsed
1129
                );
1130
            }
1131
        }
1132

1133
        tracing::info!(
1134
            "stress complete: {} samples in {:?}",
1135
            samples,
1136
            started_at.elapsed()
1137
        );
1138
    }
1139
    */
1140
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc