Skip to main content

llm_error_class/
lib.rs

1//! # llm-error-class
2//!
3//! Classify provider error responses from Anthropic, OpenAI, Google, and
4//! AWS Bedrock into a small set of retriable / non-retriable kinds.
5//!
6//! Every provider returns errors in its own shape — Anthropic wraps them
7//! in `{"type":"error","error":{"type":"...","message":"..."}}`, OpenAI
8//! in `{"error":{"type":"...","code":"..."}}`, Bedrock as one of a dozen
9//! Java-exception-style names. This crate gives you a single
10//! [`ErrorClass`] enum and one [`classify`] function that handles all
11//! four providers via HTTP status + body.
12//!
13//! ## Example
14//!
15//! ```
16//! use llm_error_class::{classify, ErrorClass};
17//! let body = r#"{"error":{"type":"rate_limit_error","message":"slow down"}}"#;
18//! assert_eq!(classify(429, body), ErrorClass::RateLimit);
19//! assert!(classify(429, body).is_retriable());
20//! ```
21
22#![deny(missing_docs)]
23
24#[cfg(feature = "serde")]
25use serde::{Deserialize, Serialize};
26
27/// Provider-agnostic error class.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
30pub enum ErrorClass {
31    /// HTTP 429 / `rate_limit_error` / `ThrottlingException`. Retry with
32    /// backoff.
33    RateLimit,
34    /// Provider is overloaded (Anthropic `overloaded_error`,
35    /// `ServiceUnavailableException`). Retry with backoff.
36    Overloaded,
37    /// Generic 5xx. Retry with backoff.
38    Server,
39    /// Request timed out at the server or transport. Retry.
40    Timeout,
41    /// HTTP 401 / 403. **Do not retry.** Caller must fix credentials.
42    Auth,
43    /// Input exceeded the model's context window
44    /// (`context_length_exceeded`, `ValidationException` with token
45    /// count). **Do not retry** without shrinking the prompt.
46    ContextWindow,
47    /// Output blocked by a content policy filter. **Do not retry.**
48    ContentPolicy,
49    /// 400 with a parser-level error in the request body. **Do not
50    /// retry** without fixing the request.
51    Malformed,
52    /// 404 (model not found, etc.). **Do not retry.**
53    NotFound,
54    /// HTTP 402 / `insufficient_quota` / `BillingException`. **Do not
55    /// retry** until the account is funded.
56    BillingQuota,
57    /// Anything we did not recognize. Caller decides what to do.
58    Unknown,
59}
60
61impl ErrorClass {
62    /// True for classes that are worth retrying with backoff.
63    ///
64    /// Retriable: [`RateLimit`](Self::RateLimit),
65    /// [`Overloaded`](Self::Overloaded), [`Server`](Self::Server),
66    /// [`Timeout`](Self::Timeout). Everything else is terminal.
67    pub fn is_retriable(self) -> bool {
68        matches!(
69            self,
70            ErrorClass::RateLimit
71                | ErrorClass::Overloaded
72                | ErrorClass::Server
73                | ErrorClass::Timeout
74        )
75    }
76}
77
78/// Classify an HTTP error response from any supported provider.
79///
80/// Pass the HTTP status code and the response body text. The body
81/// keywords drive most of the classification; the status code is the
82/// fallback when the body has no recognizable type field.
83pub fn classify(status: u16, body: &str) -> ErrorClass {
84    // Try body-typed signals first; they are stronger than the status.
85    let lower = body.to_ascii_lowercase();
86
87    // Anthropic / OpenAI-style type strings.
88    if has(&lower, "rate_limit") || has(&lower, "throttling") || has(&lower, "too many requests") {
89        return ErrorClass::RateLimit;
90    }
91    if has(&lower, "overloaded") || has(&lower, "serviceunavailable") {
92        return ErrorClass::Overloaded;
93    }
94    if has(&lower, "context_length_exceeded")
95        || has(&lower, "maximum context length")
96        || has(&lower, "exceeds the maximum")
97        || has(&lower, "context window")
98    {
99        return ErrorClass::ContextWindow;
100    }
101    if has(&lower, "content_policy") || has(&lower, "content_filter") || has(&lower, "safety") {
102        return ErrorClass::ContentPolicy;
103    }
104    if has(&lower, "insufficient_quota") || has(&lower, "billing") || has(&lower, "credit") {
105        return ErrorClass::BillingQuota;
106    }
107    if has(&lower, "timeout") || has(&lower, "timed out") {
108        return ErrorClass::Timeout;
109    }
110    if has(&lower, "authentication")
111        || has(&lower, "invalid api key")
112        || has(&lower, "unauthorized")
113        || has(&lower, "forbidden")
114    {
115        return ErrorClass::Auth;
116    }
117    if has(&lower, "not found") || has(&lower, "model not found") {
118        return ErrorClass::NotFound;
119    }
120    if has(&lower, "invalid_request")
121        || has(&lower, "validationexception")
122        || has(&lower, "bad request")
123        || has(&lower, "malformed")
124    {
125        return ErrorClass::Malformed;
126    }
127
128    // Fallback by HTTP status.
129    match status {
130        401 | 403 => ErrorClass::Auth,
131        402 => ErrorClass::BillingQuota,
132        404 => ErrorClass::NotFound,
133        408 => ErrorClass::Timeout,
134        429 => ErrorClass::RateLimit,
135        503 => ErrorClass::Overloaded,
136        500..=599 => ErrorClass::Server,
137        400 => ErrorClass::Malformed,
138        _ => ErrorClass::Unknown,
139    }
140}
141
142fn has(haystack: &str, needle: &str) -> bool {
143    haystack.contains(needle)
144}