Skip to main content

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
115/// Reports whether an AI error could plausibly succeed on a retry.
116///
117/// Errors that are not a [`ClaudeError`] cannot be classified, so they are
118/// reported as transient: only a positively-identified permanent failure should
119/// abort a caller that would otherwise retry or degrade gracefully.
120#[must_use]
121pub fn is_transient_ai_error(error: &anyhow::Error) -> bool {
122    // `is_none_or` would read better but is stable only since 1.82; the
123    // project's MSRV is 1.80.
124    error
125        .downcast_ref::<ClaudeError>()
126        .map_or(true, ClaudeError::is_transient)
127}
128
129// Note: anyhow already has a blanket impl for thiserror::Error types
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    fn http(status: u16) -> ClaudeError {
136        ClaudeError::ApiHttpError {
137            status,
138            body: String::from("body"),
139        }
140    }
141
142    #[test]
143    fn non_retryable_client_errors_are_permanent() {
144        for status in [400, 401, 403, 404, 422] {
145            assert!(
146                !http(status).is_transient(),
147                "HTTP {status} should be permanent"
148            );
149        }
150    }
151
152    #[test]
153    fn retryable_statuses_are_transient() {
154        for status in [408, 429, 500, 502, 503, 529] {
155            assert!(
156                http(status).is_transient(),
157                "HTTP {status} should be transient"
158            );
159        }
160    }
161
162    #[test]
163    fn unclassified_errors_default_to_transient() {
164        assert!(ClaudeError::RateLimitExceeded.is_transient());
165        assert!(ClaudeError::NetworkError(String::from("reset")).is_transient());
166        assert!(ClaudeError::SubprocessTimeout { secs: 300 }.is_transient());
167        assert!(ClaudeError::InvalidResponseFormat(String::from("not yaml")).is_transient());
168        assert!(ClaudeError::ApiRequestFailed(String::from("opaque")).is_transient());
169    }
170
171    #[test]
172    fn api_http_error_displays_status_and_body() {
173        let rendered = http(404).to_string();
174        assert!(rendered.contains("404"), "{rendered}");
175        assert!(rendered.contains("body"), "{rendered}");
176    }
177}