const PREFIX: &str = "anthropic-ratelimit-unified-";
#[derive(Debug, Default, Clone)]
pub struct Quota {
pub rejected: bool,
pub statuses: Vec<(String, String)>,
pub reset_secs: Option<i64>,
}
pub const SPENT_FOR_SECS: i64 = 900;
impl Quota {
pub fn still_spent(&self, now: i64) -> bool {
self.rejected && self.reset_secs.is_none_or(|r| now < r)
}
pub fn still_spent_since(&self, marked_at: i64, now: i64) -> bool {
match self.reset_secs {
Some(_) => self.still_spent(now),
None => self.rejected && now < marked_at + SPENT_FOR_SECS,
}
}
pub fn rejected_windows(&self) -> Vec<&str> {
self.statuses
.iter()
.filter(|(_, v)| v.trim().eq_ignore_ascii_case("rejected"))
.map(|(k, _)| k.as_str())
.collect()
}
}
pub fn from_headers(headers: &[(String, String)]) -> Option<Quota> {
let mut q = Quota::default();
let mut seen = false;
for (name, value) in headers {
let lower = name.to_ascii_lowercase();
let Some(rest) = lower.strip_prefix(PREFIX) else {
continue;
};
seen = true;
if rest == "status" || rest.ends_with("-status") {
if value.trim().eq_ignore_ascii_case("rejected") {
q.rejected = true;
}
q.statuses.push((rest.to_string(), value.clone()));
} else if rest == "reset" || rest.ends_with("-reset") {
if let Ok(n) = value.trim().parse::<i64>() {
q.reset_secs = Some(q.reset_secs.map_or(n, |cur| cur.min(n)));
}
}
}
seen.then_some(q)
}
pub fn retry_unrewritten(status: u16, body_was_rewritten: bool, already_retried: u32) -> bool {
body_was_rewritten && already_retried == 0 && matches!(status, 400 | 422)
}
#[derive(Debug, PartialEq)]
pub enum Throttle {
RetryAfter(std::time::Duration),
Exhausted,
}
pub fn classify_429(headers: &[(String, String)], attempt: u32) -> Throttle {
const MAX_RETRIES: u32 = 3;
let quota_says_spent = from_headers(headers).is_some_and(|q| q.rejected);
let retryable = headers.iter().any(|(n, v)| {
n.eq_ignore_ascii_case("x-should-retry") && v.trim().eq_ignore_ascii_case("true")
});
if quota_says_spent || !retryable || attempt >= MAX_RETRIES {
return Throttle::Exhausted;
}
let after = headers
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case("retry-after"))
.and_then(|(_, v)| v.trim().parse::<u64>().ok())
.unwrap_or(1u64 << attempt);
Throttle::RetryAfter(std::time::Duration::from_secs(after.min(8)))
}
#[cfg(test)]
mod tests {
use super::*;
fn h(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect()
}
#[test]
fn allowed_status_is_not_rejected() {
let q = from_headers(&h(&[
("anthropic-ratelimit-unified-status", "allowed"),
("anthropic-ratelimit-unified-5h-status", "allowed"),
]))
.expect("quota seen");
assert!(!q.rejected);
}
#[test]
fn any_rejected_window_marks_the_account_spent() {
let q = from_headers(&h(&[
("anthropic-ratelimit-unified-status", "allowed"),
("anthropic-ratelimit-unified-7d-status", "rejected"),
]))
.expect("quota seen");
assert!(q.rejected, "a rejected window exhausts the account");
}
#[test]
fn reset_is_the_soonest_and_absent_headers_yield_none() {
let q = from_headers(&h(&[
("anthropic-ratelimit-unified-status", "allowed_warning"),
("anthropic-ratelimit-unified-7d-reset", "1900000000"),
("anthropic-ratelimit-unified-5h-reset", "1800000000"),
]))
.expect("quota seen");
assert_eq!(q.reset_secs, Some(1_800_000_000), "soonest reset wins");
assert!(
from_headers(&h(&[("content-type", "application/json")])).is_none(),
"a response with no unified headers must not overwrite known state"
);
}
#[test]
fn rejected_windows_names_only_the_closed_ones() {
let q = from_headers(&h(&[
("anthropic-ratelimit-unified-5h-status", "allowed"),
("anthropic-ratelimit-unified-7d-status", "rejected"),
("anthropic-ratelimit-unified-status", "allowed_warning"),
]))
.expect("quota seen");
assert_eq!(q.rejected_windows(), vec!["7d-status"]);
let none = from_headers(&h(&[("anthropic-ratelimit-unified-status", "allowed")]))
.expect("quota seen");
assert!(none.rejected_windows().is_empty());
}
#[test]
fn a_retryable_429_is_a_throttle_and_a_spent_one_is_not() {
let throttle = h(&[("x-should-retry", "true")]);
assert_eq!(
classify_429(&throttle, 0),
Throttle::RetryAfter(std::time::Duration::from_secs(1))
);
assert_eq!(
classify_429(&throttle, 2),
Throttle::RetryAfter(std::time::Duration::from_secs(4)),
"backoff grows with the attempt"
);
assert_eq!(
classify_429(&throttle, 3),
Throttle::Exhausted,
"retries are bounded so a throttled account cannot loop forever"
);
assert_eq!(
classify_429(&h(&[("x-should-retry", "true"), ("retry-after", "5")]), 0),
Throttle::RetryAfter(std::time::Duration::from_secs(5))
);
assert_eq!(
classify_429(&h(&[("x-should-retry", "true"), ("retry-after", "600")]), 0),
Throttle::RetryAfter(std::time::Duration::from_secs(8)),
"a huge retry-after is capped - rotating beats sleeping for minutes"
);
assert_eq!(
classify_429(
&h(&[
("x-should-retry", "true"),
("anthropic-ratelimit-unified-status", "rejected")
]),
0
),
Throttle::Exhausted,
"a rejected window means the wall, not a throttle"
);
assert_eq!(classify_429(&h(&[]), 0), Throttle::Exhausted);
}
#[test]
fn header_names_are_matched_case_insensitively() {
let q = from_headers(&h(&[("Anthropic-RateLimit-Unified-Status", "REJECTED")]))
.expect("quota seen");
assert!(q.rejected);
}
}
#[cfg(test)]
mod spent_expiry_tests {
use super::*;
#[test]
fn a_spent_account_comes_back_when_its_window_resets() {
let q = Quota {
rejected: true,
statuses: Vec::new(),
reset_secs: Some(1_000),
};
assert!(q.still_spent(999), "before the reset it is out");
assert!(!q.still_spent(1_000), "at the reset it is back");
assert!(!q.still_spent(5_000), "and stays back");
}
#[test]
fn with_no_reset_reported_it_lapses_on_a_window() {
let q = Quota {
rejected: true,
statuses: Vec::new(),
reset_secs: None,
};
assert!(q.still_spent_since(100, 100), "just now");
assert!(q.still_spent_since(100, 100 + SPENT_FOR_SECS - 1));
assert!(!q.still_spent_since(100, 100 + SPENT_FOR_SECS));
}
#[test]
fn an_account_that_was_never_refused_is_never_held_out() {
let q = Quota::default();
assert!(!q.still_spent(0));
assert!(!q.still_spent_since(0, 0));
}
}
pub const CLIENT_SLEEPS_UP_TO_SECS: u64 = 20;
pub fn cap_retry_after(
headers: &[(String, String)],
somewhere_to_go: bool,
) -> Vec<(String, String)> {
if !somewhere_to_go {
return headers.to_vec();
}
headers
.iter()
.map(|(n, v)| {
if n.eq_ignore_ascii_case("retry-after") {
let secs = v
.trim()
.parse::<u64>()
.map(|s| s.min(CLIENT_SLEEPS_UP_TO_SECS))
.unwrap_or(CLIENT_SLEEPS_UP_TO_SECS);
(n.clone(), secs.to_string())
} else {
(n.clone(), v.clone())
}
})
.collect()
}
#[cfg(test)]
mod retry_after_tests {
use super::*;
fn h(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect()
}
fn get<'a>(hs: &'a [(String, String)], name: &str) -> Option<&'a str> {
hs.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
#[test]
fn a_long_wait_is_capped_when_another_account_could_serve() {
let out = cap_retry_after(&h(&[("Retry-After", "3600"), ("x-other", "keep")]), true);
assert_eq!(
get(&out, "retry-after"),
Some("20"),
"the client sleeps instead of cooling down for 30 minutes"
);
assert_eq!(
get(&out, "x-other"),
Some("keep"),
"nothing else is touched"
);
}
#[test]
fn a_short_wait_is_left_alone() {
let out = cap_retry_after(&h(&[("retry-after", "5")]), true);
assert_eq!(get(&out, "retry-after"), Some("5"));
}
#[test]
fn the_real_wait_stands_when_there_is_nowhere_to_go() {
let out = cap_retry_after(&h(&[("retry-after", "3600")]), false);
assert_eq!(get(&out, "retry-after"), Some("3600"));
}
#[test]
fn a_response_without_the_header_is_unchanged() {
let out = cap_retry_after(&h(&[("content-type", "application/json")]), true);
assert_eq!(out.len(), 1);
assert_eq!(get(&out, "content-type"), Some("application/json"));
}
}
pub fn account_cannot_serve(status: u16) -> bool {
matches!(status, 401 | 403 | 429)
}
#[cfg(test)]
mod failover_status_tests {
use super::*;
#[test]
fn a_lapsed_subscription_moves_the_turn_along() {
assert!(
account_cannot_serve(403),
"403: this account is not entitled"
);
assert!(account_cannot_serve(401), "401: its login is not accepted");
assert!(account_cannot_serve(429), "429: it is out of quota");
}
#[test]
fn a_broken_request_is_not_an_account_problem() {
for s in [200, 400, 404, 500, 529] {
assert!(!account_cannot_serve(s), "{s} is not the account's fault");
}
}
}
pub fn attempts_against_next_account() -> u32 {
0
}
pub fn proven_spent(headers: &[(String, String)], attempt: u32) -> bool {
from_headers(headers).is_some_and(|q| q.rejected) || attempt >= 3
}
#[cfg(test)]
mod evidence_tests {
use super::*;
fn h(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect()
}
#[test]
fn the_response_saying_rejected_is_proof() {
assert!(proven_spent(
&h(&[("anthropic-ratelimit-unified-status", "rejected")]),
0
));
}
#[test]
fn a_bare_refusal_is_not_proof() {
assert!(!proven_spent(&h(&[]), 0));
assert!(!proven_spent(&h(&[("retry-after", "5")]), 0));
}
#[test]
fn refusing_past_the_retries_is_proof_enough() {
assert!(proven_spent(&h(&[]), 3));
}
#[test]
fn a_window_reported_healthy_is_never_called_spent_early() {
let ok = h(&[("anthropic-ratelimit-unified-status", "allowed")]);
assert!(!proven_spent(&ok, 0));
assert!(!proven_spent(&ok, 2));
}
}
#[cfg(test)]
mod rewrite_retry_tests {
use super::*;
#[test]
fn a_rejected_rewrite_is_worth_one_try_as_the_client_wrote_it() {
assert!(retry_unrewritten(400, true, 0));
assert!(!retry_unrewritten(400, false, 0));
assert!(!retry_unrewritten(400, true, 1));
}
#[test]
fn other_failures_are_not_blamed_on_the_rewrite() {
for status in [200, 401, 403, 429, 500, 529] {
assert!(!retry_unrewritten(status, true, 0), "{status}");
}
assert!(retry_unrewritten(422, true, 0));
}
}
#[cfg(test)]
mod attempt_scope_tests {
use super::*;
#[test]
fn a_retry_count_does_not_follow_the_turn_to_another_account() {
assert!(!proven_spent(&[], 0), "no headers, no attempts: not proven");
assert!(
proven_spent(&[], 3),
"three tries at one account is the rule"
);
assert_eq!(attempts_against_next_account(), 0);
}
}