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 {
|
fn classify_status(status: reqwest::StatusCode, body: &str) -> ErrorClass {
|
||||||
match status.as_u16() {
|
match status.as_u16() {
|
||||||
429 => {
|
429 => {
|
||||||
|
|
@ -731,7 +758,10 @@ impl BifrostClient {
|
||||||
.context("Failed to read Bifrost error body")?;
|
.context("Failed to read Bifrost error body")?;
|
||||||
|
|
||||||
match classify_status(status, &body_text) {
|
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));
|
let delay = retry_after.unwrap_or_else(|| jittered_delay(attempt, policy));
|
||||||
warn!(
|
warn!(
|
||||||
"Bifrost {} on {} (attempt {}), retrying in {:?}",
|
"Bifrost {} on {} (attempt {}), retrying in {:?}",
|
||||||
|
|
@ -756,10 +786,11 @@ impl BifrostClient {
|
||||||
body: body_text[..body_text.len().min(300)].to_string(),
|
body: body_text[..body_text.len().min(300)].to_string(),
|
||||||
});
|
});
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"Bifrost returned {} after {} attempt(s) on {}: {}",
|
"Bifrost returned {} after {} attempt(s) on {}{}: {}",
|
||||||
status,
|
status,
|
||||||
attempt + 1,
|
attempt + 1,
|
||||||
model,
|
model,
|
||||||
|
reset_hint(retry_after),
|
||||||
&body_text[..body_text.len().min(500)]
|
&body_text[..body_text.len().min(500)]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -844,6 +875,53 @@ impl LlmProvider for BifrostClient {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn test_client_creation() {
|
fn test_client_creation() {
|
||||||
let client = BifrostClient::new(
|
let client = BifrostClient::new(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue