use super::*; use http_body_util::BodyExt; fn active_settings() -> Settings { Settings { enabled: true, ..Settings::default() } } async fn store( cache: &Arc, scope: &str, host: &str, target: &str, request_headers: HeaderMap, extra_response_headers: &[(&str, &str)], body: &'static str, ) -> Ticket { let ticket = cache .ticket_scoped( 22, scope, "http", host, "origin@127.0.0.1:8080", target, &request_headers, ) .unwrap(); let settings = active_settings(); let mut response = Response::new(Body::from(body)); response.headers_mut().insert( "cache-control", HeaderValue::from_static("public, max-age=120, stale-if-error=60"), ); for (name, value) in extra_response_headers { response.headers_mut().insert( http::HeaderName::from_bytes(name.as_bytes()).unwrap(), HeaderValue::from_str(value).unwrap(), ); } let plan = plan( StatusCode::OK, response.headers(), &settings, Duration::ZERO, ) .unwrap(); cache .wrap(response, ticket.clone(), plan, &settings) .into_body() .collect() .await .unwrap(); ticket } fn invalidation( operation: InvalidationOperation, selector: InvalidationSelector, value: &str, host: Option<&str>, vhost: Option<&str>, ) -> InvalidationRequest { InvalidationRequest { operation, selector, value: value.to_owned(), host: host.map(str::to_owned), vhost: vhost.map(str::to_owned), ttl_seconds: None, confirm_wide: false, } } #[tokio::test] async fn cached_single_range_returns_206_and_preserves_validators() { let cache = Cache::new(22); let ticket = store( &cache, "range", "range.example", "/asset.bin", HeaderMap::new(), &[ ("etag", "\"asset-v1\""), ("last-modified", "Mon, 26 Jan 2026 23:11:40 GMT"), ], "abcdefghij", ) .await; let mut request_headers = HeaderMap::new(); request_headers.insert("range", HeaderValue::from_static("bytes=2-5")); let range = match request_range(&Method::GET, &request_headers) { RangeSelection::Single(range) => range, other => panic!("unexpected range selection: {other:?}"), }; let hit = cache.get(&ticket).unwrap().apply_range(range, None); assert_eq!(hit.response.status(), StatusCode::PARTIAL_CONTENT); assert_eq!(hit.response.headers()["content-range"], "bytes 2-5/10"); assert_eq!(hit.response.headers()["content-length"], "4"); assert_eq!(hit.response.headers()["accept-ranges"], "bytes"); assert_eq!(hit.response.headers()["etag"], "\"asset-v1\""); assert!(hit.response.headers().contains_key("age")); let body = hit.response.into_body().collect().await.unwrap().to_bytes(); assert_eq!(body, Bytes::from_static(b"cdef")); } #[tokio::test] async fn cached_range_supports_suffix_open_unsatisfied_and_if_range() { let cache = Cache::new(22); let ticket = store( &cache, "range", "range.example", "/asset.bin", HeaderMap::new(), &[ ("etag", "\"asset-v1\""), ("last-modified", "Mon, 26 Jan 2026 23:11:40 GMT"), ], "abcdefghij", ) .await; let cases = [ (ByteRange { start: None, end: Some(3) }, "hij", "bytes 7-9/10"), (ByteRange { start: Some(7), end: None }, "hij", "bytes 7-9/10"), ]; for (range, expected, content_range) in cases { let hit = cache.get(&ticket).unwrap().apply_range(range, None); assert_eq!(hit.response.status(), StatusCode::PARTIAL_CONTENT); assert_eq!(hit.response.headers()["content-range"], content_range); let body = hit.response.into_body().collect().await.unwrap().to_bytes(); assert_eq!(body, Bytes::copy_from_slice(expected.as_bytes())); } let unsatisfied = cache.get(&ticket).unwrap().apply_range( ByteRange { start: Some(50), end: None }, None, ); assert_eq!(unsatisfied.response.status(), StatusCode::RANGE_NOT_SATISFIABLE); assert_eq!(unsatisfied.response.headers()["content-range"], "bytes */10"); assert_eq!( unsatisfied.response.into_body().collect().await.unwrap().to_bytes().len(), 0 ); let mismatch = HeaderValue::from_static("\"asset-v2\""); let full = cache.get(&ticket).unwrap().apply_range( ByteRange { start: Some(0), end: Some(2) }, Some(&mismatch), ); assert_eq!(full.response.status(), StatusCode::OK); assert!(!full.response.headers().contains_key("content-range")); assert_eq!( full.response.into_body().collect().await.unwrap().to_bytes(), Bytes::from_static(b"abcdefghij") ); let matched = HeaderValue::from_static("\"asset-v1\""); let partial = cache.get(&ticket).unwrap().apply_range( ByteRange { start: Some(0), end: Some(2) }, Some(&matched), ); assert_eq!(partial.response.status(), StatusCode::PARTIAL_CONTENT); } #[test] fn range_parser_rejects_multipart_and_duplicate_fields_and_if_range_date_allows_older_representation() { let mut multipart = HeaderMap::new(); multipart.insert("range", HeaderValue::from_static("bytes=0-1,4-5")); assert_eq!( request_range(&Method::GET, &multipart), RangeSelection::Unsupported ); let mut duplicate = HeaderMap::new(); duplicate.append("range", HeaderValue::from_static("bytes=0-1")); duplicate.append("range", HeaderValue::from_static("bytes=4-5")); assert_eq!( request_range(&Method::GET, &duplicate), RangeSelection::Unsupported ); let mut response_headers = HeaderMap::new(); response_headers.insert( "last-modified", HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"), ); let newer_if_range = HeaderValue::from_static("Tue, 27 Jan 2026 23:11:40 GMT"); assert!(if_range_matches(&response_headers, Some(&newer_if_range))); } #[tokio::test] async fn concurrent_miss_single_flight_waits_until_fill_is_published() { let cache = Cache::new(22); let mut settings = active_settings(); settings.collapse_wait_timeout_ms = 500; let ticket = cache .ticket_scoped( 22, "collapse", "http", "collapse.example", "origin@127.0.0.1:8080", "/same", &HeaderMap::new(), ) .unwrap(); let leader = match cache.claim_fill(&ticket, &settings).await { FillClaim::Leader(guard) => guard, _ => panic!("first request must become fill leader"), }; let follower_cache = cache.clone(); let follower_ticket = ticket.clone(); let follower_settings = settings.clone(); let follower = tokio::spawn(async move { match follower_cache .claim_fill(&follower_ticket, &follower_settings) .await { FillClaim::Waited => follower_cache.get(&follower_ticket).is_some(), _ => false, } }); for _ in 0..50 { if cache.view().collapse_waiters == 1 { break; } tokio::time::sleep(Duration::from_millis(2)).await; } assert_eq!(cache.view().collapse_waiters, 1); assert!(!follower.is_finished()); let mut response = Response::new(Body::from("collapsed-payload")); response.headers_mut().insert( "cache-control", HeaderValue::from_static("public, max-age=60"), ); let plan = plan( StatusCode::OK, response.headers(), &settings, Duration::ZERO, ) .unwrap(); cache .wrap_with_fill(response, ticket.clone(), plan, &settings, Some(leader)) .into_body() .collect() .await .unwrap(); assert!(follower.await.unwrap()); assert_eq!(cache.view().collapsed_fills, 1); assert_eq!(cache.view().collapse_waiters, 0); } #[tokio::test] async fn single_flight_timeout_and_leader_drop_release_the_key() { let cache = Cache::new(22); let mut settings = active_settings(); settings.collapse_wait_timeout_ms = 10; let ticket = cache .ticket(22, "http", "collapse.example", "origin", "/timeout", &HeaderMap::new()) .unwrap(); let leader = match cache.claim_fill(&ticket, &settings).await { FillClaim::Leader(guard) => guard, _ => panic!("missing leader"), }; assert!(matches!(cache.claim_fill(&ticket, &settings).await, FillClaim::Timeout)); assert_eq!(cache.view().collapse_timeouts, 1); drop(leader); assert!(matches!( cache.claim_fill(&ticket, &settings).await, FillClaim::Leader(_) )); } #[tokio::test] async fn cancelled_single_flight_waiter_releases_waiter_metric() { let cache = Cache::new(22); let mut settings = active_settings(); settings.collapse_wait_timeout_ms = 5_000; let ticket = cache .ticket(22, "http", "collapse.example", "origin", "/cancel", &HeaderMap::new()) .unwrap(); let leader = match cache.claim_fill(&ticket, &settings).await { FillClaim::Leader(guard) => guard, _ => panic!("missing leader"), }; let follower_cache = cache.clone(); let follower_ticket = ticket.clone(); let follower_settings = settings.clone(); let follower = tokio::spawn(async move { follower_cache .claim_fill(&follower_ticket, &follower_settings) .await }); for _ in 0..50 { if cache.view().collapse_waiters == 1 { break; } tokio::time::sleep(Duration::from_millis(2)).await; } assert_eq!(cache.view().collapse_waiters, 1); follower.abort(); let _ = follower.await; tokio::task::yield_now().await; assert_eq!(cache.view().collapse_waiters, 0); drop(leader); } #[tokio::test] async fn single_flight_retries_after_another_vary_representation_is_published() { let cache = Cache::new(22); let mut settings = active_settings(); settings.collapse_wait_timeout_ms = 500; let mut en_headers = HeaderMap::new(); en_headers.insert("accept-language", HeaderValue::from_static("en")); let mut pl_headers = HeaderMap::new(); pl_headers.insert("accept-language", HeaderValue::from_static("pl")); let en = cache .ticket(22, "http", "vary-collapse.example", "origin", "/page", &en_headers) .unwrap(); let pl = cache .ticket(22, "http", "vary-collapse.example", "origin", "/page", &pl_headers) .unwrap(); assert_eq!(en.key, pl.key, "Vary is learned from the response"); let leader = match cache.claim_fill(&en, &settings).await { FillClaim::Leader(guard) => guard, _ => panic!("first representation must become leader"), }; let follower_cache = cache.clone(); let follower_ticket = pl.clone(); let follower_settings = settings.clone(); let follower = tokio::spawn(async move { assert!(matches!( follower_cache .claim_fill(&follower_ticket, &follower_settings) .await, FillClaim::Waited )); assert!(follower_cache.get(&follower_ticket).is_none()); matches!( follower_cache .claim_fill(&follower_ticket, &follower_settings) .await, FillClaim::Leader(_) ) }); for _ in 0..50 { if cache.view().collapse_waiters == 1 { break; } tokio::time::sleep(Duration::from_millis(2)).await; } let mut response = Response::new(Body::from("hello")); response.headers_mut().insert( "cache-control", HeaderValue::from_static("public, max-age=60"), ); response .headers_mut() .insert("vary", HeaderValue::from_static("Accept-Language")); let plan = plan( StatusCode::OK, response.headers(), &settings, Duration::ZERO, ) .unwrap(); cache .wrap_with_fill(response, en, plan, &settings, Some(leader)) .into_body() .collect() .await .unwrap(); assert!(follower.await.unwrap()); } #[test] fn stale_if_error_policy_is_configurable_by_error_class_and_status() { let mut settings = Settings::default(); settings.stale_if_error_timeout = false; settings.stale_if_error_dns = false; settings.stale_if_error_statuses = vec![500, 503]; assert!(!settings.allows_stale_error_class(StaleErrorClass::Timeout)); assert!(!settings.allows_stale_error_class(StaleErrorClass::Dns)); assert!(settings.allows_stale_error_class(StaleErrorClass::Connection)); assert!(settings.allows_stale_error_class(StaleErrorClass::Tls)); assert!(settings.allows_stale_error_status(StatusCode::INTERNAL_SERVER_ERROR)); assert!(settings.allows_stale_error_status(StatusCode::SERVICE_UNAVAILABLE)); assert!(!settings.allows_stale_error_status(StatusCode::BAD_GATEWAY)); } #[test] fn proactive_refresh_guards_deduplicate_and_bound_parallel_work() { let cache = Cache::new(22); let ticket = cache .ticket(22, "http", "refresh.example", "origin", "/asset", &HeaderMap::new()) .unwrap(); let mut settings = active_settings(); settings.proactive_refresh_enabled = true; settings.proactive_refresh_percent = 100; settings.proactive_refresh_max_inflight = 1; let scheduled = cache .begin_proactive_schedule(&ticket, &settings) .expect("first schedule"); assert!(cache.begin_proactive_schedule(&ticket, &settings).is_none()); let running = cache .try_begin_proactive_refresh(&settings) .expect("first inflight refresh"); assert!(cache.try_begin_proactive_refresh(&settings).is_none()); assert_eq!(cache.view().proactive_inflight, 1); drop(running); drop(scheduled); assert!(cache.begin_proactive_schedule(&ticket, &settings).is_some()); assert_eq!(cache.view().proactive_inflight, 0); }