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}