Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 21 additions & 41 deletions docs/src/cache-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down
9 changes: 5 additions & 4 deletions docs/src/clients/ureq.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
```

## 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};
Expand All @@ -178,8 +178,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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()
})
Expand All @@ -201,4 +202,4 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
- 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
1 change: 1 addition & 0 deletions http-cache-quickcache/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions http-cache-reqwest/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Duration>, Option<Duration>, 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;
Expand Down
7 changes: 4 additions & 3 deletions http-cache-ureq/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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()
//! })
Expand Down
2 changes: 1 addition & 1 deletion http-cache-ureq/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
11 changes: 11 additions & 0 deletions http-cache/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading