use reqwest::{Response, StatusCode};
use serde_json::Value;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiFailureKind {
ContextExceeded,
OutputLimitExceeded,
MalformedToolArguments,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiFailure {
pub kind: ApiFailureKind,
pub message: String,
}
impl ApiFailure {
pub fn new(kind: ApiFailureKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
pub fn other(message: impl Into<String>) -> Self {
Self::new(ApiFailureKind::Other, message)
}
pub fn output_limit_exceeded(message: impl Into<String>) -> Self {
Self::new(ApiFailureKind::OutputLimitExceeded, message)
}
pub fn malformed_tool_arguments(message: impl Into<String>) -> Self {
Self::new(ApiFailureKind::MalformedToolArguments, message)
}
pub fn prefixed(mut self, prefix: &str) -> Self {
self.message = format!("{prefix}: {}", self.message);
self
}
}
impl fmt::Display for ApiFailure {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for ApiFailure {}
pub(super) fn classify_reader_error(error: &anyhow::Error) -> ApiFailure {
let text = error.to_string();
let kind = if text.contains("invalid arguments for") && text.contains("tool call") {
ApiFailureKind::MalformedToolArguments
} else {
ApiFailureKind::Other
};
ApiFailure::new(kind, text)
}
fn classify(status: Option<StatusCode>, error_type: Option<&str>) -> ApiFailureKind {
if status == Some(StatusCode::PAYLOAD_TOO_LARGE) {
return ApiFailureKind::ContextExceeded;
}
match error_type {
Some("context_length_exceeded") | Some("string_above_max_length") => {
ApiFailureKind::ContextExceeded
}
Some("max_output_tokens") | Some("max_tokens_exceeded") => {
ApiFailureKind::OutputLimitExceeded
}
_ => ApiFailureKind::Other,
}
}
pub(super) async fn http_error(response: Response, provider: &str, model: &str) -> anyhow::Error {
let status = response.status();
let retry_after = response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let body = response.text().await.unwrap_or_default();
let value = serde_json::from_str::<Value>(&body).ok();
let (error_type, message) = value
.as_ref()
.map(extract_details)
.unwrap_or((None, nonempty(&body)));
let message = message.map(|message| crate::utils::truncate_str(message, 2_048));
let failure = ApiFailure::new(
classify(Some(status), error_type),
format_error(
provider,
model,
Some(status),
retry_after.as_deref(),
error_type,
message,
),
);
anyhow::Error::new(failure)
}
pub(super) fn stream_error(event: &Value, provider: &str, model: &str) -> ApiFailure {
let (error_type, message) = extract_details(event);
let status = extract_status(event).or_else(|| error_type.and_then(status_for_type));
ApiFailure::new(
classify(status, error_type),
format_error(provider, model, status, None, error_type, message),
)
}
fn extract_details(value: &Value) -> (Option<&str>, Option<&str>) {
let response = &value["response"];
let error = if value["error"].is_object() {
&value["error"]
} else if response["error"].is_object() {
&response["error"]
} else {
value
};
let error_type = error["metadata"]["error_type"]
.as_str()
.or_else(|| error["error_type"].as_str())
.or_else(|| value["error_type"].as_str())
.or_else(|| response["error_type"].as_str())
.or_else(|| error["type"].as_str())
.or_else(|| error["code"].as_str());
let message = error["message"]
.as_str()
.or_else(|| value["message"].as_str())
.or_else(|| response["incomplete_details"]["reason"].as_str());
(error_type, message)
}
fn extract_status(value: &Value) -> Option<StatusCode> {
let response = &value["response"];
let error = if value["error"].is_object() {
&value["error"]
} else if response["error"].is_object() {
&response["error"]
} else {
value
};
error["code"]
.as_u64()
.or_else(|| value["code"].as_u64())
.and_then(|code| u16::try_from(code).ok())
.and_then(|code| StatusCode::from_u16(code).ok())
}
fn status_for_type(error_type: &str) -> Option<StatusCode> {
match error_type {
"authentication" => Some(StatusCode::UNAUTHORIZED),
"payment_required" => Some(StatusCode::PAYMENT_REQUIRED),
"permission_denied" => Some(StatusCode::FORBIDDEN),
"rate_limit_exceeded" => Some(StatusCode::TOO_MANY_REQUESTS),
"provider_unavailable" => Some(StatusCode::BAD_GATEWAY),
"provider_overloaded" => Some(StatusCode::SERVICE_UNAVAILABLE),
"timeout" => Some(StatusCode::GATEWAY_TIMEOUT),
_ => None,
}
}
fn format_error(
provider: &str,
model: &str,
status: Option<StatusCode>,
retry_after: Option<&str>,
error_type: Option<&str>,
message: Option<&str>,
) -> String {
let mut output = format!("{provider} API error");
if let Some(status) = status {
output.push_str(&format!(" ({status})"));
}
output.push_str(&format!(" for model '{model}'"));
if let Some(error_type) = error_type {
output.push_str(&format!(" [{error_type}]"));
}
if let Some(message) = message {
output.push_str(": ");
output.push_str(message);
}
match status {
Some(StatusCode::TOO_MANY_REQUESTS) => {
if let Some(retry_after) = retry_after {
output.push_str(&format!(". Retry after {retry_after}"));
} else {
output.push_str(". Retry later or choose another model/provider");
}
}
Some(StatusCode::SERVICE_UNAVAILABLE) | Some(StatusCode::BAD_GATEWAY) => {
output.push_str(". The provider may be unavailable; retry or choose another model")
}
_ => {}
}
output
}
fn nonempty(value: &str) -> Option<&str> {
let value = value.trim();
(!value.is_empty()).then_some(value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_rate_limit_with_retry_after() {
let message = format_error(
"openrouter",
"poolside/laguna",
Some(StatusCode::TOO_MANY_REQUESTS),
Some("60"),
Some("rate_limit_exceeded"),
Some("Rate limit exceeded"),
);
assert!(message.contains("429 Too Many Requests"));
assert!(message.contains("poolside/laguna"));
assert!(message.contains("rate_limit_exceeded"));
assert!(message.contains("Retry after 60"));
}
#[test]
fn extracts_openrouter_stream_error() {
let event = serde_json::json!({
"error": {
"code": 429,
"message": "upstream limit",
"metadata": {"error_type": "rate_limit_exceeded"}
}
});
let message = stream_error(&event, "openrouter", "model").message;
assert!(message.contains("429 Too Many Requests"));
assert!(message.contains("upstream limit"));
assert!(message.contains("choose another model/provider"));
}
#[test]
fn extracts_responses_error_type() {
let event = serde_json::json!({
"type": "response.failed",
"response": {
"error": {"code": "server_error", "message": "Rate limited"},
"error_type": "rate_limit_exceeded"
}
});
let message = stream_error(&event, "openrouter", "model").message;
assert!(message.contains("429 Too Many Requests"));
assert!(message.contains("rate_limit_exceeded"));
}
#[test]
fn classifies_context_and_output_limits_from_structure() {
assert_eq!(
classify(Some(StatusCode::PAYLOAD_TOO_LARGE), None),
ApiFailureKind::ContextExceeded
);
assert_eq!(
classify(None, Some("context_length_exceeded")),
ApiFailureKind::ContextExceeded
);
assert_eq!(
classify(None, Some("max_tokens_exceeded")),
ApiFailureKind::OutputLimitExceeded
);
}
#[test]
fn rate_limits_are_not_mistaken_for_context_or_output_limits() {
assert_eq!(
classify(
Some(StatusCode::TOO_MANY_REQUESTS),
Some("rate_limit_exceeded")
),
ApiFailureKind::Other
);
}
#[test]
fn incidental_digits_in_a_message_do_not_trigger_compaction() {
let event = serde_json::json!({
"error": {
"code": 500,
"message": "internal error (request req_413_88 on model gpt-4-1300)"
}
});
let failure = stream_error(&event, "openai", "model");
assert!(failure.message.contains("req_413_88"));
assert_eq!(failure.kind, ApiFailureKind::Other);
}
#[test]
fn reader_errors_recover_the_malformed_tool_argument_classification() {
let error = anyhow::anyhow!(
"invalid arguments for tool call Read (call_3): EOF while parsing a value"
);
let failure = classify_reader_error(&error).prefixed("OpenAI SSE stream error");
assert_eq!(failure.kind, ApiFailureKind::MalformedToolArguments);
assert!(failure.message.starts_with("OpenAI SSE stream error: "));
assert!(failure.message.contains("invalid arguments for tool call"));
}
#[test]
fn unrelated_reader_errors_stay_unclassified() {
let error = anyhow::anyhow!("stream ended before message_stop");
assert_eq!(classify_reader_error(&error).kind, ApiFailureKind::Other);
}
#[tokio::test]
async fn http_errors_carry_their_classification_through_anyhow() {
let response = crate::test_support::json_response(
413,
"{\"error\":{\"message\":\"prompt is too long\"}}",
)
.await;
let error = http_error(response, "anthropic", "claude-test").await;
let failure = error.downcast_ref::<ApiFailure>().expect("typed failure");
assert_eq!(failure.kind, ApiFailureKind::ContextExceeded);
assert!(failure.message.contains("prompt is too long"));
}
}