Skip to main content

liter_llm/
error.rs

1//! OpenAI-compatible error normalization.
2
3#![allow(missing_docs)]
4
5use std::time::Duration;
6
7use serde::{Deserialize, Serialize};
8
9/// Error response from an OpenAI-compatible API.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub(crate) struct ErrorResponse {
12    error: ApiError,
13}
14
15/// Inner error object.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub(crate) struct ApiError {
18    message: String,
19    #[serde(default)]
20    code: Option<String>,
21}
22
23/// All errors that can occur when using `liter-llm`.
24///
25/// Each variant carries a stable numeric `error_code` (>= 100) that bindings expose
26/// across the FFI boundary. Codes are append-only: never renumber or reuse a code.
27#[derive(Debug, thiserror::Error)]
28#[non_exhaustive]
29pub enum LiterLlmError {
30    /// `status` preserves the exact HTTP status code received (401 or 403).
31    #[cfg_attr(alef, alef(error_code = 100))]
32    #[error("authentication failed: {message}")]
33    Authentication { message: String, status: u16 },
34
35    #[cfg_attr(alef, alef(error_code = 101))]
36    #[error("rate limited: {message}")]
37    RateLimited {
38        message: String,
39        retry_after: Option<Duration>,
40    },
41
42    /// `status` preserves the exact HTTP status code received (400, 405, 413, 422, …).
43    #[cfg_attr(alef, alef(error_code = 102))]
44    #[error("bad request: {message}")]
45    BadRequest { message: String, status: u16 },
46
47    #[cfg_attr(alef, alef(error_code = 103))]
48    #[error("context window exceeded: {message}")]
49    ContextWindowExceeded { message: String },
50
51    #[cfg_attr(alef, alef(error_code = 104))]
52    #[error("content policy violation: {message}")]
53    ContentPolicy { message: String },
54
55    #[cfg_attr(alef, alef(error_code = 105))]
56    #[error("not found: {message}")]
57    NotFound { message: String },
58
59    /// `status` preserves the exact HTTP status code received (500, or other 5xx not covered
60    /// by `ServiceUnavailable`).
61    #[cfg_attr(alef, alef(error_code = 106))]
62    #[error("server error: {message}")]
63    ServerError { message: String, status: u16 },
64
65    /// `status` preserves the exact HTTP status code received (502, 503, or 504).
66    #[cfg_attr(alef, alef(error_code = 107))]
67    #[error("service unavailable: {message}")]
68    ServiceUnavailable {
69        message: String,
70        status: u16,
71        retry_after: Option<Duration>,
72    },
73
74    #[cfg_attr(alef, alef(error_code = 108))]
75    #[error("request timeout")]
76    Timeout,
77
78    #[cfg_attr(alef, alef(error_code = 109))]
79    #[error("network error: {message}")]
80    Network { message: String },
81
82    /// A catch-all for errors that occur during streaming response processing.
83    ///
84    /// This variant covers multiple sub-conditions including UTF-8 decoding
85    /// failures, CRC/checksum mismatches (AWS EventStream), JSON parse errors
86    /// in individual SSE chunks, and buffer overflow conditions.  The `message`
87    /// field contains a human-readable description of the specific failure.
88    #[cfg_attr(alef, alef(error_code = 110))]
89    #[error("streaming error: {message}")]
90    Streaming { message: String },
91
92    #[cfg_attr(alef, alef(error_code = 111))]
93    #[error("provider {provider} does not support {endpoint}")]
94    EndpointNotSupported { endpoint: String, provider: String },
95
96    #[cfg_attr(alef, alef(error_code = 112))]
97    #[error("invalid header {name:?}: {reason}")]
98    InvalidHeader { name: String, reason: String },
99
100    #[cfg_attr(alef, alef(error_code = 113))]
101    #[error("serialization error: {0}")]
102    Serialization(
103        #[from]
104        #[cfg_attr(alef, alef(skip))]
105        serde_json::Error,
106    ),
107
108    #[cfg_attr(alef, alef(error_code = 114))]
109    #[error("budget exceeded: {message}")]
110    BudgetExceeded { message: String, model: Option<String> },
111
112    #[cfg_attr(alef, alef(error_code = 115))]
113    #[error("hook rejected: {message}")]
114    HookRejected { message: String },
115
116    /// An internal logic error (e.g. unexpected Tower response variant).
117    ///
118    /// This should never surface in normal operation — if it does, it
119    /// indicates a bug in the library.
120    #[cfg_attr(alef, alef(error_code = 116))]
121    #[error("internal error: {message}")]
122    InternalError { message: String },
123
124    /// An outbound request was blocked by the active [`crate::provider::OutboundPolicy`].
125    ///
126    /// Returned when `register_custom_provider` is called with a `base_url` that
127    /// violates the policy (e.g. a private-range IP under `DenyPrivate`), or when
128    /// the per-connection DNS resolver detects a forbidden address at connect time.
129    #[cfg_attr(alef, alef(error_code = 117))]
130    #[error("outbound request to {url} forbidden: {reason}")]
131    OutboundForbidden { url: String, reason: String },
132
133    /// A different request body was submitted for an existing `Idempotency-Key`.
134    ///
135    /// Per the OpenAI `Idempotency-Key` convention, once a key is used with a
136    /// particular request body, subsequent requests using the same key must carry
137    /// an identical body.  A body mismatch is a hard error (not retryable).
138    ///
139    /// HTTP equivalent: 409 Conflict.
140    #[cfg_attr(alef, alef(error_code = 118))]
141    #[error("idempotency conflict: key '{key}' was already used with a different request body")]
142    IdempotencyConflict { key: String },
143
144    /// The same `Idempotency-Key` is already in-flight (another request with the
145    /// same key is currently being processed).
146    ///
147    /// The caller should wait briefly and retry.  The response is not yet
148    /// available, and this request has been short-circuited to avoid running
149    /// the operation twice.
150    ///
151    /// HTTP equivalent: 409 Conflict (retryable after a brief delay).
152    #[cfg_attr(alef, alef(error_code = 119))]
153    #[error("idempotency key '{key}' is currently in-flight; retry after the first request completes")]
154    IdempotencyInFlight { key: String },
155
156    /// The upstream provider rejected the request because the account quota is exhausted.
157    #[cfg_attr(alef, alef(error_code = 120))]
158    #[error("provider quota exceeded: {message}")]
159    ProviderQuotaExceeded { message: String, status: u16 },
160}
161
162#[cfg(any(feature = "native-http", feature = "wasm-http"))]
163impl From<reqwest::Error> for LiterLlmError {
164    fn from(error: reqwest::Error) -> Self {
165        #[cfg(not(target_arch = "wasm32"))]
166        if let Some(policy_error) = crate::provider::outbound_forbidden_from_reqwest(&error) {
167            return policy_error;
168        }
169        if error.is_timeout() {
170            return Self::Timeout;
171        }
172        Self::Network {
173            message: error.without_url().to_string(),
174        }
175    }
176}
177
178impl LiterLlmError {
179    /// Returns the canonical HTTP status code associated with this error.
180    ///
181    /// Maps error variants to their originating HTTP status code as set by
182    /// the private `from_status` constructor. Used by e2e assertions that check
183    /// `error.status_code` against the expected HTTP status.
184    #[must_use]
185    pub fn status_code(&self) -> u16 {
186        match self {
187            Self::Authentication { status, .. } => *status,
188            Self::RateLimited { .. } => 429,
189            Self::BadRequest { status, .. } => *status,
190            Self::ContextWindowExceeded { .. } => 400,
191            Self::ContentPolicy { .. } => 400,
192            Self::NotFound { .. } => 404,
193            Self::ServerError { status, .. } => *status,
194            Self::ServiceUnavailable { status, .. } => *status,
195            Self::Timeout => 408,
196            Self::Network { .. } => 0,
197            Self::Streaming { .. } => 0,
198            Self::EndpointNotSupported { .. } => 400,
199            Self::InvalidHeader { .. } => 400,
200            Self::Serialization(_) => 0,
201            Self::BudgetExceeded { .. } => 0,
202            Self::HookRejected { .. } => 0,
203            Self::InternalError { .. } => 0,
204            Self::OutboundForbidden { .. } => 0,
205            Self::IdempotencyConflict { .. } => 409,
206            Self::IdempotencyInFlight { .. } => 409,
207            Self::ProviderQuotaExceeded { status, .. } => *status,
208        }
209    }
210
211    /// Returns the server-advertised retry delay, when the error carries one.
212    ///
213    /// Parsed from the upstream `Retry-After` header on a 429.  Exposed as an
214    /// accessor alongside [`Self::status_code`] and [`Self::error_type`] so the
215    /// generated bindings can surface it: the value reached the Rust core but
216    /// stopped there, leaving every binding consumer to re-derive its own
217    /// backoff from nothing more than the status code. ~keep
218    #[must_use]
219    pub fn retry_after(&self) -> Option<Duration> {
220        match self {
221            Self::RateLimited { retry_after, .. } | Self::ServiceUnavailable { retry_after, .. } => *retry_after,
222            _ => None,
223        }
224    }
225
226    /// Returns `true` for errors that are worth retrying on a different service
227    /// or deployment (transient failures).
228    ///
229    /// Used by [`crate::tower::fallback::FallbackService`] and
230    /// [`crate::tower::router::Router`] to decide whether to route to an
231    /// alternative endpoint.
232    #[must_use]
233    pub fn is_transient(&self) -> bool {
234        matches!(
235            self,
236            Self::RateLimited { .. }
237                | Self::ServiceUnavailable { .. }
238                | Self::Timeout
239                | Self::ServerError { .. }
240                | Self::Network { .. }
241        )
242    }
243
244    /// Return the OpenTelemetry `error.type` string for this error variant.
245    ///
246    /// Used by the tracing middleware to record the `error.type` span attribute
247    /// on failed requests per the GenAI semantic conventions.
248    #[must_use]
249    pub fn error_type(&self) -> &'static str {
250        match self {
251            Self::Authentication { .. } => "Authentication",
252            Self::RateLimited { .. } => "RateLimited",
253            Self::BadRequest { .. } => "BadRequest",
254            Self::ContextWindowExceeded { .. } => "ContextWindowExceeded",
255            Self::ContentPolicy { .. } => "ContentPolicy",
256            Self::NotFound { .. } => "NotFound",
257            Self::ServerError { .. } => "ServerError",
258            Self::ServiceUnavailable { .. } => "ServiceUnavailable",
259            Self::Timeout => "Timeout",
260            Self::Network { .. } => "Network",
261            Self::Streaming { .. } => "Streaming",
262            Self::EndpointNotSupported { .. } => "EndpointNotSupported",
263            Self::InvalidHeader { .. } => "InvalidHeader",
264            Self::Serialization(_) => "Serialization",
265            Self::BudgetExceeded { .. } => "BudgetExceeded",
266            Self::HookRejected { .. } => "HookRejected",
267            Self::InternalError { .. } => "InternalError",
268            Self::OutboundForbidden { .. } => "OutboundForbidden",
269            Self::IdempotencyConflict { .. } => "IdempotencyConflict",
270            Self::IdempotencyInFlight { .. } => "IdempotencyInFlight",
271            Self::ProviderQuotaExceeded { .. } => "ProviderQuotaExceeded",
272        }
273    }
274
275    /// Create a version of this error suitable for broadcasting via singleflight.
276    ///
277    /// `LiterLlmError` is not `Clone` because some variants hold non-Clone types
278    /// (e.g. `reqwest::Error`).  This method produces a semantically equivalent
279    /// error that *is* owned and can be placed behind an `Arc` for broadcast.
280    /// Variants that cannot be cloned exactly are converted to their nearest
281    /// owned equivalent while preserving the error class (variant discriminant).
282    #[cfg(feature = "tower")]
283    pub(crate) fn to_singleflight_error(&self) -> Self {
284        match self {
285            Self::Authentication { message, status } => Self::Authentication {
286                message: message.clone(),
287                status: *status,
288            },
289            Self::RateLimited { message, retry_after } => Self::RateLimited {
290                message: message.clone(),
291                retry_after: *retry_after,
292            },
293            Self::BadRequest { message, status } => Self::BadRequest {
294                message: message.clone(),
295                status: *status,
296            },
297            Self::ContextWindowExceeded { message } => Self::ContextWindowExceeded {
298                message: message.clone(),
299            },
300            Self::ContentPolicy { message } => Self::ContentPolicy {
301                message: message.clone(),
302            },
303            Self::NotFound { message } => Self::NotFound {
304                message: message.clone(),
305            },
306            Self::ServerError { message, status } => Self::ServerError {
307                message: message.clone(),
308                status: *status,
309            },
310            Self::ServiceUnavailable {
311                message,
312                status,
313                retry_after,
314            } => Self::ServiceUnavailable {
315                message: message.clone(),
316                status: *status,
317                retry_after: *retry_after,
318            },
319            Self::Timeout => Self::Timeout,
320            Self::Network { message } => Self::Network {
321                message: message.clone(),
322            },
323            Self::Streaming { message } => Self::Streaming {
324                message: message.clone(),
325            },
326            Self::EndpointNotSupported { endpoint, provider } => Self::EndpointNotSupported {
327                endpoint: endpoint.clone(),
328                provider: provider.clone(),
329            },
330            Self::InvalidHeader { name, reason } => Self::InvalidHeader {
331                name: name.clone(),
332                reason: reason.clone(),
333            },
334            Self::Serialization(e) => Self::InternalError { message: e.to_string() },
335            Self::BudgetExceeded { message, model } => Self::BudgetExceeded {
336                message: message.clone(),
337                model: model.clone(),
338            },
339            Self::HookRejected { message } => Self::HookRejected {
340                message: message.clone(),
341            },
342            Self::InternalError { message } => Self::InternalError {
343                message: message.clone(),
344            },
345            Self::OutboundForbidden { url, reason } => Self::OutboundForbidden {
346                url: url.clone(),
347                reason: reason.clone(),
348            },
349            Self::IdempotencyConflict { key } => Self::IdempotencyConflict { key: key.clone() },
350            Self::IdempotencyInFlight { key } => Self::IdempotencyInFlight { key: key.clone() },
351            Self::ProviderQuotaExceeded { message, status } => Self::ProviderQuotaExceeded {
352                message: message.clone(),
353                status: *status,
354            },
355        }
356    }
357
358    /// Create from an HTTP status code, an API error response body, and an
359    /// optional `Retry-After` duration already parsed from the response header.
360    ///
361    /// The `retry_after` value is forwarded into [`LiterLlmError::RateLimited`]
362    /// so callers can honour the server-requested delay without re-parsing the
363    /// header.
364    pub(crate) fn from_status(status: u16, body: &str, retry_after: Option<Duration>) -> Self {
365        let parsed = serde_json::from_str::<ErrorResponse>(body).ok();
366        let code = parsed.as_ref().and_then(|r| r.error.code.clone());
367        let message = parsed.map(|r| r.error.message).unwrap_or_else(|| body.to_string());
368        let message_lower = message.to_ascii_lowercase();
369
370        match status {
371            401 | 403 => Self::Authentication { message, status },
372            429 if code.as_deref() == Some("insufficient_quota") => Self::ProviderQuotaExceeded { message, status },
373            429 => Self::RateLimited { message, retry_after },
374            529 => Self::ServiceUnavailable {
375                message,
376                status,
377                retry_after,
378            },
379            400 | 422 => {
380                if code.as_deref() == Some("context_length_exceeded") {
381                    Self::ContextWindowExceeded { message }
382                } else if code.as_deref() == Some("content_policy_violation")
383                    || code.as_deref() == Some("content_filter")
384                {
385                    Self::ContentPolicy { message }
386                }
387                // ~keep Some providers omit `code`, so classify retryable errors from stable message fragments.
388                else if message_lower.contains("context_length_exceeded")
389                    || message_lower.contains("context window")
390                    || message_lower.contains("maximum context length")
391                    || message_lower.contains("prompt is too long")
392                    || message_lower.contains("input token count exceeds the maximum")
393                {
394                    Self::ContextWindowExceeded { message }
395                } else if message_lower.contains("content_policy") || message_lower.contains("content_filter") {
396                    Self::ContentPolicy { message }
397                } else {
398                    Self::BadRequest { message, status }
399                }
400            }
401            404 => Self::NotFound { message },
402            405 | 413 => Self::BadRequest { message, status },
403            408 => Self::Timeout,
404            500 => Self::ServerError { message, status },
405            502..=504 => Self::ServiceUnavailable {
406                message,
407                status,
408                retry_after,
409            },
410            400..=499 => Self::BadRequest { message, status },
411            _ => Self::ServerError { message, status },
412        }
413    }
414}
415
416#[cfg_attr(alef, alef(skip))]
417pub type Result<T> = std::result::Result<T, LiterLlmError>;