Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 99 additions & 57 deletions rust/crates/api/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,13 @@ pub enum ApiError {
body_snippet: String,
source: serde_json::Error,
},
Api {
status: reqwest::StatusCode,
error_type: Option<String>,
message: Option<String>,
request_id: Option<String>,
body: String,
retryable: bool,
/// Suggested user action based on error type (e.g., "Reduce prompt size" for 413)
suggested_action: Option<String>,
/// Parsed Retry-After header value (seconds) for 429 responses.
/// When present, overrides the exponential backoff delay.
retry_after: Option<Duration>,
},
/// A provider returned an error response.
///
/// Boxed: the payload is ~139 bytes, and `ApiError` is the `Err` half of nearly every
/// `Result` in this crate, so inlining it inflates the size of every one of those returns
/// (clippy::result_large_err). Boxing keeps `ApiError` small and means adding a field here
/// cannot silently re-inflate every signature in the crate.
Api(Box<ApiErrorDetails>),
RetriesExhausted {
attempts: u32,
last_error: Box<ApiError>,
Expand All @@ -81,7 +75,38 @@ pub enum ApiError {
},
}

/// Payload of [`ApiError::Api`], held behind a `Box` so the enum stays small.
#[derive(Debug)]
pub struct ApiErrorDetails {
pub status: reqwest::StatusCode,
pub error_type: Option<String>,
pub message: Option<String>,
pub request_id: Option<String>,
pub body: String,
pub retryable: bool,
/// Suggested user action based on error type (e.g., "Reduce prompt size" for 413)
pub suggested_action: Option<String>,
/// Parsed Retry-After header value (seconds) for 429 responses.
/// When present, overrides the exponential backoff delay.
pub retry_after: Option<Duration>,
}

impl ApiError {
/// Convenience constructor so callers do not have to spell out the `Box` at every site.
#[must_use]
pub fn api(details: ApiErrorDetails) -> Self {
Self::Api(Box::new(details))
}

/// Borrow the provider-error payload, if this is one.
#[must_use]
pub fn api_details(&self) -> Option<&ApiErrorDetails> {
match self {
Self::Api(details) => Some(details),
_ => None,
}
}

#[must_use]
pub const fn missing_credentials(
provider: &'static str,
Expand Down Expand Up @@ -137,7 +162,7 @@ impl ApiError {
/// over the computed backoff delay when it exists.
pub fn retry_after(&self) -> Option<Duration> {
match self {
Self::Api { retry_after, .. } => *retry_after,
Self::Api(details) => details.retry_after,
Self::RetriesExhausted { last_error, .. } => last_error.retry_after(),
_ => None,
}
Expand All @@ -146,7 +171,7 @@ impl ApiError {
pub fn is_retryable(&self) -> bool {
match self {
Self::Http(error) => error.is_connect() || error.is_timeout() || error.is_request(),
Self::Api { retryable, .. } => *retryable,
Self::Api(details) => details.retryable,
Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(),
Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
Expand All @@ -164,7 +189,7 @@ impl ApiError {
#[must_use]
pub fn request_id(&self) -> Option<&str> {
match self {
Self::Api { request_id, .. } => request_id.as_deref(),
Self::Api(details) => details.request_id.as_deref(),
Self::RetriesExhausted { last_error, .. } => last_error.request_id(),
Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
Expand All @@ -191,12 +216,12 @@ impl ApiError {
Self::MissingCredentials { .. } | Self::ExpiredOAuthToken | Self::Auth(_) => {
"provider_auth"
}
Self::Api { status, .. } if matches!(status.as_u16(), 401 | 403) => "provider_auth",
Self::Api(details) if matches!(details.status.as_u16(), 401 | 403) => "provider_auth",
Self::ContextWindowExceeded { .. } => "context_window",
Self::Api { .. } if self.is_context_window_failure() => "context_window",
Self::Api { status, .. } if status.as_u16() == 429 => "provider_rate_limit",
Self::Api { .. } if self.is_generic_fatal_wrapper() => "provider_internal",
Self::Api { .. } => "provider_error",
Self::Api(_) if self.is_context_window_failure() => "context_window",
Self::Api(details) if details.status.as_u16() == 429 => "provider_rate_limit",
Self::Api(_) if self.is_generic_fatal_wrapper() => "provider_internal",
Self::Api(_) => "provider_error",
Self::Http(_) | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => {
"provider_transport"
}
Expand All @@ -208,11 +233,12 @@ impl ApiError {
#[must_use]
pub fn is_generic_fatal_wrapper(&self) -> bool {
match self {
Self::Api { message, body, .. } => {
message
Self::Api(details) => {
details
.message
.as_deref()
.is_some_and(looks_like_generic_fatal_wrapper)
|| looks_like_generic_fatal_wrapper(body)
|| looks_like_generic_fatal_wrapper(&details.body)
}
Self::RetriesExhausted { last_error, .. } => last_error.is_generic_fatal_wrapper(),
Self::MissingCredentials { .. }
Expand All @@ -229,21 +255,36 @@ impl ApiError {
}
}

/// True for failures whose own text cannot say what went wrong: transport, decode, and frame
/// errors.
///
/// These matter because a server that answers an oversized request with a non-SSE HTTP 500
/// body lands here rather than in [`Self::is_context_window_failure`] — reqwest reports
/// "error decoding response body" and the real cause is lost. Callers that know how large the
/// request was can combine the two signals; callers that do not must treat this as unknown,
/// never as an overflow.
#[must_use]
pub fn is_ambiguous_transport_failure(&self) -> bool {
match self {
Self::Http(_) | Self::Json { .. } | Self::InvalidSseFrame(_) => true,
Self::RetriesExhausted { last_error, .. } => {
last_error.is_ambiguous_transport_failure()
}
_ => false,
}
}

#[must_use]
pub fn is_context_window_failure(&self) -> bool {
match self {
Self::ContextWindowExceeded { .. } => true,
Self::Api {
status,
message,
body,
..
} => {
matches!(status.as_u16(), 400 | 413 | 422)
&& (message
Self::Api(details) => {
matches!(details.status.as_u16(), 400 | 413 | 422)
&& (details
.message
.as_deref()
.is_some_and(looks_like_context_window_error)
|| looks_like_context_window_error(body))
|| looks_like_context_window_error(&details.body))
}
Self::RetriesExhausted { last_error, .. } => last_error.is_context_window_failure(),
Self::MissingCredentials { .. }
Expand Down Expand Up @@ -327,14 +368,14 @@ impl Display for ApiError {
"failed to parse {provider} response for model {model}: {source}; first 200 chars of body: {body_snippet}"
),
// #28: enhance 401/403 errors with actionable auth guidance
Self::Api {
status,
error_type,
message,
request_id,
body,
..
} if matches!(status.as_u16(), 401 | 403) => {
Self::Api(details) if matches!(details.status.as_u16(), 401 | 403) => {
let (status, error_type, message, request_id, body) = (
&details.status,
&details.error_type,
&details.message,
&details.request_id,
&details.body,
);
if let (Some(error_type), Some(message)) = (error_type, message) {
write!(f, "api returned {status} ({error_type})")?;
if let Some(request_id) = request_id {
Expand All @@ -356,14 +397,14 @@ impl Display for ApiError {
Run `claw doctor` to verify your credential configuration."
)
}
Self::Api {
status,
error_type,
message,
request_id,
body,
..
} => {
Self::Api(details) => {
let (status, error_type, message, request_id, body) = (
&details.status,
&details.error_type,
&details.message,
&details.request_id,
&details.body,
);
if let (Some(error_type), Some(message)) = (error_type, message) {
write!(f, "api returned {status} ({error_type})")?;
if let Some(request_id) = request_id {
Expand Down Expand Up @@ -469,6 +510,7 @@ fn truncate_body_snippet(body: &str, max_chars: usize) -> String {

#[cfg(test)]
mod tests {
use super::ApiErrorDetails;
use super::{truncate_body_snippet, ApiError};

#[test]
Expand Down Expand Up @@ -533,7 +575,7 @@ mod tests {

#[test]
fn detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal() {
let error = ApiError::Api {
let error = ApiError::Api(Box::new(ApiErrorDetails {
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
error_type: Some("api_error".to_string()),
message: Some(
Expand All @@ -545,7 +587,7 @@ mod tests {
retryable: true,
suggested_action: None,
retry_after: None,
};
}));

assert!(error.is_generic_fatal_wrapper());
assert_eq!(error.safe_failure_class(), "provider_internal");
Expand All @@ -557,7 +599,7 @@ mod tests {
fn retries_exhausted_preserves_nested_request_id_and_failure_class() {
let error = ApiError::RetriesExhausted {
attempts: 3,
last_error: Box::new(ApiError::Api {
last_error: Box::new(ApiError::Api(Box::new(ApiErrorDetails {
status: reqwest::StatusCode::BAD_GATEWAY,
error_type: Some("api_error".to_string()),
message: Some(
Expand All @@ -569,7 +611,7 @@ mod tests {
retryable: true,
suggested_action: None,
retry_after: None,
}),
}))),
};

assert!(error.is_generic_fatal_wrapper());
Expand All @@ -579,7 +621,7 @@ mod tests {

#[test]
fn classifies_provider_context_window_errors() {
let error = ApiError::Api {
let error = ApiError::Api(Box::new(ApiErrorDetails {
status: reqwest::StatusCode::BAD_REQUEST,
error_type: Some("invalid_request_error".to_string()),
message: Some(
Expand All @@ -591,7 +633,7 @@ mod tests {
retryable: false,
suggested_action: None,
retry_after: None,
};
}));

assert!(error.is_context_window_failure());
assert_eq!(error.safe_failure_class(), "context_window");
Expand All @@ -600,7 +642,7 @@ mod tests {

#[test]
fn classifies_openai_configured_limit_errors_as_context_window_failures() {
let error = ApiError::Api {
let error = ApiError::Api(Box::new(ApiErrorDetails {
status: reqwest::StatusCode::BAD_REQUEST,
error_type: Some("invalid_request_error".to_string()),
message: Some(
Expand All @@ -612,7 +654,7 @@ mod tests {
retryable: false,
suggested_action: None,
retry_after: None,
};
}));

assert!(error.is_context_window_failure());
assert_eq!(error.safe_failure_class(), "context_window");
Expand Down
9 changes: 5 additions & 4 deletions rust/crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ pub use client::{
oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token,
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
};
pub use error::ApiError;
pub use error::{ApiError, ApiErrorDetails};
pub use http_client::{
build_http_client, build_http_client_or_default, build_http_client_with,
build_http_client_with_opts, ProxyConfig, TimeoutConfig,
Expand All @@ -27,9 +27,10 @@ pub use providers::openai_compat::{
OpenAiCompatConfig,
};
pub use providers::{
detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override,
model_family_identity_for, model_family_identity_for_kind, provider_diagnostics_for_model,
resolve_model_alias, ProviderDiagnostics, ProviderKind,
context_window_override, detect_provider_kind, estimate_message_request_input_tokens,
max_tokens_for_model, max_tokens_for_model_with_override, model_family_identity_for,
model_family_identity_for_kind, model_token_limit, provider_diagnostics_for_model,
resolve_model_alias, ModelTokenLimit, ProviderDiagnostics, ProviderKind,
};
pub use sse::{parse_frame, SseParser};
pub use types::{
Expand Down
Loading