Skip to main content

tower_http_cache/
streaming.rs

1//! Smart streaming and large file handling for tower-http-cache.
2//!
3//! This module provides intelligent body size detection and content-type based
4//! filtering to prevent large files (PDFs, videos, archives) from overwhelming
5//! the cache. Large bodies are automatically streamed through without buffering,
6//! preserving memory and cache efficiency.
7
8use std::collections::HashSet;
9
10use crate::range::RangeHandling;
11
12/// Decision on how to handle a response body based on size and content type.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14#[allow(clippy::manual_non_exhaustive)]
15pub enum StreamingDecision {
16    /// Buffer the body and cache it (small enough, appropriate type)
17    Buffer,
18
19    /// Skip caching entirely (too large or excluded content type)
20    SkipCache,
21
22    /// Stream the body without buffering (reserved for future use).
23    #[doc(hidden)]
24    StreamThrough,
25
26    /// Try to stream if possible, fallback to buffer (unknown size)
27    StreamIfPossible,
28}
29
30/// Configuration for smart streaming and large file handling.
31///
32/// The streaming policy allows you to configure how the cache handles
33/// large response bodies and specific content types. This prevents memory
34/// exhaustion and cache pollution from large files.
35///
36/// # Examples
37///
38/// ```
39/// use tower_http_cache::streaming::StreamingPolicy;
40/// use tower_http_cache::range::RangeHandling;
41/// use std::collections::HashSet;
42///
43/// let policy = StreamingPolicy {
44///     enabled: true,
45///     max_cacheable_size: Some(1024 * 1024), // 1MB
46///     excluded_content_types: HashSet::from([
47///         "application/pdf".to_string(),
48///         "video/*".to_string(),
49///     ]),
50///     range_handling: RangeHandling::PassThrough,
51///     ..Default::default()
52/// };
53/// ```
54#[derive(Debug, Clone)]
55pub struct StreamingPolicy {
56    /// Enable smart streaming (default: true)
57    pub enabled: bool,
58
59    /// Skip caching bodies larger than this (default: 1MB)
60    /// Set to None to disable size-based filtering
61    pub max_cacheable_size: Option<usize>,
62
63    /// Content types to never cache (PDFs, videos, archives, etc.)
64    /// Supports wildcards like "video/*"
65    pub excluded_content_types: HashSet<String>,
66
67    /// Bypass content-type exclusions for these types (size limits still apply).
68    /// Useful for API responses that should be cached even if they match an exclusion pattern.
69    pub force_cache_content_types: HashSet<String>,
70
71    /// Use streaming for bodies above this size (default: 512KB)
72    /// Currently used for decision making; actual streaming not yet implemented
73    pub stream_threshold: usize,
74
75    /// How to handle HTTP Range requests (default: PassThrough)
76    pub range_handling: RangeHandling,
77
78    /// Enable chunk caching for large files (default: false, opt-in)
79    ///
80    /// When enabled, large files are split into chunks and cached separately,
81    /// allowing efficient range request handling without caching the entire file.
82    pub enable_chunk_cache: bool,
83
84    /// Chunk size for large files (default: 1MB)
85    ///
86    /// Files are split into chunks of this size when chunk caching is enabled.
87    /// Larger chunks use less memory overhead but may waste bandwidth for small
88    /// range requests. Smaller chunks are more granular but have more overhead.
89    pub chunk_size: usize,
90
91    /// Minimum file size for chunking (default: 10MB)
92    ///
93    /// Files smaller than this will not be chunked even if chunk caching is enabled.
94    /// This prevents overhead for files that don't benefit from chunking.
95    pub min_chunk_file_size: u64,
96}
97
98impl Default for StreamingPolicy {
99    fn default() -> Self {
100        Self {
101            enabled: true,
102            max_cacheable_size: Some(1024 * 1024), // 1MB
103            excluded_content_types: HashSet::from([
104                "application/pdf".to_string(),
105                "video/*".to_string(),
106                "audio/*".to_string(),
107                "application/zip".to_string(),
108                "application/x-rar".to_string(),
109                "application/x-tar".to_string(),
110                "application/gzip".to_string(),
111                "application/x-7z-compressed".to_string(),
112                "application/octet-stream".to_string(),
113            ]),
114            force_cache_content_types: HashSet::from([
115                "application/json".to_string(),
116                "application/xml".to_string(),
117                "text/*".to_string(),
118            ]),
119            stream_threshold: 512 * 1024, // 512KB
120            range_handling: RangeHandling::default(),
121            enable_chunk_cache: false,             // Opt-in feature
122            chunk_size: 1024 * 1024,               // 1MB chunks
123            min_chunk_file_size: 10 * 1024 * 1024, // 10MB minimum
124        }
125    }
126}
127
128impl StreamingPolicy {
129    /// Creates a new streaming policy with all features disabled.
130    /// Useful for gradually migrating existing code.
131    pub fn disabled() -> Self {
132        Self {
133            enabled: false,
134            max_cacheable_size: None,
135            excluded_content_types: HashSet::new(),
136            force_cache_content_types: HashSet::new(),
137            stream_threshold: usize::MAX,
138            range_handling: RangeHandling::PassThrough,
139            enable_chunk_cache: false,
140            chunk_size: 1024 * 1024,
141            min_chunk_file_size: 0,
142        }
143    }
144
145    /// Creates a streaming policy that only filters by size.
146    pub fn size_only(max_size: usize) -> Self {
147        Self {
148            enabled: true,
149            max_cacheable_size: Some(max_size),
150            excluded_content_types: HashSet::new(),
151            force_cache_content_types: HashSet::new(),
152            stream_threshold: max_size,
153            range_handling: RangeHandling::PassThrough,
154            enable_chunk_cache: false,
155            chunk_size: 1024 * 1024,
156            min_chunk_file_size: 0,
157        }
158    }
159
160    /// Creates a streaming policy that only filters by content type.
161    pub fn content_type_only(excluded: HashSet<String>) -> Self {
162        Self {
163            enabled: true,
164            max_cacheable_size: None,
165            excluded_content_types: excluded,
166            force_cache_content_types: HashSet::new(),
167            stream_threshold: usize::MAX,
168            range_handling: RangeHandling::PassThrough,
169            enable_chunk_cache: false,
170            chunk_size: 1024 * 1024,
171            min_chunk_file_size: 0,
172        }
173    }
174}
175
176/// Determines how to handle a response body based on size hints and content type.
177///
178/// This function implements the core streaming decision logic by examining:
179/// 1. Whether streaming is enabled in the policy
180/// 2. Content-Type header (fast path for exclusions/forced caching)
181/// 3. Size hints from the body
182/// 4. Content-Length header as fallback
183///
184/// # Arguments
185///
186/// * `policy` - The streaming policy configuration
187/// * `size_hint` - Size hint from the response body
188/// * `content_type` - Content-Type header value, if present
189/// * `content_length` - Content-Length header value, if present
190///
191/// # Returns
192///
193/// A [`StreamingDecision`] indicating how to handle the body.
194pub fn should_stream(
195    policy: &StreamingPolicy,
196    size_hint: &http_body::SizeHint,
197    content_type: Option<&str>,
198    content_length: Option<u64>,
199) -> StreamingDecision {
200    // Check if streaming is disabled
201    if !policy.enabled {
202        return StreamingDecision::Buffer;
203    }
204
205    // Check content type first (fast path)
206    let is_forced = if let Some(ct) = content_type {
207        // Check if content type is excluded
208        if is_excluded_content_type(ct, &policy.excluded_content_types) {
209            return StreamingDecision::SkipCache;
210        }
211
212        // Check if content type is forced to cache
213        is_forced_content_type(ct, &policy.force_cache_content_types)
214    } else {
215        false
216    };
217
218    // Check size hints - size limits apply even to forced types
219    if let Some(exact_size) = size_hint.exact() {
220        return decide_by_size(exact_size as usize, policy, is_forced);
221    }
222
223    if let Some(upper_bound) = size_hint.upper() {
224        return decide_by_size(upper_bound as usize, policy, is_forced);
225    }
226
227    // Fallback to Content-Length header
228    if let Some(content_len) = content_length {
229        return decide_by_size(content_len as usize, policy, is_forced);
230    }
231
232    // Unknown size - use conservative approach
233    // If we have no size information, buffer it (existing behavior)
234    StreamingDecision::StreamIfPossible
235}
236
237/// Decides caching strategy based on body size.
238///
239/// The `is_forced` parameter indicates if the content-type is in force_cache list,
240/// but size limits still apply regardless.
241fn decide_by_size(size: usize, policy: &StreamingPolicy, _is_forced: bool) -> StreamingDecision {
242    if let Some(max_size) = policy.max_cacheable_size {
243        if size > max_size {
244            return StreamingDecision::SkipCache;
245        }
246    }
247
248    // For now, always buffer if size is acceptable
249    // Future: return StreamingDecision::StreamThrough for large-but-cacheable bodies
250    StreamingDecision::Buffer
251}
252
253/// Checks if a content type matches any excluded patterns.
254///
255/// Supports exact matches and wildcard patterns like "video/*".
256fn is_excluded_content_type(content_type: &str, excluded: &HashSet<String>) -> bool {
257    let normalized = content_type.to_lowercase();
258
259    for pattern in excluded {
260        if matches_pattern(&normalized, pattern) {
261            return true;
262        }
263    }
264
265    false
266}
267
268/// Checks if a content type matches any forced cache patterns.
269fn is_forced_content_type(content_type: &str, forced: &HashSet<String>) -> bool {
270    let normalized = content_type.to_lowercase();
271
272    for pattern in forced {
273        if matches_pattern(&normalized, pattern) {
274            return true;
275        }
276    }
277
278    false
279}
280
281/// Matches a content type against a pattern (supports wildcards).
282///
283/// For non-wildcard patterns, matches if the content type equals the pattern
284/// or starts with it followed by a semicolon (to handle parameters like `; charset=utf-8`).
285fn matches_pattern(content_type: &str, pattern: &str) -> bool {
286    let pattern_lower = pattern.to_lowercase();
287
288    if pattern_lower.ends_with("/*") {
289        // Wildcard pattern like "video/*"
290        let prefix = &pattern_lower[..pattern_lower.len() - 2];
291        content_type.starts_with(prefix)
292    } else {
293        // Exact match (with optional parameter suffix)
294        content_type == pattern_lower || content_type.starts_with(&format!("{};", pattern_lower))
295    }
296}
297
298/// Extracts size information from various sources for logging/metrics.
299pub fn extract_size_info(
300    size_hint: &http_body::SizeHint,
301    content_length: Option<u64>,
302) -> Option<u64> {
303    size_hint
304        .exact()
305        .or_else(|| size_hint.upper())
306        .or(content_length)
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use http_body::SizeHint;
313
314    #[test]
315    fn test_default_policy_excludes_pdf() {
316        let policy = StreamingPolicy::default();
317        let size_hint = SizeHint::with_exact(5 * 1024 * 1024); // 5MB
318
319        let decision = should_stream(
320            &policy,
321            &size_hint,
322            Some("application/pdf"),
323            Some(5 * 1024 * 1024),
324        );
325
326        assert_eq!(decision, StreamingDecision::SkipCache);
327    }
328
329    #[test]
330    fn test_default_policy_excludes_video() {
331        let policy = StreamingPolicy::default();
332        let size_hint = SizeHint::with_exact(10 * 1024 * 1024);
333
334        let decision = should_stream(&policy, &size_hint, Some("video/mp4"), None);
335
336        assert_eq!(decision, StreamingDecision::SkipCache);
337    }
338
339    #[test]
340    fn test_small_json_gets_buffered() {
341        let policy = StreamingPolicy::default();
342        let size_hint = SizeHint::with_exact(1024); // 1KB
343
344        let decision = should_stream(&policy, &size_hint, Some("application/json"), Some(1024));
345
346        assert_eq!(decision, StreamingDecision::Buffer);
347    }
348
349    #[test]
350    fn test_large_json_skipped_by_size() {
351        let policy = StreamingPolicy::default();
352        let size_hint = SizeHint::with_exact(2 * 1024 * 1024); // 2MB
353
354        let decision = should_stream(
355            &policy,
356            &size_hint,
357            Some("application/json"),
358            Some(2 * 1024 * 1024),
359        );
360
361        assert_eq!(decision, StreamingDecision::SkipCache);
362    }
363
364    #[test]
365    fn test_force_cache_respects_size_limits() {
366        // force_cache only bypasses content-type exclusions, not size limits
367        let mut policy = StreamingPolicy::default();
368        policy
369            .force_cache_content_types
370            .insert("application/important".to_string());
371
372        let size_hint = SizeHint::with_exact(5 * 1024 * 1024); // 5MB (over 1MB limit)
373
374        let decision = should_stream(
375            &policy,
376            &size_hint,
377            Some("application/important"),
378            Some(5 * 1024 * 1024),
379        );
380
381        // Even though it's forced to cache, size limit still applies
382        assert_eq!(decision, StreamingDecision::SkipCache);
383
384        // But under the size limit it should cache
385        let small_hint = SizeHint::with_exact(500 * 1024); // 500KB
386        let decision_small = should_stream(
387            &policy,
388            &small_hint,
389            Some("application/important"),
390            Some(500 * 1024),
391        );
392        assert_eq!(decision_small, StreamingDecision::Buffer);
393    }
394
395    #[test]
396    fn test_disabled_policy_always_buffers() {
397        let policy = StreamingPolicy::disabled();
398        let size_hint = SizeHint::with_exact(10 * 1024 * 1024);
399
400        let decision = should_stream(
401            &policy,
402            &size_hint,
403            Some("application/pdf"),
404            Some(10 * 1024 * 1024),
405        );
406
407        assert_eq!(decision, StreamingDecision::Buffer);
408    }
409
410    #[test]
411    fn test_wildcard_pattern_matching() {
412        assert!(matches_pattern("video/mp4", "video/*"));
413        assert!(matches_pattern("video/mpeg", "video/*"));
414        assert!(matches_pattern("audio/mp3", "audio/*"));
415        assert!(!matches_pattern("application/json", "video/*"));
416    }
417
418    #[test]
419    fn test_exact_pattern_matching() {
420        assert!(matches_pattern("application/pdf", "application/pdf"));
421        assert!(!matches_pattern("application/pdf", "pdf")); // no substring match
422        assert!(!matches_pattern("text/plain", "application/pdf"));
423        // Matches with parameter suffix
424        assert!(matches_pattern(
425            "application/json; charset=utf-8",
426            "application/json"
427        ));
428    }
429
430    #[test]
431    fn test_size_hint_exact() {
432        let policy = StreamingPolicy::default();
433        let size_hint = SizeHint::with_exact(500 * 1024); // 500KB
434
435        let decision = should_stream(&policy, &size_hint, None, None);
436        assert_eq!(decision, StreamingDecision::Buffer);
437    }
438
439    #[test]
440    fn test_size_hint_upper_bound() {
441        let policy = StreamingPolicy::default();
442        let mut size_hint = SizeHint::default();
443        size_hint.set_upper(500 * 1024);
444
445        let decision = should_stream(&policy, &size_hint, None, None);
446        assert_eq!(decision, StreamingDecision::Buffer);
447    }
448
449    #[test]
450    fn test_content_length_fallback() {
451        let policy = StreamingPolicy::default();
452        let size_hint = SizeHint::default(); // No hints
453
454        let decision = should_stream(&policy, &size_hint, None, Some(500 * 1024));
455        assert_eq!(decision, StreamingDecision::Buffer);
456    }
457
458    #[test]
459    fn test_unknown_size_conservative() {
460        let policy = StreamingPolicy::default();
461        let size_hint = SizeHint::default();
462
463        let decision = should_stream(&policy, &size_hint, None, None);
464        assert_eq!(decision, StreamingDecision::StreamIfPossible);
465    }
466
467    #[test]
468    fn test_size_only_policy() {
469        let policy = StreamingPolicy::size_only(512 * 1024);
470        let size_hint = SizeHint::with_exact(1024 * 1024);
471
472        // PDF should be cached (no content-type filtering)
473        let decision = should_stream(&policy, &size_hint, Some("application/pdf"), None);
474        assert_eq!(decision, StreamingDecision::SkipCache); // Due to size
475
476        let size_hint_small = SizeHint::with_exact(256 * 1024);
477        let decision_small =
478            should_stream(&policy, &size_hint_small, Some("application/pdf"), None);
479        assert_eq!(decision_small, StreamingDecision::Buffer); // Small enough
480    }
481
482    #[test]
483    fn test_content_type_only_policy() {
484        let mut excluded = HashSet::new();
485        excluded.insert("application/pdf".to_string());
486        let policy = StreamingPolicy::content_type_only(excluded);
487
488        let size_hint = SizeHint::with_exact(10 * 1024 * 1024); // 10MB
489
490        // Large non-PDF should be cached (no size filtering)
491        let decision = should_stream(&policy, &size_hint, Some("application/json"), None);
492        assert_eq!(decision, StreamingDecision::Buffer);
493
494        // PDF should be skipped
495        let decision_pdf = should_stream(&policy, &size_hint, Some("application/pdf"), None);
496        assert_eq!(decision_pdf, StreamingDecision::SkipCache);
497    }
498
499    #[test]
500    fn test_extract_size_info() {
501        let size_hint = SizeHint::with_exact(1024);
502        assert_eq!(extract_size_info(&size_hint, None), Some(1024));
503
504        let mut size_hint_upper = SizeHint::default();
505        size_hint_upper.set_upper(2048);
506        assert_eq!(extract_size_info(&size_hint_upper, None), Some(2048));
507
508        let size_hint_none = SizeHint::default();
509        assert_eq!(extract_size_info(&size_hint_none, Some(4096)), Some(4096));
510
511        assert_eq!(extract_size_info(&size_hint_none, None), None);
512    }
513
514    #[test]
515    fn test_case_insensitive_content_type() {
516        let policy = StreamingPolicy::default();
517        let size_hint = SizeHint::with_exact(1024);
518
519        // All variations should be excluded
520        assert_eq!(
521            should_stream(&policy, &size_hint, Some("Application/PDF"), None),
522            StreamingDecision::SkipCache
523        );
524        assert_eq!(
525            should_stream(&policy, &size_hint, Some("APPLICATION/PDF"), None),
526            StreamingDecision::SkipCache
527        );
528        assert_eq!(
529            should_stream(&policy, &size_hint, Some("Video/MP4"), None),
530            StreamingDecision::SkipCache
531        );
532    }
533
534    #[test]
535    fn test_content_type_with_charset() {
536        let policy = StreamingPolicy::default();
537        let size_hint = SizeHint::with_exact(1024);
538
539        // Should match even with charset parameter
540        let decision = should_stream(
541            &policy,
542            &size_hint,
543            Some("application/json; charset=utf-8"),
544            None,
545        );
546        assert_eq!(decision, StreamingDecision::Buffer);
547    }
548}