anthropic: Handle additional API error codes (#62984)

Anthropic now returns distinct error types for billing failures, request
conflicts, and gateway timeouts. Because the Anthropic client did not
recognize these values, they fell through to
`LanguageModelCompletionError::Other`, losing their structured status
and causing callers to apply generic handling.

Recognize the documented `billing_error`, `conflict_error`, and
`timeout_error` codes. Billing failures now become `PaymentRequired`,
conflicts preserve their HTTP 409 status and provider message, and
timeouts become typed upstream HTTP 504 failures. End-to-end tests
exercise each mapping through the HTTP response parsing path.

Testing:

- `cargo fmt --check`
- `cargo nextest run -p anthropic`
- `./script/clippy -p anthropic`
- `git diff --check`

Release Notes:

- Fixed handling of Anthropic billing, conflict, and timeout errors.
This commit is contained in:
Conrad Irwin authored and GitHub committed 2026-08-21 05:38:56 +00:00
1 parent bf2d51bcfd
commit 0ac96c12fc
1 file changed
+104
+104
View File
@@ -1120,14 +1120,20 @@ pub enum ApiErrorCode {
InvalidRequestError,
/// 401 - `authentication_error`: There's an issue with your API key.
AuthenticationError,
/// 402 - `billing_error`: There's an issue with the account's billing.
BillingError,
/// 403 - `permission_error`: Your API key does not have permission to use the specified resource.
PermissionError,
/// 404 - `not_found_error`: The requested resource was not found.
NotFoundError,
/// 409 - `conflict_error`: The request conflicts with the current state of the resource.
ConflictError,
/// 413 - `request_too_large`: Request exceeds the maximum allowed number of bytes.
RequestTooLarge,
/// 429 - `rate_limit_error`: Your account has hit a rate limit.
RateLimitError,
/// 504 - `timeout_error`: Anthropic's gateway timed out while processing the request.
TimeoutError,
/// 500 - `api_error`: An unexpected error has occurred internal to Anthropic's systems.
ApiError,
/// 529 - `overloaded_error`: Anthropic's API is temporarily overloaded.
@@ -1235,11 +1241,17 @@ pub fn completion_error_from_anthropic_api(
provider,
message: error.message,
},
BillingError => Error::PaymentRequired,
PermissionError => Error::PermissionError {
provider,
message: error.message,
},
NotFoundError => Error::ApiEndpointNotFound { provider },
ConflictError => Error::HttpResponseError {
provider,
status_code: StatusCode::CONFLICT,
message: error.message,
},
RequestTooLarge => Error::PromptTooLarge {
tokens: language_model_core::parse_prompt_too_long(&error.message),
},
@@ -1247,6 +1259,11 @@ pub fn completion_error_from_anthropic_api(
provider,
retry_after: None,
},
TimeoutError => Error::UpstreamProviderError {
message: error.message,
status: StatusCode::GATEWAY_TIMEOUT,
retry_after: None,
},
ApiError => Error::ApiInternalServerError {
provider,
message: error.message,
@@ -1293,6 +1310,93 @@ mod tests {
));
}
#[test]
fn list_models_maps_anthropic_billing_errors_to_payment_required() {
let client = FakeHttpClient::create(|_| async move {
Ok(http::Response::builder()
.status(StatusCode::PAYMENT_REQUIRED)
.body(AsyncBody::from(
r#"{"type":"error","error":{"type":"billing_error","message":"There is a problem with your billing information."},"request_id":"request-id"}"#,
))
.expect("valid response"))
});
let error = futures::executor::block_on(list_models(
client.as_ref(),
ANTHROPIC_API_URL,
"test-key",
&CustomHeaders::default(),
))
.expect_err("billing error should fail");
let completion_error: language_model_core::LanguageModelCompletionError = error.into();
assert!(matches!(
completion_error,
language_model_core::LanguageModelCompletionError::PaymentRequired
));
}
#[test]
fn list_models_maps_anthropic_conflict_errors_to_http_conflict() {
let client = FakeHttpClient::create(|_| async move {
Ok(http::Response::builder()
.status(StatusCode::CONFLICT)
.body(AsyncBody::from(
r#"{"type":"error","error":{"type":"conflict_error","message":"The resource was modified concurrently."},"request_id":"request-id"}"#,
))
.expect("valid response"))
});
let error = futures::executor::block_on(list_models(
client.as_ref(),
ANTHROPIC_API_URL,
"test-key",
&CustomHeaders::default(),
))
.expect_err("conflict should fail");
let completion_error: language_model_core::LanguageModelCompletionError = error.into();
assert!(matches!(
completion_error,
language_model_core::LanguageModelCompletionError::HttpResponseError {
provider,
status_code: StatusCode::CONFLICT,
message,
} if provider == language_model_core::ANTHROPIC_PROVIDER_NAME
&& message == "The resource was modified concurrently."
));
}
#[test]
fn list_models_maps_anthropic_timeout_errors_to_gateway_timeout() {
let client = FakeHttpClient::create(|_| async move {
Ok(http::Response::builder()
.status(StatusCode::GATEWAY_TIMEOUT)
.body(AsyncBody::from(
r#"{"type":"error","error":{"type":"timeout_error","message":"The request timed out."},"request_id":"request-id"}"#,
))
.expect("valid response"))
});
let error = futures::executor::block_on(list_models(
client.as_ref(),
ANTHROPIC_API_URL,
"test-key",
&CustomHeaders::default(),
))
.expect_err("timeout should fail");
let completion_error: language_model_core::LanguageModelCompletionError = error.into();
assert!(matches!(
completion_error,
language_model_core::LanguageModelCompletionError::UpstreamProviderError {
message,
status: StatusCode::GATEWAY_TIMEOUT,
retry_after: None,
} if message == "The request timed out."
));
}
fn listed_entry(id: &str, capabilities: ModelCapabilities) -> ListModelEntry {
ListModelEntry {
id: id.to_string(),