use std::future::Future;
use anodizer_core::retry::{RetryPolicy, is_retriable, jitter_duration};
use super::secondary_rate_limit::{RetryAfterCapture, is_secondary_rate_limit, secondary_rl_delay};
use crate::release_log;
pub(crate) fn format_retry_warn(label: &str, attempt: u32, max: u32, status: u16) -> String {
let cause = if status == 0 {
"transport error (no HTTP response)".to_string()
} else {
format!("status={status}")
};
format!("{label} failed (attempt {attempt}/{max}, {cause}); will retry")
}
pub(crate) fn format_retry_succeeded(label: &str, attempts: u32) -> String {
format!("{label} succeeded after {attempts} attempt(s)")
}
pub(crate) fn format_retry_giving_up(label: &str, attempts: u32) -> String {
format!("{label} failed after {attempts} attempt(s), giving up")
}
pub(crate) async fn retry_octocrab_call<T, F, Fut>(
policy: &RetryPolicy,
label: &'static str,
retry_after: Option<&RetryAfterCapture>,
mut make_call: F,
) -> Result<T, octocrab::Error>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, octocrab::Error>>,
{
let max = policy.max_attempts.max(1);
let mut attempt: u32 = 1;
let mut last_err: Option<octocrab::Error> = None;
loop {
let skip_policy_sleep = last_err.as_ref().is_some_and(is_secondary_rate_limit);
if attempt > 1 && !skip_policy_sleep {
tokio::time::sleep(policy.delay_for(attempt)).await;
}
match make_call().await {
Ok(v) => {
if attempt > 1 {
release_log().status(&format_retry_succeeded(label, attempt));
}
return Ok(v);
}
Err(err) => {
let secondary_rl = is_secondary_rate_limit(&err);
let (status, retriable) = classify_retriability(&err);
if !retriable && !secondary_rl {
return Err(err);
}
release_log().warn(&format_retry_warn(label, attempt, max, status));
if attempt >= max {
release_log().warn(&format_retry_giving_up(label, attempt));
return Err(err);
}
if secondary_rl {
let delay = jitter_duration(secondary_rl_delay(retry_after));
release_log().warn(&format!(
"{label} hit GitHub secondary rate limit; \
sleeping {:.1}s before retry (attempt {attempt}/{max})",
delay.as_secs_f64(),
));
tokio::time::sleep(delay).await;
}
last_err = Some(err);
}
}
attempt += 1;
}
}
fn classify_retriability(err: &octocrab::Error) -> (u16, bool) {
use anodizer_core::retry::{HttpError, Retriable};
match err {
octocrab::Error::GitHub { source, .. } => {
let status = source.status_code.as_u16();
let probe = HttpError::new(std::io::Error::other("status probe"), status);
(status, is_retriable(&probe))
}
octocrab::Error::Hyper { .. }
| octocrab::Error::Http { .. }
| octocrab::Error::Service { .. }
| octocrab::Error::Other { .. }
| octocrab::Error::Serde { .. }
| octocrab::Error::Json { .. } => {
let probe = Retriable::new(std::io::Error::other("transport probe"));
(0, is_retriable(&probe))
}
_ => {
(0, false)
}
}
}
pub(crate) fn is_octocrab_404(err: &octocrab::Error) -> bool {
matches!(
err,
octocrab::Error::GitHub { source, .. } if source.status_code.as_u16() == 404
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::build_test_octocrab;
use anodizer_core::test_helpers::responder::spawn_oneshot_http_responder;
use std::sync::atomic::Ordering;
use std::time::Duration;
#[tokio::test]
async fn retries_5xx_then_succeeds() {
let (addr, calls) = spawn_oneshot_http_responder(vec![
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n[]",
]);
let octo = build_test_octocrab(addr);
let policy = RetryPolicy {
max_attempts: 5,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let result: Result<Vec<serde_json::Value>, octocrab::Error> =
retry_octocrab_call(&policy, "test list", None, || async {
octo.get("/test", None::<&()>).await
})
.await;
assert!(
result.is_ok(),
"5xx must retry to success: {:?}",
result.err()
);
assert_eq!(
calls.load(Ordering::SeqCst),
3,
"expected 2 retries past 503 + 1 success"
);
}
#[tokio::test]
async fn fast_fails_4xx_without_retry() {
let (addr, calls) = spawn_oneshot_http_responder(vec![
"HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nContent-Length: 27\r\n\r\n{\"message\":\"Not Found\"} ",
]);
let octo = build_test_octocrab(addr);
let policy = RetryPolicy {
max_attempts: 5,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let result: Result<Vec<serde_json::Value>, octocrab::Error> =
retry_octocrab_call(&policy, "test list", None, || async {
octo.get("/test", None::<&()>).await
})
.await;
assert!(result.is_err(), "4xx must surface as Err, got Ok");
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"4xx must NOT retry (fast-fail honors classifier)"
);
}
#[tokio::test]
async fn respects_max_attempts_one() {
let (addr, calls) = spawn_oneshot_http_responder(vec![
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
]);
let octo = build_test_octocrab(addr);
let policy = RetryPolicy {
max_attempts: 1,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let result: Result<Vec<serde_json::Value>, octocrab::Error> =
retry_octocrab_call(&policy, "test list", None, || async {
octo.get("/test", None::<&()>).await
})
.await;
assert!(result.is_err(), "attempts=1 + 503 must surface Err");
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"attempts=1 must produce exactly one octocrab call"
);
}
#[test]
fn format_retry_warn_shape_pins_shared_format() {
let s = format_retry_warn("delete release", 3, 10, 503);
assert_eq!(
s,
"delete release failed (attempt 3/10, status=503); will retry"
);
}
#[test]
fn format_retry_warn_status_zero_reads_as_transport_error() {
let s = format_retry_warn("create release", 1, 10, 0);
assert_eq!(
s,
"create release failed (attempt 1/10, transport error (no HTTP response)); will retry"
);
assert!(
!s.contains("status=0"),
"transport-error warning must not contain a misleading `status=0`: {s}"
);
assert!(
s.contains("will retry"),
"per-attempt warning must read as a retry, not a terminal failure: {s}"
);
}
#[test]
fn format_retry_succeeded_shape() {
assert_eq!(
format_retry_succeeded("create release", 3),
"create release succeeded after 3 attempt(s)"
);
}
#[test]
fn format_retry_giving_up_shape() {
assert_eq!(
format_retry_giving_up("create release", 10),
"create release failed after 10 attempt(s), giving up"
);
}
#[tokio::test]
#[serial_test::serial(secondary_rl_env)]
async fn secondary_rate_limit_403_retries_with_delay() {
use std::time::Instant;
let body_403 = r#"{"message":"You have exceeded a secondary rate limit and have been temporarily blocked from content creation. Please retry your request again later.","documentation_url":"https://docs.github.com/rest/overview/resources-in-the-rest-api#secondary-rate-limits"}"#;
let body_len = body_403.len();
let resp_403 = Box::leak(
format!(
"HTTP/1.1 403 Forbidden\r\n\
Content-Type: application/json\r\n\
Retry-After: 2\r\n\
Content-Length: {body_len}\r\n\
\r\n\
{body_403}"
)
.into_boxed_str(),
);
let resp_200 =
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n[]";
let (addr, calls) = spawn_oneshot_http_responder(vec![resp_403, resp_200]);
let octo = build_test_octocrab(addr);
let policy = RetryPolicy {
max_attempts: 5,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
unsafe {
std::env::set_var("ANODIZER_GITHUB_SECONDARY_RL_DELAY_SECS", "1");
}
let t0 = Instant::now();
let result: Result<Vec<serde_json::Value>, octocrab::Error> =
retry_octocrab_call(&policy, "test upload", None, || async {
octo.get("/test", None::<&()>).await
})
.await;
let elapsed = t0.elapsed();
unsafe {
std::env::remove_var("ANODIZER_GITHUB_SECONDARY_RL_DELAY_SECS");
}
assert!(
result.is_ok(),
"403 secondary-RL must retry to success: {:?}",
result.err()
);
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"expected exactly 2 calls: 1 secondary-RL 403 + 1 success 200"
);
assert!(
elapsed >= Duration::from_millis(800),
"secondary-RL delay must hold for at least 800 ms (jitter floor is 80 % of 1 s; \
elapsed: {elapsed:?})"
);
}
}