#![allow(clippy::unwrap_used, reason = "test code")]
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use fetch::fake::{FakeDeps, FakeHandler};
use fetch::pipeline::RecoveryMode;
use fetch::{HttpClient, HttpError, HttpResponseBuilder};
use http::StatusCode;
use http_extensions::routing::Router;
use ohno::ErrorExt;
use seatbelt::retry::Attempt;
use seatbelt::{Recovery, RecoveryKind};
use templated_uri::BaseUri;
use tick::ClockControl;
const FAILING_HOST: &str = "https://failing.example.com";
const HEALTHY_HOST: &str = "https://healthy.example.com";
#[derive(Clone)]
struct Calls(Arc<AtomicUsize>);
impl Calls {
fn new() -> Self {
Self(Arc::new(AtomicUsize::new(0)))
}
fn increment(&self) {
self.0.fetch_add(1, Ordering::Relaxed);
}
fn get(&self) -> usize {
self.0.load(Ordering::Relaxed)
}
}
async fn send_requests(client: &HttpClient, uri: &str, count: usize) {
for _ in 0..count {
let _ = client.get(uri).fetch().await;
}
}
fn create_per_host_client(calls: Calls) -> HttpClient {
let handler = FakeHandler::from_fn(move |req| {
calls.increment();
let status = if req.uri().host() == Some("failing.example.com") {
StatusCode::INTERNAL_SERVER_ERROR
} else {
StatusCode::OK
};
HttpResponseBuilder::new_fake().status(status).build()
});
let clock = ClockControl::default().auto_advance_timers(true).to_clock();
HttpClient::builder_fake(handler, FakeDeps { clock })
.standard_pipeline(|pipeline, _| {
pipeline
.total_timeout(|t| t.timeout(Duration::MAX))
.attempt_timeout(|t| t.timeout(Duration::MAX))
.retry(|retry| retry.max_retry_attempts(3).base_delay(Duration::from_millis(1)))
.breaker(|breaker| {
breaker
.min_throughput(5)
.failure_threshold(0.5)
.break_duration(Duration::from_mins(1))
})
})
.build()
}
fn create_uniform_client(status: StatusCode, calls: Calls) -> HttpClient {
let handler = FakeHandler::from_fn(move |_req| {
calls.increment();
HttpResponseBuilder::new_fake().status(status).build()
});
let clock = ClockControl::default().auto_advance_timers(true).to_clock();
HttpClient::builder_fake(handler, FakeDeps { clock })
.standard_pipeline(|pipeline, _| {
pipeline
.total_timeout(|t| t.timeout(Duration::MAX))
.attempt_timeout(|t| t.timeout(Duration::MAX))
.retry(|retry| retry.max_retry_attempts(3).base_delay(Duration::from_millis(1)))
.breaker(|breaker| {
breaker
.min_throughput(5)
.failure_threshold(0.5)
.break_duration(Duration::from_mins(1))
})
})
.build()
}
#[cfg_attr(miri, ignore)]
#[tokio::test]
async fn breaker_trips_after_repeated_server_errors() {
let calls = Calls::new();
let client = create_uniform_client(StatusCode::INTERNAL_SERVER_ERROR, calls.clone());
send_requests(&client, "https://example.com", 10).await;
let calls_before = calls.get();
let error = client.get("https://example.com").fetch().await.unwrap_err();
assert!(
error.message().contains("circuit breaker"),
"expected 'circuit breaker' in message, got: {}",
error.message()
);
assert_eq!(error.recovery().kind(), RecoveryKind::Unavailable);
assert_eq!(calls.get(), calls_before);
}
#[cfg_attr(miri, ignore)]
#[tokio::test]
async fn retries_happen_before_breaker_trips() {
let calls = Calls::new();
let client = create_uniform_client(StatusCode::INTERNAL_SERVER_ERROR, calls.clone());
let response = client.get("https://example.com").fetch().await.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let attempt = response.extensions().get::<Attempt>().copied().unwrap();
assert_eq!(attempt, Attempt::new(3, true));
assert_eq!(calls.get(), 4);
}
#[cfg_attr(miri, ignore)]
#[tokio::test]
async fn success_does_not_trip_breaker() {
let calls = Calls::new();
let client = create_uniform_client(StatusCode::OK, calls.clone());
send_requests(&client, "https://example.com", 20).await;
assert_eq!(calls.get(), 20);
}
#[cfg_attr(miri, ignore)]
#[tokio::test]
async fn client_errors_do_not_trip_breaker() {
let calls = Calls::new();
let client = create_uniform_client(StatusCode::BAD_REQUEST, calls.clone());
send_requests(&client, "https://example.com", 20).await;
assert_eq!(calls.get(), 20);
}
#[cfg_attr(miri, ignore)]
#[tokio::test]
async fn breaker_is_isolated_per_origin() {
let calls = Calls::new();
let client = create_per_host_client(calls.clone());
send_requests(&client, FAILING_HOST, 20).await;
let error = client.get(FAILING_HOST).fetch().await.unwrap_err();
assert!(
error.message().contains("circuit breaker"),
"expected breaker open for failing host, got: {}",
error.message()
);
let response = client.get(HEALTHY_HOST).fetch().await.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"healthy host must not be affected by the failing host's breaker"
);
}
#[cfg_attr(miri, ignore)]
#[tokio::test]
async fn healthy_host_stays_healthy_while_failing_host_is_broken() {
let calls = Calls::new();
let client = create_per_host_client(calls.clone());
send_requests(&client, HEALTHY_HOST, 20).await;
send_requests(&client, FAILING_HOST, 20).await;
let error = client.get(FAILING_HOST).fetch().await.unwrap_err();
assert!(error.message().contains("circuit breaker"));
let response = client.get(HEALTHY_HOST).fetch().await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[cfg_attr(miri, ignore)]
#[tokio::test]
async fn breaker_rejection_attaches_request() {
let calls = Calls::new();
let client = create_uniform_client(StatusCode::INTERNAL_SERVER_ERROR, calls.clone());
send_requests(&client, "https://example.com", 20).await;
let mut error = client.get("https://example.com").fetch().await.unwrap_err();
assert!(error.message().contains("circuit breaker"));
assert!(error.take_request().is_some(), "rejected request should be attached to the error");
}
#[cfg_attr(miri, ignore)]
#[tokio::test]
async fn retry_receives_breaker_rejection() {
let calls = Calls::new();
let client = create_uniform_client(StatusCode::INTERNAL_SERVER_ERROR, calls.clone());
send_requests(&client, "https://example.com", 20).await;
let calls_before = calls.get();
let error = client.get("https://example.com").fetch().await.unwrap_err();
assert!(error.message().contains("circuit breaker"));
assert_eq!(calls.get(), calls_before, "handler should not be called when the breaker is open");
}
const HEDGING_DELAY: Duration = Duration::from_millis(100);
fn create_hedging_client(calls: Calls, responses: Vec<StatusCode>) -> HttpClient {
let clock = ClockControl::default().auto_advance_timers(true).to_clock();
let responses = Arc::new(std::sync::Mutex::new(responses.into_iter()));
let handler = FakeHandler::from_fn(move |_req| {
calls.increment();
let status = responses.lock().unwrap().next().unwrap_or(StatusCode::SERVICE_UNAVAILABLE);
HttpResponseBuilder::new_fake().status(status).build()
});
HttpClient::builder_fake(handler, FakeDeps { clock })
.standard_pipeline(|pipeline, _| {
pipeline
.total_timeout(|t| t.timeout(Duration::MAX))
.attempt_timeout(|t| t.timeout(Duration::MAX))
.recovery_mode(RecoveryMode::Hedging)
.hedging(|hedging| hedging.max_hedged_attempts(1).hedging_delay(HEDGING_DELAY))
})
.build()
}
#[cfg_attr(miri, ignore)]
#[tokio::test]
async fn hedging_returns_success_response() {
let calls = Calls::new();
let client = create_hedging_client(calls.clone(), vec![StatusCode::INTERNAL_SERVER_ERROR, StatusCode::OK]);
let response = client.get("https://example.com").fetch().await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(calls.get(), 2);
}
#[cfg_attr(miri, ignore)]
#[tokio::test]
async fn hedging_returns_first_success_immediately() {
let calls = Calls::new();
let client = create_hedging_client(calls.clone(), vec![StatusCode::OK, StatusCode::OK]);
let response = client.get("https://example.com").fetch().await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(calls.get(), 1, "no hedge should fire when the first attempt succeeds");
}
const PRIMARY_HOST: &str = "primary.example.com";
const SECONDARY_HOST: &str = "secondary.example.com";
#[derive(Clone, Default)]
struct HostCalls {
primary: Arc<AtomicUsize>,
secondary: Arc<AtomicUsize>,
}
#[cfg_attr(miri, ignore)]
#[tokio::test]
async fn fallback_router_recovers_when_primary_is_unavailable() {
let calls = HostCalls::default();
let handler_calls = calls.clone();
let handler = FakeHandler::from_fn(move |req| match req.uri().host() {
Some(PRIMARY_HOST) => {
handler_calls.primary.fetch_add(1, Ordering::Relaxed);
Err(HttpError::unavailable("primary is down").with_request(req))
}
Some(SECONDARY_HOST) => {
handler_calls.secondary.fetch_add(1, Ordering::Relaxed);
HttpResponseBuilder::new_fake().status(StatusCode::OK).build()
}
other => panic!("unexpected host: {other:?}"),
});
let clock = ClockControl::default().auto_advance_timers(true).to_clock();
let client = HttpClient::builder_fake(handler, FakeDeps { clock })
.router(Router::fallback(
BaseUri::from_static("https://primary.example.com/"),
BaseUri::from_static("https://secondary.example.com/"),
))
.standard_pipeline(|pipeline, _| {
pipeline
.total_timeout(|t| t.timeout(Duration::MAX))
.attempt_timeout(|t| t.timeout(Duration::MAX))
.retry(|retry| retry.max_retry_attempts(3).base_delay(Duration::from_millis(1)))
})
.build();
let response = client.get("/foo").fetch().await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let attempt = response.extensions().get::<Attempt>().copied().unwrap();
assert_eq!(attempt, Attempt::new(1, false));
assert_eq!(
calls.primary.load(Ordering::Relaxed),
1,
"primary endpoint should have been attempted exactly once"
);
assert_eq!(
calls.secondary.load(Ordering::Relaxed),
1,
"fallback endpoint should have served the request exactly once"
);
}
#[cfg_attr(miri, ignore)]
#[tokio::test]
async fn hedging_does_not_clone_unsafe_methods() {
let calls = Calls::new();
let client = create_hedging_client(calls.clone(), vec![StatusCode::INTERNAL_SERVER_ERROR, StatusCode::OK]);
let response = client.post("https://example.com").fetch().await.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(calls.get(), 1, "unsafe method should not produce a hedged attempt");
}