use thiserror::Error;
#[derive(Error, Debug)]
pub enum ClaudeError {
#[error(
"Claude API key not found. Set CLAUDE_API_KEY or ANTHROPIC_API_KEY environment variable"
)]
ApiKeyNotFound,
#[error("Claude API request failed: {0}")]
ApiRequestFailed(String),
#[error("Claude API request failed (HTTP {status}): {body}")]
ApiHttpError {
status: u16,
body: String,
},
#[error("Invalid response format from Claude API: {0}")]
InvalidResponseFormat(String),
#[error("Failed to parse amendments from Claude response: {0}")]
AmendmentParsingFailed(String),
#[error(
"Prompt too large for model '{model}': estimated {estimated_tokens} tokens, \
but only {max_tokens} input tokens available"
)]
PromptTooLarge {
estimated_tokens: usize,
max_tokens: usize,
model: String,
},
#[error("Rate limit exceeded. Please try again later")]
RateLimitExceeded,
#[error("Network error: {0}")]
NetworkError(String),
#[error("Subprocess binary not found: {0}")]
SubprocessBinaryMissing(String),
#[error("Failed to spawn subprocess: {0}")]
SubprocessSpawnFailed(String),
#[error("Subprocess timed out after {secs} seconds")]
SubprocessTimeout {
secs: u64,
},
#[error("Subprocess output exceeded limit of {limit} bytes")]
SubprocessOutputTooLarge {
limit: usize,
},
#[error("Subprocess produced invalid JSON output: {0}")]
SubprocessJsonParseFailed(String),
}
impl ClaudeError {
#[must_use]
pub fn is_transient(&self) -> bool {
match self {
Self::ApiHttpError { status, .. } => match status {
408 | 429 => true,
400..=499 => false,
_ => true,
},
_ => true,
}
}
#[must_use]
pub fn is_structured_output_rejection(&self) -> bool {
match self {
Self::ApiHttpError {
status: 400 | 422,
body,
} => body.to_ascii_lowercase().contains("output_config"),
_ => false,
}
}
}
#[must_use]
pub fn is_transient_ai_error(error: &anyhow::Error) -> bool {
error
.downcast_ref::<ClaudeError>()
.map_or(true, ClaudeError::is_transient)
}
#[must_use]
pub fn is_structured_output_rejection(error: &anyhow::Error) -> bool {
error
.downcast_ref::<ClaudeError>()
.is_some_and(ClaudeError::is_structured_output_rejection)
}
#[cfg(test)]
mod tests {
use super::*;
fn http(status: u16) -> ClaudeError {
ClaudeError::ApiHttpError {
status,
body: String::from("body"),
}
}
#[test]
fn non_retryable_client_errors_are_permanent() {
for status in [400, 401, 403, 404, 422] {
assert!(
!http(status).is_transient(),
"HTTP {status} should be permanent"
);
}
}
#[test]
fn retryable_statuses_are_transient() {
for status in [408, 429, 500, 502, 503, 529] {
assert!(
http(status).is_transient(),
"HTTP {status} should be transient"
);
}
}
#[test]
fn unclassified_errors_default_to_transient() {
assert!(ClaudeError::RateLimitExceeded.is_transient());
assert!(ClaudeError::NetworkError(String::from("reset")).is_transient());
assert!(ClaudeError::SubprocessTimeout { secs: 300 }.is_transient());
assert!(ClaudeError::InvalidResponseFormat(String::from("not yaml")).is_transient());
assert!(ClaudeError::ApiRequestFailed(String::from("opaque")).is_transient());
}
#[test]
fn api_http_error_displays_status_and_body() {
let rendered = http(404).to_string();
assert!(rendered.contains("404"), "{rendered}");
assert!(rendered.contains("body"), "{rendered}");
}
fn http_body(status: u16, body: &str) -> ClaudeError {
ClaudeError::ApiHttpError {
status,
body: String::from(body),
}
}
#[test]
fn output_config_rejections_are_recognised() {
for status in [400, 422] {
assert!(
http_body(
status,
r#"{"message":"output_config.format: Extra inputs are not permitted"}"#
)
.is_structured_output_rejection(),
"HTTP {status} naming output_config should be recognised"
);
}
assert!(http_body(400, "OUTPUT_CONFIG is not supported").is_structured_output_rejection());
}
#[test]
fn other_failures_are_not_output_config_rejections() {
assert!(!http_body(400, "max_tokens: must be positive").is_structured_output_rejection());
assert!(!http_body(404, "output_config").is_structured_output_rejection());
assert!(!http_body(500, "output_config exploded").is_structured_output_rejection());
assert!(
!ClaudeError::ApiRequestFailed(String::from("output_config"))
.is_structured_output_rejection()
);
}
#[test]
fn anyhow_helper_classifies_only_claude_errors() {
let rejection: anyhow::Error = http_body(400, "output_config.format: nope").into();
assert!(is_structured_output_rejection(&rejection));
let other: anyhow::Error = http_body(400, "bad request").into();
assert!(!is_structured_output_rejection(&other));
let foreign = anyhow::anyhow!("output_config");
assert!(!is_structured_output_rejection(&foreign));
}
}