From 97aeb5631dbf8d897628ff50562ff682bb57e6a8 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 1 Aug 2026 09:26:47 -0400 Subject: [PATCH] refactor(llm): tighten provider error classification --- .../src/codec/anthropic_messages/stream.rs | 4 +-- .../src/codec/openai_responses/stream.rs | 4 +-- lib/components/fabro-llm/src/error.rs | 13 +------ .../fabro-llm/src/providers/openai.rs | 34 +++++++++++++++++++ 4 files changed, 39 insertions(+), 16 deletions(-) diff --git a/lib/components/fabro-llm/src/codec/anthropic_messages/stream.rs b/lib/components/fabro-llm/src/codec/anthropic_messages/stream.rs index 15e1e71cd..bb8d79027 100644 --- a/lib/components/fabro-llm/src/codec/anthropic_messages/stream.rs +++ b/lib/components/fabro-llm/src/codec/anthropic_messages/stream.rs @@ -8,7 +8,7 @@ use super::SYNTHETIC_TOOL_NAME; use super::decode::{convert_synthetic_tool_to_text, map_finish_reason, refusal_error}; use crate::codec::{RawEvent, StreamDecoder, parse_tool_arguments_or_empty}; -use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind, kind_from_error_code}; +use crate::error::{self, Error, ProviderErrorDetail, ProviderErrorKind}; use crate::types::{ ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, StreamEvent, ThinkingData, TokenCounts, ToolCall, @@ -358,7 +358,7 @@ fn stream_error_event_to_provider_error(data: &serde_json::Value, provider_name: // overloaded_error, api_error, and unknown stream errors are transient. let kind = error_code .as_deref() - .and_then(kind_from_error_code) + .and_then(error::kind_from_error_code) .unwrap_or(ProviderErrorKind::Server); Error::Provider { diff --git a/lib/components/fabro-llm/src/codec/openai_responses/stream.rs b/lib/components/fabro-llm/src/codec/openai_responses/stream.rs index 643369688..9bca719ec 100644 --- a/lib/components/fabro-llm/src/codec/openai_responses/stream.rs +++ b/lib/components/fabro-llm/src/codec/openai_responses/stream.rs @@ -11,7 +11,7 @@ use serde::Deserialize; use super::decode::{map_finish_reason, token_counts_from_api_usage, tool_call_from_item}; use super::wire::ApiUsage; use crate::codec::{CodecCtx, RawEvent, StreamDecoder}; -use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind, kind_from_error_code}; +use crate::error::{self, Error, ProviderErrorDetail, ProviderErrorKind}; use crate::types::{ ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, StreamEvent, TokenCounts, ToolCall, @@ -38,7 +38,7 @@ fn provider_error_from_openai_error_json(error: &serde_json::Value, provider: &s // Unrecognized and absent codes are treated as transient. let kind = classifier - .and_then(kind_from_error_code) + .and_then(error::kind_from_error_code) .unwrap_or(ProviderErrorKind::Server); Error::Provider { diff --git a/lib/components/fabro-llm/src/error.rs b/lib/components/fabro-llm/src/error.rs index 7897eebe7..6ec6d7886 100644 --- a/lib/components/fabro-llm/src/error.rs +++ b/lib/components/fabro-llm/src/error.rs @@ -295,7 +295,7 @@ impl Error { /// `insufficient_quota` means the same thing whether it arrives in an HTTP /// error body or in a mid-stream error event. #[must_use] -pub fn kind_from_error_code(code: &str) -> Option { +pub(crate) fn kind_from_error_code(code: &str) -> Option { Some(match code { // Out of credit, or over a billing cap. Distinct from RateLimit: // backoff never clears it, but another provider has its own quota. @@ -726,17 +726,6 @@ mod tests { None, ); assert_eq!(err.provider_kind(), Some(ProviderErrorKind::QuotaExceeded)); - - // No code, so the message fallback still runs. - let err = error_from_status_code( - 400, - "This model's maximum context length is 4096 tokens".into(), - "openai".into(), - None, - None, - None, - ); - assert_eq!(err.provider_kind(), Some(ProviderErrorKind::ContextLength)); } #[test] diff --git a/lib/components/fabro-llm/src/providers/openai.rs b/lib/components/fabro-llm/src/providers/openai.rs index 56eb76c3d..b56159c2c 100644 --- a/lib/components/fabro-llm/src/providers/openai.rs +++ b/lib/components/fabro-llm/src/providers/openai.rs @@ -520,6 +520,40 @@ mod tests { assert!(matches!(err, Error::Configuration { .. })); } + #[tokio::test] + async fn complete_classifies_insufficient_quota_as_quota_exceeded() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST).path("/responses"); + then.status(429) + .header("content-type", "application/json") + .json_body(serde_json::json!({ + "error": { + "message": "You exceeded your current quota.", + "type": "insufficient_quota" + } + })); + }); + let adapter = Adapter::new("sk-test").with_base_url(server.base_url()); + + let err = adapter + .complete(&minimal_request()) + .await + .expect_err("spent quota should fail the completion"); + + mock.assert(); + assert_eq!(err.provider_kind(), Some(ProviderErrorKind::QuotaExceeded)); + assert_eq!(err.status_code(), Some(429)); + assert!(!err.retryable()); + assert!(err.failover_eligible()); + match err { + Error::Provider { detail, .. } => { + assert_eq!(detail.error_code.as_deref(), Some("insufficient_quota")); + } + other => panic!("expected provider error, got {other:?}"), + } + } + #[tokio::test] async fn codex_complete_via_stream_propagates_stream_errors() { let server = MockServer::start();