diff --git a/docs/src/cache-modes.md b/docs/src/cache-modes.md index 340d088..e294d00 100644 --- a/docs/src/cache-modes.md +++ b/docs/src/cache-modes.md @@ -16,13 +16,9 @@ When constructing a new instance of `HttpCache`, you must specify a cache mode. - `IgnoreRules`: This mode will ignore the HTTP headers and always store a response given it was a 200 status code. It will also ignore the staleness when retrieving a response from the cache, so expiration of the cached response will need to be handled manually. If there was no cached response it will create a normal request, and will update the cache with the response. -## Maximum TTL Control +## Default and Maximum TTL -When using cache modes like `IgnoreRules` that bypass server cache headers, you can use the `max_ttl` option to provide expiration control. This is particularly useful for preventing cached responses from persisting indefinitely. - -### Usage - -The `max_ttl` option accepts a `Duration` and sets a maximum time-to-live for cached responses: +The `default_ttl` and `max_ttl` options tune how long responses are considered fresh, for when you know more about your caching needs than the server's headers tell you. Both accept a `Duration`: ```rust use http_cache::{HttpCacheOptions, RedbManager, HttpCache, CacheMode}; @@ -31,52 +27,31 @@ use std::time::Duration; let manager = RedbManager::new("./http-cache.redb").unwrap(); let options = HttpCacheOptions { - max_ttl: Some(Duration::from_secs(300)), // 5 minutes maximum + default_ttl: Some(Duration::from_secs(60)), // 1 minute when the server doesn't say + max_ttl: Some(Duration::from_secs(3600)), // 1 hour maximum ..Default::default() }; let cache = HttpCache { - mode: CacheMode::IgnoreRules, // Ignore server cache headers + mode: CacheMode::Default, manager, options, }; ``` -### Behavior +### `default_ttl` -- **Override longer durations**: If the server specifies a longer cache duration (e.g., `max-age=3600`), `max_ttl` will reduce it to the specified limit -- **Respect shorter durations**: If the server specifies a shorter duration (e.g., `max-age=60`), the server's shorter duration will be used -- **Provide fallback duration**: When using `IgnoreRules` mode where server headers are ignored, `max_ttl` provides the cache duration +- **Applies without an explicit expiration**: When a response has no `max-age` directive and no `Expires` header (nor `s-maxage` in a shared cache), it is fresh for `default_ttl` +- **Replaces the heuristic**: Without `default_ttl`, such responses are only fresh for a fraction of the time since their `Last-Modified` date, see `CacheOptions::cache_heuristic`. Without that header they're stale right away +- **Respects the server**: Responses that may not be stored still aren't, `no-cache` responses are still revalidated on every use, and an invalid or past `Expires` still marks a response as stale -### Examples +### `max_ttl` -**With IgnoreRules mode:** -```rust -// Cache everything for 5 minutes, ignoring server headers -let options = HttpCacheOptions { - max_ttl: Some(Duration::from_secs(300)), - ..Default::default() -}; -let cache = HttpCache { - mode: CacheMode::IgnoreRules, - manager, - options, -}; -``` +- **Override longer durations**: If the server specifies a longer cache duration (e.g., `max-age=3600` or an `Expires` date), `max_ttl` will reduce it to the specified limit. This includes heuristic lifetimes and `default_ttl` +- **Respect shorter durations**: If the server specifies a shorter duration (e.g., `max-age=60`), the server's shorter duration will be used +- **Only a limit**: `max_ttl` does not make responses without an expiration fresh, use `default_ttl` for that -**With Default mode:** -```rust -// Respect server headers but limit cache duration to 1 hour maximum -let options = HttpCacheOptions { - max_ttl: Some(Duration::from_secs(3600)), - ..Default::default() -}; -let cache = HttpCache { - mode: CacheMode::Default, - manager, - options, -}; -``` +Freshness is not checked by `ForceCache`, `OnlyIfCached` and `IgnoreRules`, so neither option expires responses in those modes. ## Content-Type Based Caching @@ -180,6 +155,8 @@ let options = HttpCacheOptions { _ => Some(CacheMode::NoStore), } })), + // Forced responses without their own expiration are fresh for 10 minutes + default_ttl: Some(Duration::from_secs(600)), // Limit cache duration to 1 hour max max_ttl: Some(Duration::from_secs(3600)), ..Default::default() @@ -233,6 +210,8 @@ let options = HttpCacheOptions { cache_key: Some(Arc::new(|req| { format!("{}:{}:{}", req.method, req.uri.host().unwrap_or(""), req.uri.path()) })), + // Cache duration when the server doesn't specify one + default_ttl: Some(Duration::from_secs(300)), // 5 minutes // Maximum cache duration max_ttl: Some(Duration::from_secs(1800)), // 30 minutes // Add cache status headers for debugging @@ -359,7 +338,8 @@ let options = HttpCacheOptions { vec![] // No cache busting by default })), - // Global cache duration limit + // Global cache duration default and limit + default_ttl: Some(Duration::from_secs(3600)), max_ttl: Some(Duration::from_secs(86400)), // Enable cache status headers for debugging @@ -381,7 +361,7 @@ let cache = HttpCache { 2. **Request-Based Cache Mode Override**: The `cache_mode_fn` allows overriding cache behavior based on request properties (headers, path, method, etc.) 3. **Response-Based Cache Mode Override**: The `response_cache_mode_fn` allows overriding cache behavior based on both request and response data 4. **Cache Busting**: The `cache_bust` function allows invalidating related cache entries -5. **Global Settings**: Options like `max_ttl` and `cache_status_headers` provide global configuration +5. **Global Settings**: Options like `default_ttl`, `max_ttl` and `cache_status_headers` provide global configuration All of these functions are called on a per-request basis, giving you complete control over caching behavior for each individual request. diff --git a/docs/src/clients/ureq.md b/docs/src/clients/ureq.md index 8ba540a..9e3e574 100644 --- a/docs/src/clients/ureq.md +++ b/docs/src/clients/ureq.md @@ -166,9 +166,9 @@ fn main() -> Result<(), Box> { } ``` -## Maximum TTL Control +## Default and Maximum TTL -Control cache expiration times, particularly useful with `IgnoreRules` mode: +Control cache expiration times when the server's headers don't suit your needs: ```rust use http_cache_ureq::{CachedAgent, RedbManager, CacheMode, HttpCacheOptions}; @@ -178,8 +178,9 @@ fn main() -> Result<(), Box> { smol::block_on(async { let agent = CachedAgent::builder() .cache_manager(RedbManager::new("./http-cache.redb")?) - .cache_mode(CacheMode::IgnoreRules) // Ignore server cache headers + .cache_mode(CacheMode::Default) .cache_options(HttpCacheOptions { + default_ttl: Some(Duration::from_secs(60)), // 1 minute when the server doesn't say max_ttl: Some(Duration::from_secs(300)), // Limit cache to 5 minutes maximum ..Default::default() }) @@ -201,4 +202,4 @@ fn main() -> Result<(), Box> { - All HTTP methods are supported (GET, POST, PUT, DELETE, HEAD, etc.) - Cache invalidation occurs for non-GET/HEAD requests to the same resource - Only GET and HEAD requests are cached by default -- `max_ttl` provides expiration control when using `CacheMode::IgnoreRules` +- `default_ttl` and `max_ttl` only apply to modes that check freshness, `CacheMode::IgnoreRules` never expires responses diff --git a/http-cache-quickcache/src/test.rs b/http-cache-quickcache/src/test.rs index cc6ba6c..bfe6074 100644 --- a/http-cache-quickcache/src/test.rs +++ b/http-cache-quickcache/src/test.rs @@ -125,6 +125,7 @@ async fn default_mode_with_options() -> Result<()> { response_cache_mode_fn: None, cache_bust: None, cache_status_headers: true, + default_ttl: None, max_ttl: None, metadata_provider: None, modify_response: None, diff --git a/http-cache-reqwest/src/test.rs b/http-cache-reqwest/src/test.rs index 481cd6d..c96bc2f 100644 --- a/http-cache-reqwest/src/test.rs +++ b/http-cache-reqwest/src/test.rs @@ -182,6 +182,77 @@ async fn max_ttl_applies_to_buffered_responses() -> Result<()> { Ok(()) } +/// `(default_ttl, max_ttl, expected upstream hits)` for two requests to a +/// response without caching headers. +const TTL_WITHOUT_EXPIRATION: [(Option, Option, u64); 4] = [ + (None, None, 2), + (None, Some(Duration::from_secs(3600)), 2), + (Some(Duration::from_secs(3600)), None, 1), + (Some(Duration::from_secs(3600)), Some(Duration::ZERO), 2), +]; + +fn build_mock_without_caching_headers(expect: u64) -> Mock { + Mock::given(method(GET)) + .respond_with(ResponseTemplate::new(200).set_body_bytes(TEST_BODY)) + .expect(expect) +} + +#[tokio::test] +async fn default_ttl_applies_to_buffered_responses() -> Result<()> { + for (default_ttl, max_ttl, expected_hits) in TTL_WITHOUT_EXPIRATION { + let mock_server = MockServer::start().await; + let m = build_mock_without_caching_headers(expected_hits); + let _mock_guard = mock_server.register_as_scoped(m).await; + let url = format!("{}/", mock_server.uri()); + + let client = ClientBuilder::new(Client::new()) + .with(Cache(HttpCache { + mode: CacheMode::Default, + manager: create_cache_manager(), + options: HttpCacheOptions { + default_ttl, + max_ttl, + ..Default::default() + }, + })) + .build(); + + for _ in 0..2 { + let response = client.get(&url).send().await?; + assert_eq!(response.bytes().await?, TEST_BODY); + } + } + Ok(()) +} + +#[cfg(feature = "streaming")] +#[tokio::test] +async fn default_ttl_applies_to_streaming_responses() -> Result<()> { + use crate::StreamingCache; + use http_cache::StreamingManager; + + for (default_ttl, max_ttl, expected_hits) in TTL_WITHOUT_EXPIRATION { + let mock_server = MockServer::start().await; + let m = build_mock_without_caching_headers(expected_hits); + let _mock_guard = mock_server.register_as_scoped(m).await; + let url = format!("{}/", mock_server.uri()); + + let client = ClientBuilder::new(Client::new()) + .with(StreamingCache::with_options( + StreamingManager::with_temp_dir(100).await?, + CacheMode::Default, + HttpCacheOptions { default_ttl, max_ttl, ..Default::default() }, + )) + .build(); + + for _ in 0..2 { + let response = client.get(&url).send().await?; + assert_eq!(response.bytes().await?, TEST_BODY); + } + } + Ok(()) +} + #[tokio::test] async fn no_cache_mode() -> Result<()> { let mock_server = MockServer::start().await; diff --git a/http-cache-ureq/src/lib.rs b/http-cache-ureq/src/lib.rs index 04fa227..b8bc804 100644 --- a/http-cache-ureq/src/lib.rs +++ b/http-cache-ureq/src/lib.rs @@ -172,9 +172,9 @@ //! } //! ``` //! -//! ## Maximum TTL Control +//! ## Default and Maximum TTL //! -//! Set a maximum time-to-live for cached responses, particularly useful with `CacheMode::IgnoreRules`: +//! Set a time-to-live for responses without their own expiration, and a maximum for all responses: //! //! ```no_run //! use http_cache_ureq::{CachedAgent, RedbManager, CacheMode, HttpCacheOptions}; @@ -184,8 +184,9 @@ //! smol::block_on(async { //! let agent = CachedAgent::builder() //! .cache_manager(RedbManager::new("./http-cache.redb")?) -//! .cache_mode(CacheMode::IgnoreRules) // Ignore server cache-control headers +//! .cache_mode(CacheMode::Default) //! .cache_options(HttpCacheOptions { +//! default_ttl: Some(Duration::from_secs(60)), // 1 minute when the server doesn't say //! max_ttl: Some(Duration::from_secs(300)), // Limit cache to 5 minutes regardless of server headers //! ..Default::default() //! }) diff --git a/http-cache-ureq/src/test.rs b/http-cache-ureq/src/test.rs index 5e56bfb..b1a5c85 100644 --- a/http-cache-ureq/src/test.rs +++ b/http-cache-ureq/src/test.rs @@ -856,7 +856,7 @@ async fn max_ttl_with_ignore_rules() { .cache_manager(manager.clone()) .cache_mode(CacheMode::IgnoreRules) // Ignore cache-control headers .cache_options(HttpCacheOptions { - max_ttl: Some(Duration::from_secs(300)), // 5 minutes - provides expiration control + max_ttl: Some(Duration::from_secs(300)), // No effect, IgnoreRules doesn't check freshness ..Default::default() }) .build() diff --git a/http-cache/CHANGELOG.md b/http-cache/CHANGELOG.md index ab85beb..d45bfd9 100644 --- a/http-cache/CHANGELOG.md +++ b/http-cache/CHANGELOG.md @@ -2,10 +2,21 @@ ## [Unreleased] +### Added + +- Added `HttpCacheOptions::default_ttl` to set how long responses without a `max-age` directive or `Expires` header stay fresh, instead of using the `Last-Modified` heuristic + ### Changed - Migrated to the 2024 edition, which requires Rust 1.85.0 or newer - MSRV lowered from 1.90.0 to 1.89.0. `rust-version` now covers default features resolved with `resolver = "3"`; see the MSRV policy in the README +- `max_ttl` is now only a limit. A response without an explicit expiration is no longer fresh for `max_ttl`, set `default_ttl` to the same duration to keep that behavior + +### Fixed + +- `max_ttl` no longer extends shorter lifetimes from an `Expires` header or the `Last-Modified` heuristic to `max_ttl`, and now also caps `s-maxage` in shared caches +- `max_ttl` no longer drops a `Pragma: no-cache` from responses without a `Cache-Control` header +- The crate documentation no longer suggests `max_ttl` expires responses in `CacheMode::IgnoreRules`, which does not check freshness ## [1.0.0-alpha.8] - 2026-09-08 diff --git a/http-cache/src/lib.rs b/http-cache/src/lib.rs index 00fa1e0..374d22c 100644 --- a/http-cache/src/lib.rs +++ b/http-cache/src/lib.rs @@ -116,9 +116,10 @@ //! # fn main() {} //! ``` //! -//! ## Maximum TTL Control +//! ## Default and Maximum TTL //! -//! Set a maximum time-to-live for cached responses, particularly useful with `CacheMode::IgnoreRules`: +//! Set a time-to-live for responses that don't specify their own expiration, and cap how long +//! any response is considered fresh: //! //! ```rust //! # #[cfg(feature = "manager-cacache")] @@ -128,14 +129,16 @@ //! //! let manager = CACacheManager::new("./cache".into(), true); //! -//! // Limit cache duration to 5 minutes regardless of server headers //! let options = HttpCacheOptions { -//! max_ttl: Some(Duration::from_secs(300)), // 5 minutes +//! // Responses without `max-age` or `Expires` are fresh for 1 minute +//! default_ttl: Some(Duration::from_secs(60)), +//! // No response is fresh for longer than 5 minutes +//! max_ttl: Some(Duration::from_secs(300)), //! ..Default::default() //! }; //! //! let cache = HttpCache { -//! mode: CacheMode::IgnoreRules, // Ignore server cache-control headers +//! mode: CacheMode::Default, //! manager, //! options, //! }; @@ -336,7 +339,9 @@ use std::{ }; use http::{ - HeaderValue, Response, StatusCode, header::CACHE_CONTROL, request, response, + HeaderValue, Response, StatusCode, + header::{CACHE_CONTROL, EXPIRES, PRAGMA}, + request, response, }; use http_cache_semantics::{AfterResponse, BeforeRequest, CachePolicy}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; @@ -1645,9 +1650,25 @@ pub struct HttpCacheOptions { pub modify_response: Option, /// Determines if the cache status headers should be added to the response. pub cache_status_headers: bool, + /// Time-to-live for responses that don't specify their own expiration. + /// + /// Applies when a response has no `max-age` directive and no `Expires` + /// header (nor `s-maxage` in a shared cache). It replaces the heuristic + /// based on `Last-Modified`, see [`CacheOptions::cache_heuristic`]. + /// + /// Doesn't make responses cacheable that are not allowed to be stored, + /// and `no-cache` responses are still revalidated on every use. + /// An invalid or past `Expires` header marks a response as already stale, + /// so this doesn't apply to those either. + /// + /// When `None`, responses without an explicit expiration only use the heuristic. + pub default_ttl: Option, /// Maximum time-to-live for cached responses. - /// When set, this overrides any longer cache durations specified by the server. - /// Particularly useful with `CacheMode::IgnoreRules` to provide expiration control. + /// + /// When set, this caps any longer lifetime specified by the server, as well + /// as heuristic lifetimes and `default_ttl`. Shorter lifetimes are kept. + /// It does not make responses without an expiration cacheable, use + /// `default_ttl` for that. pub max_ttl: Option, /// Rate limiter that applies only on cache misses. /// When enabled, requests that result in cache hits are returned immediately, @@ -1672,6 +1693,7 @@ impl Default for HttpCacheOptions { cache_bust: None, modify_response: None, cache_status_headers: true, + default_ttl: None, max_ttl: None, #[cfg(feature = "rate-limiting")] rate_limiter: None, @@ -1695,6 +1717,7 @@ impl Debug for HttpCacheOptions { .field("cache_bust", &"Fn(&request::Parts) -> Vec") .field("modify_response", &"Fn(&mut ModifyResponse)") .field("cache_status_headers", &self.cache_status_headers) + .field("default_ttl", &self.default_ttl) .field("max_ttl", &self.max_ttl) .field("rate_limiter", &"Option") .field( @@ -1717,6 +1740,7 @@ impl Debug for HttpCacheOptions { .field("cache_bust", &"Fn(&request::Parts) -> Vec") .field("modify_response", &"Fn(&mut ModifyResponse)") .field("cache_status_headers", &self.cache_status_headers) + .field("default_ttl", &self.default_ttl) .field("max_ttl", &self.max_ttl) .field( "metadata_provider", @@ -1833,73 +1857,40 @@ impl HttpCacheOptions { response_parts: &response::Parts, ) -> CachePolicy { let cache_options = self.cache_options.unwrap_or_default(); - - // If max_ttl is specified, we need to modify the response headers to enforce it - if let Some(max_ttl) = self.max_ttl { - // Parse existing cache-control header - let cache_control = response_parts - .headers - .get("cache-control") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - - // Extract existing max-age if present - let existing_max_age = - cache_control.split(',').find_map(|directive| { - let directive = directive.trim(); - if directive.starts_with("max-age=") { - directive.strip_prefix("max-age=")?.parse::().ok() - } else { - None - } - }); - - // Convert max_ttl to seconds - let max_ttl_seconds = max_ttl.as_secs(); - - // Apply max_ttl by setting max-age to the minimum of existing max-age and max_ttl - let effective_max_age = match existing_max_age { - Some(existing) => std::cmp::min(existing, max_ttl_seconds), - None => max_ttl_seconds, - }; - - // Build new cache-control header - let mut new_directives = Vec::new(); - - // Add non-max-age directives from existing cache-control - for directive in cache_control.split(',').map(|d| d.trim()) { - if !directive.starts_with("max-age=") && !directive.is_empty() { - new_directives.push(directive.to_string()); - } - } - - // Add our effective max-age - new_directives.push(format!("max-age={}", effective_max_age)); - - let new_cache_control = new_directives.join(", "); - - // Create modified response parts - we have to clone since response::Parts has private fields - let mut modified_response_parts = response_parts.clone(); - modified_response_parts.headers.insert( - "cache-control", - HeaderValue::from_str(&new_cache_control) - .unwrap_or_else(|_| HeaderValue::from_static("max-age=0")), - ); - - CachePolicy::new_options( - request_parts, - &modified_response_parts, - SystemTime::now(), - cache_options, - ) - } else { + let now = SystemTime::now(); + let new_policy = |response_parts: &response::Parts| { CachePolicy::new_options( request_parts, response_parts, - SystemTime::now(), + now, cache_options, ) + }; + + // `default_ttl` stands in for the heuristic freshness lifetime, so + // it only applies when the server gave no explicit expiration. + let mut policy = match self.default_ttl { + Some(default_ttl) + if !has_explicit_expiration( + response_parts, + cache_options.shared, + ) => + { + new_policy(&with_max_age(response_parts, default_ttl)) + } + _ => new_policy(response_parts), + }; + + // `max_ttl` caps the freshness lifetime however it was determined: + // `max-age`, `s-maxage`, `Expires`, heuristics or `default_ttl`. + // The lifetime counts from the response's age, like `max-age` does. + if let Some(max_ttl) = self.max_ttl + && policy.time_to_live(now) + policy.age(now) > max_ttl + { + policy = new_policy(&with_max_age(response_parts, max_ttl)); } + + policy } /// Determines if a response should be cached based on cache mode and HTTP semantics @@ -2135,6 +2126,75 @@ fn merge_headers(dst: &mut http::HeaderMap, src: &http::HeaderMap) { } } +/// The directives of every `Cache-Control` header on a response, trimmed. +fn cache_control_directives( + parts: &response::Parts, +) -> impl Iterator { + parts + .headers + .get_all(CACHE_CONTROL) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(str::trim) + .filter(|directive| !directive.is_empty()) +} + +/// Whether `directive` is `name`, with or without an argument. Names are +/// case-sensitive, like `http-cache-semantics` parses them. +fn is_directive(directive: &str, name: &str) -> bool { + directive.split('=').next().is_some_and(|key| key.trim() == name) +} + +/// RFC 9111 s4.2.1: whether the response states its own freshness lifetime, +/// matching the conditions `http-cache-semantics` checks. An `Expires` that is +/// invalid or in the past still counts, as it marks the response as stale. +fn has_explicit_expiration(parts: &response::Parts, shared: bool) -> bool { + parts.headers.contains_key(EXPIRES) + || cache_control_directives(parts).any(|directive| { + is_directive(directive, "max-age") + || (shared && is_directive(directive, "s-maxage")) + }) +} + +/// Copies `parts` with its freshness lifetime set to `ttl`, by replacing the +/// `max-age` and `s-maxage` directives. `max-age` takes precedence over +/// `Expires` and heuristics, so other headers are left alone. +fn with_max_age(parts: &response::Parts, ttl: Duration) -> response::Parts { + let seconds = ttl.as_secs(); + let mut directives: Vec = cache_control_directives(parts) + .filter(|directive| !is_directive(directive, "max-age")) + .map(|directive| { + if is_directive(directive, "s-maxage") { + format!("s-maxage={seconds}") + } else { + directive.to_string() + } + }) + .collect(); + directives.push(format!("max-age={seconds}")); + + // `Pragma: no-cache` only counts when there is no `Cache-Control` header, + // which we're about to add. + if !parts.headers.contains_key(CACHE_CONTROL) + && parts + .headers + .get(PRAGMA) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("no-cache")) + { + directives.push("no-cache".to_string()); + } + + let mut parts = parts.clone(); + parts.headers.insert( + CACHE_CONTROL, + HeaderValue::from_str(&directives.join(", ")) + .unwrap_or_else(|_| HeaderValue::from_static("max-age=0")), + ); + parts +} + /// RFC 7234 s4.4: the GET and HEAD cache keys to invalidate after a /// successful (2xx/3xx) response to a non-GET/HEAD request, or `None` when /// the status does not warrant invalidation. diff --git a/http-cache/src/test.rs b/http-cache/src/test.rs index 4d00d8b..3758e1c 100644 --- a/http-cache/src/test.rs +++ b/http-cache/src/test.rs @@ -1720,3 +1720,182 @@ mod rate_limiting_tests { assert_eq!(host3, "localhost"); } } + +mod ttl_tests { + use crate::{CacheOptions, HttpCacheOptions}; + use http::{Request, Response}; + use std::time::{Duration, SystemTime}; + + const MIN: Duration = Duration::from_secs(60); + const HOUR: Duration = Duration::from_secs(60 * 60); + const DAY: Duration = Duration::from_secs(24 * 60 * 60); + + fn http_date(offset_from_now: Duration, future: bool) -> String { + let now = SystemTime::now(); + httpdate::fmt_http_date(if future { + now + offset_from_now + } else { + now - offset_from_now + }) + } + + /// The time-to-live of a freshly received response with `headers`. + fn ttl(options: &HttpCacheOptions, headers: &[(&str, &str)]) -> Duration { + let (req, _) = + Request::get("https://example.com/").body(()).unwrap().into_parts(); + let mut res = Response::builder(); + for (name, value) in headers { + res = res.header(*name, *value); + } + let (res, _) = res.body(()).unwrap().into_parts(); + options.create_cache_policy(&req, &res).time_to_live(SystemTime::now()) + } + + /// Asserts `actual` is `expected`, allowing for time passing in the test. + #[track_caller] + fn assert_ttl(actual: Duration, expected: Duration) { + assert!( + actual <= expected && actual + Duration::from_secs(5) > expected, + "expected a ttl of {expected:?}, got {actual:?}" + ); + } + + fn options( + default_ttl: Option, + max_ttl: Option, + ) -> HttpCacheOptions { + HttpCacheOptions { default_ttl, max_ttl, ..Default::default() } + } + + #[test] + fn no_expiration_is_stale_without_default_ttl() { + assert_ttl(ttl(&options(None, None), &[]), Duration::ZERO); + assert_ttl(ttl(&options(None, Some(HOUR)), &[]), Duration::ZERO); + } + + #[test] + fn default_ttl_applies_without_expiration() { + assert_ttl(ttl(&options(Some(MIN), None), &[]), MIN); + assert_ttl(ttl(&options(Some(MIN), Some(HOUR)), &[]), MIN); + let headers = [("cache-control", "public")]; + assert_ttl(ttl(&options(Some(MIN), None), &headers), MIN); + } + + #[test] + fn max_ttl_caps_default_ttl() { + assert_ttl(ttl(&options(Some(DAY), Some(HOUR)), &[]), HOUR); + } + + #[test] + fn max_age_takes_precedence_over_default_ttl() { + let headers = [("cache-control", "max-age=3600")]; + assert_ttl(ttl(&options(Some(MIN), None), &headers), HOUR); + let headers = [("cache-control", "max-age=0")]; + assert_ttl(ttl(&options(Some(MIN), None), &headers), Duration::ZERO); + } + + #[test] + fn max_ttl_caps_max_age() { + let headers = [("cache-control", "max-age=86400")]; + assert_ttl(ttl(&options(None, Some(HOUR)), &headers), HOUR); + let headers = [("cache-control", "max-age=60")]; + assert_ttl(ttl(&options(None, Some(HOUR)), &headers), MIN); + } + + #[test] + fn max_ttl_counts_from_age() { + let headers = [("cache-control", "max-age=86400"), ("age", "600")]; + assert_ttl(ttl(&options(None, Some(HOUR)), &headers), HOUR - 10 * MIN); + } + + #[test] + fn expires_takes_precedence_over_default_ttl() { + let expires = http_date(HOUR, true); + let headers = [("expires", expires.as_str())]; + assert_ttl(ttl(&options(Some(MIN), None), &headers), HOUR); + } + + #[test] + fn max_ttl_keeps_shorter_expires() { + let expires = http_date(10 * MIN, true); + let headers = [("expires", expires.as_str())]; + assert_ttl(ttl(&options(Some(MIN), Some(HOUR)), &headers), 10 * MIN); + } + + #[test] + fn max_ttl_caps_expires() { + let expires = http_date(DAY, true); + let headers = [("expires", expires.as_str())]; + assert_ttl(ttl(&options(None, Some(HOUR)), &headers), HOUR); + } + + #[test] + fn past_or_invalid_expires_is_stale_despite_default_ttl() { + let expires = http_date(HOUR, false); + for expires in [expires.as_str(), "0"] { + let headers = [("expires", expires)]; + assert_ttl( + ttl(&options(Some(MIN), None), &headers), + Duration::ZERO, + ); + } + } + + #[test] + fn default_ttl_replaces_heuristic() { + let last_modified = http_date(10 * DAY, false); + let headers = [("last-modified", last_modified.as_str())]; + // 10% of the time since it was last modified + assert_ttl(ttl(&options(None, None), &headers), DAY); + assert_ttl(ttl(&options(None, Some(HOUR)), &headers), HOUR); + assert_ttl(ttl(&options(Some(MIN), None), &headers), MIN); + } + + #[test] + fn default_ttl_does_not_override_no_cache_or_no_store() { + for cache_control in ["no-cache", "no-store"] { + let headers = [("cache-control", cache_control)]; + assert_ttl( + ttl(&options(Some(MIN), None), &headers), + Duration::ZERO, + ); + } + let headers = [("pragma", "no-cache")]; + assert_ttl(ttl(&options(Some(MIN), None), &headers), Duration::ZERO); + } + + #[test] + fn shared_cache_uses_s_maxage() { + let shared = |default_ttl, max_ttl| HttpCacheOptions { + cache_options: Some(CacheOptions { + shared: true, + ..Default::default() + }), + ..options(default_ttl, max_ttl) + }; + let headers = [("cache-control", "s-maxage=600")]; + assert_ttl(ttl(&shared(Some(MIN), None), &headers), 10 * MIN); + assert_ttl(ttl(&shared(None, Some(MIN)), &headers), MIN); + } + + #[test] + fn private_cache_ignores_s_maxage() { + let private = |default_ttl, max_ttl| HttpCacheOptions { + cache_options: Some(CacheOptions { + shared: false, + ..Default::default() + }), + ..options(default_ttl, max_ttl) + }; + let headers = [("cache-control", "s-maxage=600")]; + assert_ttl(ttl(&private(Some(MIN), None), &headers), MIN); + assert_ttl(ttl(&private(None, None), &headers), Duration::ZERO); + } + + #[test] + fn directives_are_read_across_headers() { + let headers = + [("cache-control", "public"), ("cache-control", "max-age=86400")]; + assert_ttl(ttl(&options(Some(MIN), Some(HOUR)), &headers), HOUR); + } +}