Skip to main content

llm_universal/
error.rs

1//! Errors modeled on the official SDKs: `src/core/error.ts` in anthropic-sdk-typescript and
2//! openai-node (an `APIError` hierarchy keyed by HTTP status, `requestID`, `type`/`code`/`param`)
3//! and `error.rs` in async-openai (an enum with `InvalidArgument` and `JSONDeserialize`).
4
5use serde_json::Value;
6use thiserror::Error;
7
8/// How many characters of a response body are kept in an error.
9const MAX_BODY_CHARS: usize = 4096;
10
11/// Error class by HTTP status, matching the `APIError` subclasses in the official SDKs.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ApiErrorKind {
14    /// 400 `BadRequestError`
15    BadRequest,
16    /// 401 `AuthenticationError`
17    Authentication,
18    /// 403 `PermissionDeniedError`
19    PermissionDenied,
20    /// 404 `NotFoundError`
21    NotFound,
22    /// 409 `ConflictError`
23    Conflict,
24    /// 422 `UnprocessableEntityError`
25    UnprocessableEntity,
26    /// 429 `RateLimitError`
27    RateLimit,
28    /// >= 500 `InternalServerError` (includes Anthropic's 529 `overloaded_error`)
29    InternalServer,
30    /// Any other non-2xx status (the base `APIError`)
31    Other,
32}
33
34impl ApiErrorKind {
35    pub fn from_status(status: u16) -> Self {
36        match status {
37            400 => Self::BadRequest,
38            401 => Self::Authentication,
39            403 => Self::PermissionDenied,
40            404 => Self::NotFound,
41            409 => Self::Conflict,
42            422 => Self::UnprocessableEntity,
43            429 => Self::RateLimit,
44            s if s >= 500 => Self::InternalServer,
45            _ => Self::Other,
46        }
47    }
48}
49
50/// A response with a non-2xx status (`APIStatusError` in the SDKs).
51#[derive(Debug, Clone, PartialEq)]
52pub struct ApiError {
53    pub status: u16,
54    pub kind: ApiErrorKind,
55    /// `error.message` from the body; otherwise the whole body (truncated).
56    pub message: Option<String>,
57    /// `error.type`, e.g. `rate_limit_error` (Anthropic) or `invalid_request_error` (OpenAI).
58    pub error_type: Option<String>,
59    /// `error.code` (OpenAI).
60    pub code: Option<String>,
61    /// `error.param` (OpenAI).
62    pub param: Option<String>,
63    /// `request-id` (Anthropic) or `x-request-id` (OpenAI) header — quote it to support.
64    pub request_id: Option<String>,
65    /// The response body, if it was JSON.
66    pub body: Option<Value>,
67    /// Delay the server asked for in `retry-after` / `retry-after-ms`, if any.
68    pub retry_after: Option<std::time::Duration>,
69}
70
71impl ApiError {
72    pub(crate) fn from_response(status: u16, request_id: Option<String>, text: &str) -> Self {
73        let body: Option<Value> = serde_json::from_str(text).ok();
74        // Anthropic: {"type":"error","error":{"type","message"}}; OpenAI: {"error":{"message","type","code","param"}}.
75        // Gemini: {"error":{"code","message","status"}}; Cohere: {"message"}.
76        let field = |name: &str| {
77            body.as_ref()
78                .and_then(|b| b["error"][name].as_str())
79                .map(str::to_string)
80        };
81        let top_message = body
82            .as_ref()
83            .and_then(|b| b["message"].as_str())
84            .map(str::to_string);
85        let message = field("message")
86            .or(top_message)
87            .or_else(|| {
88                let text = text.trim();
89                (!text.is_empty()).then(|| text.to_string())
90            })
91            .map(|m| truncate(&m));
92        Self {
93            status,
94            kind: ApiErrorKind::from_status(status),
95            message,
96            error_type: field("type").or_else(|| field("status")),
97            code: field("code"),
98            param: field("param"),
99            request_id,
100            body,
101            retry_after: None,
102        }
103    }
104
105    /// An error sent inside a stream (`event: error` / a `{"error": ...}` chunk), where there is
106    /// no HTTP status of its own. The status is derived from the error type or code.
107    pub(crate) fn from_stream_error(error: &Value) -> Self {
108        let error_type = error["type"].as_str().or_else(|| error["status"].as_str());
109        let status = error["code"]
110            .as_u64()
111            .and_then(|c| u16::try_from(c).ok())
112            .filter(|c| (400..600).contains(c))
113            .unwrap_or(match error_type {
114                Some("overloaded_error") => 529,
115                Some("rate_limit_error" | "RESOURCE_EXHAUSTED") => 429,
116                Some("invalid_request_error" | "INVALID_ARGUMENT") => 400,
117                Some("authentication_error" | "UNAUTHENTICATED") => 401,
118                Some("permission_error" | "PERMISSION_DENIED") => 403,
119                Some("not_found_error" | "NOT_FOUND") => 404,
120                _ => 500,
121            });
122        Self {
123            status,
124            kind: ApiErrorKind::from_status(status),
125            message: error["message"].as_str().map(truncate),
126            error_type: error_type.map(str::to_string),
127            code: error["code"].as_str().map(str::to_string),
128            param: error["param"].as_str().map(str::to_string),
129            request_id: None,
130            body: Some(error.clone()),
131            retry_after: None,
132        }
133    }
134}
135
136/// Same format as `APIError.makeMessage` in the SDKs: `"{status} {message}"`
137/// or `"{status} status code (no body)"`.
138impl std::fmt::Display for ApiError {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        match &self.message {
141            Some(message) => write!(f, "{} {}", self.status, message),
142            None => write!(f, "{} status code (no body)", self.status),
143        }
144    }
145}
146
147impl std::error::Error for ApiError {}
148
149/// Every error this crate returns.
150#[derive(Debug, Error)]
151pub enum LlmError {
152    /// The server answered with a non-2xx status (`APIStatusError` and subclasses).
153    #[error("{0}")]
154    Api(Box<ApiError>),
155    /// No response: DNS, TLS or a dropped connection (`APIConnectionError`).
156    #[error("Connection error.")]
157    Connection(#[source] reqwest::Error),
158    /// The request timed out (`APIConnectionTimeoutError`).
159    #[error("Request timed out.")]
160    Timeout(#[source] reqwest::Error),
161    /// The response body is not JSON (`JSONDeserialize` in async-openai).
162    /// `content` holds the start of the body; it is not part of the message, so model
163    /// output does not end up in logs by accident.
164    #[error("failed to deserialize api response: {source}")]
165    Deserialize {
166        #[source]
167        source: serde_json::Error,
168        content: String,
169    },
170    /// The JSON parsed but is missing expected fields.
171    #[error("unexpected api response: {0}")]
172    InvalidResponse(String),
173    /// Client-side validation before the request is sent (`InvalidArgument` in async-openai).
174    #[error("invalid args: {0}")]
175    InvalidArgument(String),
176}
177
178impl LlmError {
179    pub(crate) fn from_reqwest(error: reqwest::Error) -> Self {
180        if error.is_builder() {
181            // The request could not be built (e.g. a bad header); retrying cannot help.
182            return Self::InvalidArgument(format!(
183                "could not build request: {}",
184                error.without_url()
185            ));
186        }
187        // Drop the URL so a key in it can never reach logs through the error chain.
188        let error = error.without_url();
189        if error.is_timeout() {
190            Self::Timeout(error)
191        } else {
192            Self::Connection(error)
193        }
194    }
195
196    /// HTTP status, if the server answered.
197    pub fn status(&self) -> Option<u16> {
198        match self {
199            Self::Api(e) => Some(e.status),
200            _ => None,
201        }
202    }
203
204    /// Request ID of the response, if the server sent one.
205    pub fn request_id(&self) -> Option<&str> {
206        match self {
207            Self::Api(e) => e.request_id.as_deref(),
208            _ => None,
209        }
210    }
211
212    /// Same retry rules as the SDKs: 408, 409, 429, >= 500, connection errors and timeouts.
213    pub fn is_retryable(&self) -> bool {
214        match self {
215            Self::Api(e) => matches!(e.status, 408 | 409 | 429) || e.status >= 500,
216            Self::Connection(_) | Self::Timeout(_) => true,
217            _ => false,
218        }
219    }
220}
221
222pub(crate) fn truncate(text: &str) -> String {
223    text.chars().take(MAX_BODY_CHARS).collect()
224}