From f2ead77d06efaa66ccf96172a260c71f23588409 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Wed, 23 Sep 2026 05:48:13 -0700 Subject: [PATCH] fix(fleet): preserve durable provider refusals during retry classification A classified quota/auth/model/policy error can contain transient wording or HTTP codes in its body and context. Do not turn those durable refusals back into retries by matching their text. Reuse LlmError::is_retryable for typed transient failures, retaining the existing relay-400/parse/transport fallback. Verification: 10 focused tests passed, 0 failed, 13242 not selected. Includes quota HTTP-boundary tests, misleading refusal text, ordinary rate limits, and the actual relay timeout-then-success request loop. TUI all-target/all- feature Clippy with CI warning flags, fmt, diff check, static ratchets passed. Extracted old/new classifier probe with real anyhow context reproduces quota and authorization misclassification before and correct behavior after. Initial broad attempt: 6 passed, 1 failed (relay retry expected 2 calls, saw 1). Narrowed production logic, not the existing test; the final run passes. No existing test changed or removed. This does not implement model replacement. --- crates/tui/src/tools/subagent/mod.rs | 17 +++++++- crates/tui/src/tools/subagent/tests.rs | 54 ++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 49a341b3b8..851a9e0c2a 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -12650,8 +12650,21 @@ fn retryable_subagent_provider_failure( } fn is_transient_subagent_provider_error(error: &anyhow::Error) -> bool { - if let Some(LlmError::RateLimited { .. }) = error.downcast_ref::() { - return true; + // Durable account, model, and policy refusals outrank message text: a + // quota error mentioning "429" is not a transient rate limit. Retain the + // transport fallback for generic/parse errors and relay HTTP 400s, which + // can carry an upstream timeout rather than an invalid request. + match error.downcast_ref::() { + Some( + LlmError::QuotaExhausted(_) + | LlmError::AuthenticationError(_) + | LlmError::AuthorizationError(_) + | LlmError::ModelError(_) + | LlmError::ContentPolicyError(_) + | LlmError::ContextLengthError(_), + ) => return false, + Some(error) if error.is_retryable() => return true, + _ => {} } let message = format!("{error:#}").to_ascii_lowercase(); diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 6adb7a0bb6..553fec7d63 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -9802,6 +9802,60 @@ fn transient_provider_classifier_matches_structured_rate_limit() { assert!(is_transient_subagent_provider_error(&err)); } +#[test] +fn transient_provider_classifier_respects_durable_typed_errors() { + // The provider body and outer context both contain the old transient + // heuristics. Neither may override a durable HTTP-boundary classification. + let misleading = "429 rate limited: stream request temporarily unavailable"; + let mut errors = vec![ + LlmError::from_http_response(401, misleading), + LlmError::AuthorizationError(misleading.to_string()), + LlmError::ModelError(misleading.to_string()), + LlmError::ContentPolicyError(misleading.to_string()), + LlmError::ContextLengthError(misleading.to_string()), + ]; + for status in [400, 402, 429] { + let quota = LlmError::from_http_response( + status, + r#"{"error":{"type":"insufficient_quota","message":"429: insufficient quota for stream request"}}"#, + ); + assert!(matches!(quota, LlmError::QuotaExhausted(_))); + errors.push(quota); + } + for error in errors { + assert!(!error.is_retryable(), "fixture must be a durable refusal"); + let error = anyhow::Error::new(error).context("stream request failed: 503"); + assert!( + !is_transient_subagent_provider_error(&error), + "typed refusal must not become transient: {error:#}" + ); + assert!(retryable_subagent_provider_failure(&error, 1).is_none()); + } +} + +#[test] +fn transient_provider_classifier_uses_typed_retryability_without_keywords() { + for error in [ + LlmError::ServerError { + status: 500, + message: "upstream failed".to_string(), + }, + LlmError::NetworkError("socket closed".to_string()), + LlmError::Timeout(Duration::from_secs(1)), + ] { + let error = anyhow::Error::new(error); + assert!(is_transient_subagent_provider_error(&error)); + assert!(retryable_subagent_provider_failure(&error, 1).is_some()); + } + let error = anyhow::Error::new(LlmError::RateLimited { + message: "slow down".to_string(), + retry_after: Some(Duration::from_secs(7)), + }); + let retry = retryable_subagent_provider_failure(&error, 1).expect("transient rate limit"); + assert_eq!(retry.delay, Duration::from_secs(7)); + assert_eq!(retry.checkpoint_reason, "api_rate_limited"); +} + #[tokio::test] async fn subagent_retries_transient_provider_header_timeout_before_succeeding() { let tmp = tempdir().expect("tempdir");