use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::collect::github::budget::MAX_RETRY_AFTER;
async fn request_count(server: &MockServer) -> usize {
server
.received_requests()
.await
.map(|r| r.len())
.unwrap_or_default()
}
#[tokio::test]
async fn a_secondary_rate_limit_is_retried_then_terminates() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/issues/1"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
.mount(&server)
.await;
let budget = FetchBudget::new();
let url = format!("{}/issues/1", server.uri());
let err = retry_get(&reqwest::Client::new(), &url, &budget)
.await
.expect_err("a permanently rate-limited endpoint must not return Ok");
assert!(
matches!(err, CollectError::Throttled { status: 429, .. }),
"expected Throttled, got {err:?}"
);
assert_eq!(
request_count(&server).await,
(MAX_RETRIES + 1) as usize,
"the attempt cap must bound how many requests one call makes"
);
assert!(
budget.tripped_error().is_some(),
"exhausting the attempt cap while still rate-limited must latch the breaker"
);
}
#[tokio::test]
async fn retry_after_is_honoured_instead_of_the_fixed_ladder() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/issues/2"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "45"))
.mount(&server)
.await;
let budget = FetchBudget::with_sleep_budget(Duration::from_secs(5));
let url = format!("{}/issues/2", server.uri());
let err = retry_get(&reqwest::Client::new(), &url, &budget)
.await
.expect_err("a wait the budget cannot afford must terminate the call");
match err {
CollectError::Throttled {
status,
retry_after,
} => {
assert_eq!(status, 429);
assert_eq!(
retry_after,
Some(Duration::from_secs(45)),
"the error must carry the server's own delay, not the fixed ladder"
);
}
other => panic!("expected Throttled, got {other:?}"),
}
assert_eq!(
request_count(&server).await,
1,
"an unaffordable Retry-After must stop the call, not spend three more attempts on it"
);
}
#[tokio::test]
async fn a_latched_budget_sends_no_further_requests() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/issues/3"))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
.mount(&server)
.await;
let budget = FetchBudget::new();
budget.trip(429, Some(Duration::from_secs(30)));
let url = format!("{}/issues/3", server.uri());
for _ in 0..25 {
let err = retry_get(&reqwest::Client::new(), &url, &budget)
.await
.expect_err("a latched budget must refuse every call");
assert!(matches!(err, CollectError::Throttled { .. }));
}
assert_eq!(
request_count(&server).await,
0,
"not one request may leave the process after the breaker latches"
);
}
#[tokio::test]
async fn a_transient_5xx_still_recovers() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/issues/4"))
.respond_with(ResponseTemplate::new(502))
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/issues/4"))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
.mount(&server)
.await;
let budget = FetchBudget::new();
let url = format!("{}/issues/4", server.uri());
let resp = retry_get(&reqwest::Client::new(), &url, &budget)
.await
.expect("a single transient 502 must still recover");
assert_eq!(resp.status().as_u16(), 200);
assert_eq!(request_count(&server).await, 2);
}
#[tokio::test]
async fn a_403_without_rate_limit_evidence_is_returned_immediately() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/issues/5"))
.respond_with(
ResponseTemplate::new(403)
.insert_header("x-ratelimit-remaining", "4999")
.set_body_string("Resource not accessible by personal access token"),
)
.mount(&server)
.await;
let budget = FetchBudget::new();
let url = format!("{}/issues/5", server.uri());
let resp = retry_get(&reqwest::Client::new(), &url, &budget)
.await
.expect("a scope failure is a response, not a retry");
assert_eq!(resp.status().as_u16(), 403);
assert_eq!(request_count(&server).await, 1);
assert!(budget.tripped_error().is_none());
}
#[tokio::test]
async fn a_drained_primary_quota_terminates_rather_than_spinning() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/issues/6"))
.respond_with(
ResponseTemplate::new(403)
.insert_header("x-ratelimit-remaining", "0")
.insert_header("x-ratelimit-reset", "99999999999"),
)
.mount(&server)
.await;
let budget = FetchBudget::with_sleep_budget(MAX_RETRY_AFTER / 2);
let url = format!("{}/issues/6", server.uri());
let err = retry_get(&reqwest::Client::new(), &url, &budget)
.await
.expect_err("a drained quota must terminate the run's GitHub fetching");
assert!(matches!(err, CollectError::Throttled { status: 403, .. }));
assert_eq!(request_count(&server).await, 1);
}