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}
68
69impl ApiError {
70    pub(crate) fn from_response(status: u16, request_id: Option<String>, text: &str) -> Self {
71        let body: Option<Value> = serde_json::from_str(text).ok();
72        // Anthropic: {"type":"error","error":{"type","message"}}; OpenAI: {"error":{"message","type","code","param"}}.
73        // Gemini: {"error":{"code","message","status"}}; Cohere: {"message"}.
74        let field = |name: &str| {
75            body.as_ref()
76                .and_then(|b| b["error"][name].as_str())
77                .map(str::to_string)
78        };
79        let top_message = body
80            .as_ref()
81            .and_then(|b| b["message"].as_str())
82            .map(str::to_string);
83        let message = field("message")
84            .or(top_message)
85            .or_else(|| {
86                let text = text.trim();
87                (!text.is_empty()).then(|| text.to_string())
88            })
89            .map(|m| truncate(&m));
90        Self {
91            status,
92            kind: ApiErrorKind::from_status(status),
93            message,
94            error_type: field("type").or_else(|| field("status")),
95            code: field("code"),
96            param: field("param"),
97            request_id,
98            body,
99        }
100    }
101}
102
103/// Same format as `APIError.makeMessage` in the SDKs: `"{status} {message}"`
104/// or `"{status} status code (no body)"`.
105impl std::fmt::Display for ApiError {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        match &self.message {
108            Some(message) => write!(f, "{} {}", self.status, message),
109            None => write!(f, "{} status code (no body)", self.status),
110        }
111    }
112}
113
114impl std::error::Error for ApiError {}
115
116/// Every error this crate returns.
117#[derive(Debug, Error)]
118pub enum LlmError {
119    /// The server answered with a non-2xx status (`APIStatusError` and subclasses).
120    #[error("{0}")]
121    Api(Box<ApiError>),
122    /// No response: DNS, TLS or a dropped connection (`APIConnectionError`).
123    #[error("Connection error.")]
124    Connection(#[source] reqwest::Error),
125    /// The request timed out (`APIConnectionTimeoutError`).
126    #[error("Request timed out.")]
127    Timeout(#[source] reqwest::Error),
128    /// The response body is not JSON (`JSONDeserialize` in async-openai).
129    /// `content` holds the start of the body; it is not part of the message, so model
130    /// output does not end up in logs by accident.
131    #[error("failed to deserialize api response: {source}")]
132    Deserialize {
133        #[source]
134        source: serde_json::Error,
135        content: String,
136    },
137    /// The JSON parsed but is missing expected fields.
138    #[error("unexpected api response: {0}")]
139    InvalidResponse(String),
140    /// Client-side validation before the request is sent (`InvalidArgument` in async-openai).
141    #[error("invalid args: {0}")]
142    InvalidArgument(String),
143}
144
145impl LlmError {
146    pub(crate) fn from_reqwest(error: reqwest::Error) -> Self {
147        if error.is_builder() {
148            // The request could not be built (e.g. a bad header); retrying cannot help.
149            return Self::InvalidArgument(format!(
150                "could not build request: {}",
151                error.without_url()
152            ));
153        }
154        // Drop the URL so a key in it can never reach logs through the error chain.
155        let error = error.without_url();
156        if error.is_timeout() {
157            Self::Timeout(error)
158        } else {
159            Self::Connection(error)
160        }
161    }
162
163    /// HTTP status, if the server answered.
164    pub fn status(&self) -> Option<u16> {
165        match self {
166            Self::Api(e) => Some(e.status),
167            _ => None,
168        }
169    }
170
171    /// Request ID of the response, if the server sent one.
172    pub fn request_id(&self) -> Option<&str> {
173        match self {
174            Self::Api(e) => e.request_id.as_deref(),
175            _ => None,
176        }
177    }
178
179    /// Same retry rules as the SDKs: 408, 409, 429, >= 500, connection errors and timeouts.
180    pub fn is_retryable(&self) -> bool {
181        match self {
182            Self::Api(e) => matches!(e.status, 408 | 409 | 429) || e.status >= 500,
183            Self::Connection(_) | Self::Timeout(_) => true,
184            _ => false,
185        }
186    }
187}
188
189pub(crate) fn truncate(text: &str) -> String {
190    text.chars().take(MAX_BODY_CHARS).collect()
191}