bifrost: stop sleeping on a spent quota window
retry-after was honoured unclamped, so an 11997s header on 2026-08-16 parked the subconscious pass for 3h20m with no cancel path. A 429 asking for more than 60s is a spent window, not a burst, and it does not clear inside the retry loop — fail it and name the number. claude_subscription answers the same header by rotating logins; bifrost has nothing to rotate to, so an absent header keeps the ordinary backoff.
This commit is contained in:
parent
06a1a035a6
commit
3b11903365
1 changed files with 80 additions and 2 deletions
|
|
@ -456,6 +456,33 @@ fn retry_cap(body: &str, max_retries: u32) -> u32 {
|
|||
}
|
||||
}
|
||||
|
||||
/// Longest `retry-after` still worth sleeping on rather than failing the call.
|
||||
///
|
||||
/// A window this long is a spent quota, not a burst limit, and it does not
|
||||
/// clear inside a retry loop. Measured 2026-08-16: an `11997` header put the
|
||||
/// subconscious pass to sleep for 3h20m with no cancel path and nothing on the
|
||||
/// wire to say it was waiting rather than dead.
|
||||
///
|
||||
/// `claude_subscription.rs` learned this first and answers it by rotating to
|
||||
/// another login, so there an *absent* `retry-after` also counts as exhausted.
|
||||
/// Bifrost has nothing to rotate to, so an absent header keeps the ordinary
|
||||
/// jittered backoff — only an explicitly long one fails fast.
|
||||
const BURST_RETRY_CEILING_SECS: u64 = 60;
|
||||
|
||||
fn is_exhausted_window(status: reqwest::StatusCode, retry_after: Option<Duration>) -> bool {
|
||||
status.as_u16() == 429
|
||||
&& retry_after.is_some_and(|d| d.as_secs() > BURST_RETRY_CEILING_SECS)
|
||||
}
|
||||
|
||||
/// Name the window in the error, so a failure says how long the provider
|
||||
/// wanted rather than only that it refused.
|
||||
fn reset_hint(retry_after: Option<Duration>) -> String {
|
||||
match retry_after {
|
||||
Some(d) => format!(" (provider asked for {}s)", d.as_secs()),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_status(status: reqwest::StatusCode, body: &str) -> ErrorClass {
|
||||
match status.as_u16() {
|
||||
429 => {
|
||||
|
|
@ -731,7 +758,10 @@ impl BifrostClient {
|
|||
.context("Failed to read Bifrost error body")?;
|
||||
|
||||
match classify_status(status, &body_text) {
|
||||
ErrorClass::Transient if attempt < retry_cap(&body_text, policy.max_retries) => {
|
||||
ErrorClass::Transient
|
||||
if attempt < retry_cap(&body_text, policy.max_retries)
|
||||
&& !is_exhausted_window(status, retry_after) =>
|
||||
{
|
||||
let delay = retry_after.unwrap_or_else(|| jittered_delay(attempt, policy));
|
||||
warn!(
|
||||
"Bifrost {} on {} (attempt {}), retrying in {:?}",
|
||||
|
|
@ -756,10 +786,11 @@ impl BifrostClient {
|
|||
body: body_text[..body_text.len().min(300)].to_string(),
|
||||
});
|
||||
anyhow::bail!(
|
||||
"Bifrost returned {} after {} attempt(s) on {}: {}",
|
||||
"Bifrost returned {} after {} attempt(s) on {}{}: {}",
|
||||
status,
|
||||
attempt + 1,
|
||||
model,
|
||||
reset_hint(retry_after),
|
||||
&body_text[..body_text.len().min(500)]
|
||||
);
|
||||
}
|
||||
|
|
@ -844,6 +875,53 @@ impl LlmProvider for BifrostClient {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exhausted_window_rejects_the_measured_11997s_header() {
|
||||
// The header that put the subconscious to sleep for 3h20m on
|
||||
// 2026-08-16. It must not be slept on.
|
||||
let long = Some(Duration::from_secs(11997));
|
||||
assert!(is_exhausted_window(reqwest::StatusCode::TOO_MANY_REQUESTS, long));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_window_still_sleeps_on_a_real_burst_limit() {
|
||||
let short = Some(Duration::from_secs(30));
|
||||
assert!(!is_exhausted_window(
|
||||
reqwest::StatusCode::TOO_MANY_REQUESTS,
|
||||
short
|
||||
));
|
||||
// Exactly at the ceiling is still a burst, not a spent window.
|
||||
let at_ceiling = Some(Duration::from_secs(BURST_RETRY_CEILING_SECS));
|
||||
assert!(!is_exhausted_window(
|
||||
reqwest::StatusCode::TOO_MANY_REQUESTS,
|
||||
at_ceiling
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_window_ignores_absent_headers_and_other_statuses() {
|
||||
// Bifrost has no account to rotate to, so a 429 with no header keeps
|
||||
// the ordinary jittered backoff — this is where it diverges from
|
||||
// `claude_subscription::is_exhausted_window` on purpose.
|
||||
assert!(!is_exhausted_window(
|
||||
reqwest::StatusCode::TOO_MANY_REQUESTS,
|
||||
None
|
||||
));
|
||||
assert!(!is_exhausted_window(
|
||||
reqwest::StatusCode::SERVICE_UNAVAILABLE,
|
||||
Some(Duration::from_secs(11997))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_hint_names_the_window_or_says_nothing() {
|
||||
assert_eq!(
|
||||
reset_hint(Some(Duration::from_secs(11997))),
|
||||
" (provider asked for 11997s)"
|
||||
);
|
||||
assert_eq!(reset_hint(None), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_creation() {
|
||||
let client = BifrostClient::new(
|
||||
|
|
|
|||
Loading…
Reference in a new issue