omni_dev/claude/error.rs
1//! Claude-specific error handling.
2
3use thiserror::Error;
4
5/// Claude API specific errors.
6#[derive(Error, Debug)]
7pub enum ClaudeError {
8 /// API key not found in environment variables.
9 #[error(
10 "Claude API key not found. Set CLAUDE_API_KEY or ANTHROPIC_API_KEY environment variable"
11 )]
12 ApiKeyNotFound,
13
14 /// Claude API request failed with error message.
15 ///
16 /// Used where no HTTP status is available (subprocess failures, or an error
17 /// the backend could not attribute to a status). Prefer
18 /// [`ClaudeError::ApiHttpError`] whenever a status is known, so callers can
19 /// tell a permanent failure from a retryable one.
20 #[error("Claude API request failed: {0}")]
21 ApiRequestFailed(String),
22
23 /// AI API returned a non-success HTTP status.
24 #[error("Claude API request failed (HTTP {status}): {body}")]
25 ApiHttpError {
26 /// HTTP status code returned by the API.
27 status: u16,
28 /// Response body, used as the error detail.
29 body: String,
30 },
31
32 /// Invalid response format from Claude API.
33 #[error("Invalid response format from Claude API: {0}")]
34 InvalidResponseFormat(String),
35
36 /// Failed to parse amendments from Claude response.
37 #[error("Failed to parse amendments from Claude response: {0}")]
38 AmendmentParsingFailed(String),
39
40 /// Prompt exceeds the model's available input token budget.
41 #[error(
42 "Prompt too large for model '{model}': estimated {estimated_tokens} tokens, \
43 but only {max_tokens} input tokens available"
44 )]
45 PromptTooLarge {
46 /// Estimated token count of the assembled prompt.
47 estimated_tokens: usize,
48 /// Maximum available input tokens (context minus reserved output).
49 max_tokens: usize,
50 /// Model identifier.
51 model: String,
52 },
53
54 /// Rate limit exceeded for Claude API.
55 #[error("Rate limit exceeded. Please try again later")]
56 RateLimitExceeded,
57
58 /// Network connectivity error.
59 #[error("Network error: {0}")]
60 NetworkError(String),
61
62 /// Required subprocess binary is missing from PATH.
63 #[error("Subprocess binary not found: {0}")]
64 SubprocessBinaryMissing(String),
65
66 /// Failed to spawn a subprocess.
67 #[error("Failed to spawn subprocess: {0}")]
68 SubprocessSpawnFailed(String),
69
70 /// Subprocess exceeded the configured timeout.
71 #[error("Subprocess timed out after {secs} seconds")]
72 SubprocessTimeout {
73 /// Timeout that was exceeded, in seconds.
74 secs: u64,
75 },
76
77 /// Subprocess produced more output than the configured cap.
78 #[error("Subprocess output exceeded limit of {limit} bytes")]
79 SubprocessOutputTooLarge {
80 /// Configured stdout cap in bytes.
81 limit: usize,
82 },
83
84 /// Subprocess stdout was not valid JSON.
85 #[error("Subprocess produced invalid JSON output: {0}")]
86 SubprocessJsonParseFailed(String),
87}
88
89impl ClaudeError {
90 /// Returns `true` when retrying the request could plausibly succeed.
91 ///
92 /// Only a non-retryable 4xx is treated as permanent: the request is
93 /// malformed, unauthorised, or names something that does not exist (a
94 /// misspelled model, say), so no amount of retrying or falling back will
95 /// help. Everything else — 5xx, network failures, timeouts, and any error
96 /// this cannot positively classify — is reported as transient, which
97 /// preserves the historical fall-back-and-degrade behaviour for errors
98 /// whose permanence is unproven.
99 #[must_use]
100 pub fn is_transient(&self) -> bool {
101 match self {
102 Self::ApiHttpError { status, .. } => match status {
103 // Request timeout and rate limiting are explicitly retryable.
104 408 | 429 => true,
105 // Other client errors can never succeed as-issued.
106 400..=499 => false,
107 // 5xx, and anything unexpected, may be temporary.
108 _ => true,
109 },
110 _ => true,
111 }
112 }
113
114 /// Returns `true` when the endpoint rejected the structured-output field
115 /// `output_config` itself, rather than anything about the request's
116 /// content.
117 ///
118 /// Anthropic's Messages API takes `output_config.format` on models the
119 /// registry flags via `supports_structured_output`, but a gateway named by
120 /// `ANTHROPIC_BEDROCK_BASE_URL` may not pass the field through, and answers
121 /// a strict-schema rejection — `{"message": "output_config.format: Extra
122 /// inputs are not permitted"}` — rather than a model or content error
123 /// (issue #1561). That is a property of the *endpoint*, which the
124 /// per-model registry gate cannot know, so callers use this to drop to the
125 /// YAML response path instead of failing the run.
126 ///
127 /// The field-name match is the load-bearing half, keeping this narrow
128 /// enough that no ordinary `400` (bad model, oversized prompt, malformed
129 /// body) can trip it. `422` is accepted alongside `400` because
130 /// pydantic-style gateways conventionally use it for exactly this
131 /// unrecognised-field rejection.
132 #[must_use]
133 pub fn is_structured_output_rejection(&self) -> bool {
134 match self {
135 Self::ApiHttpError {
136 status: 400 | 422,
137 body,
138 } => body.to_ascii_lowercase().contains("output_config"),
139 _ => false,
140 }
141 }
142}
143
144/// Reports whether an AI error could plausibly succeed on a retry.
145///
146/// Errors that are not a [`ClaudeError`] cannot be classified, so they are
147/// reported as transient: only a positively-identified permanent failure should
148/// abort a caller that would otherwise retry or degrade gracefully.
149#[must_use]
150pub fn is_transient_ai_error(error: &anyhow::Error) -> bool {
151 // `is_none_or` would read better but is stable only since 1.82; the
152 // project's MSRV is 1.80.
153 error
154 .downcast_ref::<ClaudeError>()
155 .map_or(true, ClaudeError::is_transient)
156}
157
158/// Reports whether an AI error is an endpoint rejecting the `output_config`
159/// structured-output field — see
160/// [`ClaudeError::is_structured_output_rejection`].
161///
162/// Errors that are not a [`ClaudeError`] cannot be classified, so they are
163/// reported as `false`: only a positively-identified rejection may make a
164/// caller degrade to the YAML path.
165#[must_use]
166pub fn is_structured_output_rejection(error: &anyhow::Error) -> bool {
167 error
168 .downcast_ref::<ClaudeError>()
169 .is_some_and(ClaudeError::is_structured_output_rejection)
170}
171
172// Note: anyhow already has a blanket impl for thiserror::Error types
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 fn http(status: u16) -> ClaudeError {
179 ClaudeError::ApiHttpError {
180 status,
181 body: String::from("body"),
182 }
183 }
184
185 #[test]
186 fn non_retryable_client_errors_are_permanent() {
187 for status in [400, 401, 403, 404, 422] {
188 assert!(
189 !http(status).is_transient(),
190 "HTTP {status} should be permanent"
191 );
192 }
193 }
194
195 #[test]
196 fn retryable_statuses_are_transient() {
197 for status in [408, 429, 500, 502, 503, 529] {
198 assert!(
199 http(status).is_transient(),
200 "HTTP {status} should be transient"
201 );
202 }
203 }
204
205 #[test]
206 fn unclassified_errors_default_to_transient() {
207 assert!(ClaudeError::RateLimitExceeded.is_transient());
208 assert!(ClaudeError::NetworkError(String::from("reset")).is_transient());
209 assert!(ClaudeError::SubprocessTimeout { secs: 300 }.is_transient());
210 assert!(ClaudeError::InvalidResponseFormat(String::from("not yaml")).is_transient());
211 assert!(ClaudeError::ApiRequestFailed(String::from("opaque")).is_transient());
212 }
213
214 #[test]
215 fn api_http_error_displays_status_and_body() {
216 let rendered = http(404).to_string();
217 assert!(rendered.contains("404"), "{rendered}");
218 assert!(rendered.contains("body"), "{rendered}");
219 }
220
221 fn http_body(status: u16, body: &str) -> ClaudeError {
222 ClaudeError::ApiHttpError {
223 status,
224 body: String::from(body),
225 }
226 }
227
228 /// The reported gateway rejection (#1561) — and its `422` variant — are
229 /// recognised, in whatever case the endpoint spells the field.
230 #[test]
231 fn output_config_rejections_are_recognised() {
232 for status in [400, 422] {
233 assert!(
234 http_body(
235 status,
236 r#"{"message":"output_config.format: Extra inputs are not permitted"}"#
237 )
238 .is_structured_output_rejection(),
239 "HTTP {status} naming output_config should be recognised"
240 );
241 }
242 assert!(http_body(400, "OUTPUT_CONFIG is not supported").is_structured_output_rejection());
243 }
244
245 /// The predicate must stay narrow: an ordinary `4xx`, and a `5xx` that
246 /// happens to echo the field name, are not endpoint rejections of it.
247 #[test]
248 fn other_failures_are_not_output_config_rejections() {
249 assert!(!http_body(400, "max_tokens: must be positive").is_structured_output_rejection());
250 assert!(!http_body(404, "output_config").is_structured_output_rejection());
251 assert!(!http_body(500, "output_config exploded").is_structured_output_rejection());
252 assert!(
253 !ClaudeError::ApiRequestFailed(String::from("output_config"))
254 .is_structured_output_rejection()
255 );
256 }
257
258 /// An error that is not a [`ClaudeError`] cannot be classified, so the
259 /// `anyhow` helper reports `false` rather than degrading on a guess.
260 #[test]
261 fn anyhow_helper_classifies_only_claude_errors() {
262 let rejection: anyhow::Error = http_body(400, "output_config.format: nope").into();
263 assert!(is_structured_output_rejection(&rejection));
264
265 let other: anyhow::Error = http_body(400, "bad request").into();
266 assert!(!is_structured_output_rejection(&other));
267
268 let foreign = anyhow::anyhow!("output_config");
269 assert!(!is_structured_output_rejection(&foreign));
270 }
271}