refactor(llm): tighten provider error classification

This commit is contained in:
Bryan Helmkamp 2026-08-01 09:26:47 -04:00
parent dc46d183b0
commit 97aeb5631d
No known key found for this signature in database
4 changed files with 39 additions and 16 deletions

View file

@ -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 {

View file

@ -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 {

View file

@ -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<ProviderErrorKind> {
pub(crate) fn kind_from_error_code(code: &str) -> Option<ProviderErrorKind> {
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]

View file

@ -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();