use std::sync::{Mutex, OnceLock, PoisonError};
use std::time::{Duration, Instant};
pub const STRIKES: u32 = 2;
const BACKOFF_BASE: Duration = Duration::from_secs(5 * 60);
const BACKOFF_MAX: Duration = Duration::from_secs(60 * 60);
#[must_use]
pub fn is_auth_failure(message: &str) -> bool {
let m = message.to_ascii_lowercase();
[
"gh auth login",
"authentication required",
"requires authentication",
"gh_token",
"github_token",
"bad credentials",
]
.iter()
.any(|needle| m.contains(needle))
}
fn backoff_for(consecutive: u32) -> Duration {
let steps = consecutive.saturating_sub(STRIKES).min(16);
let secs = BACKOFF_BASE.as_secs().saturating_mul(1u64 << steps);
Duration::from_secs(secs.min(BACKOFF_MAX.as_secs()))
}
#[derive(Default)]
struct State {
consecutive: u32,
suspended_until: Option<Instant>,
reason: Option<String>,
reported: bool,
}
#[derive(Default)]
pub struct AuthBackoff {
state: Mutex<State>,
}
impl AuthBackoff {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn may_poll(&self, now: Instant) -> bool {
let mut st = self.lock();
match st.suspended_until {
Some(until) if now < until => false,
Some(_) => {
st.suspended_until = None;
st.reason = None;
st.reported = false;
true
}
None => true,
}
}
pub fn record_auth_failure(&self, now: Instant, detail: &str) -> bool {
let mut st = self.lock();
st.consecutive = st.consecutive.saturating_add(1);
if st.consecutive < STRIKES {
return true;
}
let window = backoff_for(st.consecutive);
st.suspended_until = Some(now + window);
st.reason = Some(format!(
"pr-cleanup sweep suspended for {}s after {} consecutive `gh` authentication \
failures: {}. Run `gh auth login`; the sweep resumes on its own.",
window.as_secs(),
st.consecutive,
detail.trim()
));
let first_report = !st.reported;
st.reported = true;
first_report
}
pub fn record_answer(&self) {
let mut st = self.lock();
st.consecutive = 0;
st.suspended_until = None;
st.reason = None;
st.reported = false;
}
#[must_use]
pub fn degraded_reason(&self) -> Option<String> {
self.lock().reason.clone()
}
fn lock(&self) -> std::sync::MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
}
pub fn shared() -> &'static AuthBackoff {
static GATE: OnceLock<AuthBackoff> = OnceLock::new();
GATE.get_or_init(AuthBackoff::new)
}
#[cfg(test)]
#[path = "auth_backoff_tests.rs"]
mod auth_backoff_tests;