use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use crate::sync_util::LockExt;
const MAX_THROTTLE: Duration = Duration::from_secs(24 * 60 * 60);
struct Entry {
until: Instant,
scope: Option<String>,
}
static GATE: LazyLock<Mutex<HashMap<String, Entry>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
pub(crate) fn note(endpoint: &str, wait: Duration, scope: Option<String>) {
if wait.is_zero() {
return;
}
let until = Instant::now() + wait.min(MAX_THROTTLE);
let mut gate = GATE.lock_ignore_poison();
match gate.get_mut(endpoint) {
Some(existing) if existing.until >= until => {}
_ => {
gate.insert(
endpoint.to_string(),
Entry {
until,
scope: scope.clone(),
},
);
tracing::warn!(
target: "dirge::rate_limit",
endpoint = %endpoint,
wait_secs = wait.as_secs(),
scope = scope.as_deref().unwrap_or("-"),
"provider rate limit reached; suppressing requests until it resets"
);
}
}
}
pub(crate) fn note_from_error(endpoint: &str, error_msg: &str) -> Option<Duration> {
use crate::agent::recovery::{ErrorKind, classify_error, rate_limit_signal};
if !matches!(
classify_error(error_msg),
ErrorKind::RateLimit | ErrorKind::UsageCap
) {
return None;
}
let signal = rate_limit_signal(error_msg);
let wait = match (signal.exhausted, signal.reset_in) {
(true, Some(reset)) if !reset.is_zero() => reset,
_ => crate::agent::recovery::retry_after_from_error_msg(error_msg)
.filter(|d| !d.is_zero())?,
};
note(endpoint, wait, signal.scope);
Some(wait)
}
pub(crate) fn note_from_headers(endpoint: &str, headers: &http::HeaderMap) -> Option<Duration> {
let mut text = String::from("429 Too Many Requests");
for (name, value) in headers.iter() {
let name = name.as_str();
let relevant = name.starts_with("x-ratelimit")
|| name.starts_with("anthropic-ratelimit")
|| name == "retry-after"
|| name == "retry-after-ms";
if relevant && let Ok(value) = value.to_str() {
text.push('\n');
text.push_str(name);
text.push_str(": ");
text.push_str(value);
}
}
note_from_error(endpoint, &text)
}
pub(crate) fn remaining(endpoint: &str) -> Option<(Duration, Option<String>)> {
let mut gate = GATE.lock_ignore_poison();
let entry = gate.get(endpoint)?;
let now = Instant::now();
if entry.until <= now {
gate.remove(endpoint);
return None;
}
Some((entry.until - now, entry.scope.clone()))
}
pub(crate) fn clear(endpoint: &str) {
GATE.lock_ignore_poison().remove(endpoint);
}
pub(crate) fn suppressed_error_message(wait: Duration, scope: Option<&str>) -> String {
let window = scope.map(|s| format!(" (window: {s})")).unwrap_or_default();
format!(
"429 Too Many Requests — dirge did not send this request: the provider's rate limit \
is still in effect{window} and retrying before it resets cannot succeed. \
Retry-After: {}",
wait.as_secs().max(1),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::recovery::{ErrorKind, classify_error};
fn endpoint(name: &str) -> String {
format!("test-{name}.invalid")
}
#[test]
fn unknown_endpoint_is_not_throttled() {
assert!(remaining(&endpoint("unknown")).is_none());
}
#[test]
fn noting_a_wait_throttles_the_endpoint() {
let ep = endpoint("basic");
note(&ep, Duration::from_secs(60), Some("per-min".into()));
let (left, scope) = remaining(&ep).expect("endpoint should be throttled");
assert!(left <= Duration::from_secs(60) && left > Duration::from_secs(55));
assert_eq!(scope.as_deref(), Some("per-min"));
}
#[test]
fn a_lapsed_throttle_reports_clear() {
let ep = endpoint("lapsed");
note(&ep, Duration::from_millis(1), None);
std::thread::sleep(Duration::from_millis(20));
assert!(
remaining(&ep).is_none(),
"throttle should expire on its own"
);
}
#[test]
fn a_zero_wait_records_nothing() {
let ep = endpoint("zero");
note(&ep, Duration::ZERO, None);
assert!(remaining(&ep).is_none());
}
#[test]
fn a_later_deadline_wins_over_an_earlier_one() {
let ep = endpoint("extend");
note(&ep, Duration::from_secs(300), None);
note(&ep, Duration::from_secs(5), None);
let (left, _) = remaining(&ep).expect("still throttled");
assert!(
left > Duration::from_secs(60),
"a shorter later report must not shrink the window, got {left:?}",
);
}
#[test]
fn success_clears_the_throttle() {
let ep = endpoint("clear");
note(&ep, Duration::from_secs(300), None);
clear(&ep);
assert!(remaining(&ep).is_none());
}
#[test]
fn an_absurd_wait_is_capped() {
let ep = endpoint("absurd");
note(&ep, Duration::from_secs(400 * 24 * 3600), None);
let (left, _) = remaining(&ep).expect("still throttled");
assert!(left <= MAX_THROTTLE, "must clamp, got {left:?}");
}
#[test]
fn openrouter_per_day_429_latches_the_endpoint() {
let ep = endpoint("openrouter-day");
let reset = (chrono::Utc::now() + chrono::Duration::hours(14)).timestamp_millis();
let msg = format!(
r#"Invalid status code 429 Too Many Requests with message: {{"error":{{"message":"Rate limit exceeded: free-models-per-day. Add 10 credits to unlock 1000 free model requests per day","code":429,"metadata":{{"headers":{{"X-RateLimit-Limit":"50","X-RateLimit-Remaining":"0","X-RateLimit-Reset":"{reset}"}}}}}}}}"#
);
let wait = note_from_error(&ep, &msg).expect("definitive signal should latch");
assert!(wait > Duration::from_secs(13 * 3600));
let (_, scope) = remaining(&ep).expect("throttled");
assert_eq!(scope.as_deref(), Some("free-models-per-day"));
}
#[test]
fn a_bare_429_does_not_latch() {
let ep = endpoint("bare");
assert!(note_from_error(&ep, "HTTP 429 Too Many Requests").is_none());
assert!(remaining(&ep).is_none());
}
#[test]
fn a_network_error_does_not_latch() {
let ep = endpoint("network");
assert!(note_from_error(&ep, "connection reset by peer").is_none());
assert!(remaining(&ep).is_none());
}
#[test]
fn header_map_with_an_exhausted_dimension_latches() {
let ep = endpoint("headers-groq");
let mut headers = http::HeaderMap::new();
headers.insert("x-ratelimit-remaining-requests", "0".parse().unwrap());
headers.insert("x-ratelimit-reset-requests", "2m59.56s".parse().unwrap());
headers.insert("x-ratelimit-remaining-tokens", "12000".parse().unwrap());
headers.insert("x-ratelimit-reset-tokens", "7.66s".parse().unwrap());
let wait = note_from_headers(&ep, &headers).expect("exhausted requests dimension latches");
assert_eq!(
wait,
Duration::from_millis(179_560),
"must wait on the exhausted dimension, not the healthy one",
);
}
#[test]
fn header_map_prefers_retry_after() {
let ep = endpoint("headers-anthropic");
let mut headers = http::HeaderMap::new();
headers.insert("retry-after", "12".parse().unwrap());
headers.insert(
"anthropic-ratelimit-requests-reset",
(chrono::Utc::now() + chrono::Duration::seconds(600))
.to_rfc3339()
.parse()
.unwrap(),
);
assert_eq!(
note_from_headers(&ep, &headers),
Some(Duration::from_secs(12)),
);
}
#[test]
fn header_map_without_rate_limit_info_does_not_latch() {
let ep = endpoint("headers-empty");
let mut headers = http::HeaderMap::new();
headers.insert("content-type", "application/json".parse().unwrap());
assert!(note_from_headers(&ep, &headers).is_none());
assert!(remaining(&ep).is_none());
}
#[test]
fn an_explicit_retry_after_latches() {
let ep = endpoint("retry-after");
let wait = note_from_error(&ep, "429 Too Many Requests; Retry-After: 30")
.expect("Retry-After is definitive");
assert_eq!(wait, Duration::from_secs(30));
}
#[test]
fn suppressed_message_round_trips_as_a_retryable_rate_limit() {
let msg = suppressed_error_message(Duration::from_secs(42), Some("free-models-per-min"));
assert_eq!(classify_error(&msg), ErrorKind::RateLimit);
let policy = crate::agent::recovery::RecoveryPolicy::default();
let backoff = policy.backoff_duration_for_msg(0, &msg);
assert!(
backoff >= Duration::from_secs(42) && backoff <= Duration::from_secs(45),
"backoff should track the remaining wait, got {backoff:?}",
);
}
#[test]
fn suppressed_message_round_trips_as_a_usage_cap_when_long() {
let msg =
suppressed_error_message(Duration::from_secs(14 * 3600), Some("free-models-per-day"));
assert_eq!(classify_error(&msg), ErrorKind::UsageCap);
assert!(
!crate::agent::recovery::RecoveryPolicy::default()
.should_retry(0, classify_error(&msg))
);
}
}