Skip to main content

claude_api/
error.rs

1//! Error type, result alias, and wire-format error payload.
2//!
3//! Every endpoint method returns [`Result<T>`](Result) where the
4//! error variant is [`Error`]. The error carries:
5//!
6//! - **HTTP status** (when the API responded with one) via
7//!   [`Error::status`]
8//! - **`request-id`** (always populated when the server sent one)
9//!   via [`Error::request_id`] -- this is the field to include in
10//!   support tickets
11//! - **Retry classification** via [`Error::is_retryable`] -- the
12//!   [`retry::RetryPolicy`](crate::retry::RetryPolicy) layer uses
13//!   the same logic
14//! - **`Retry-After` honoring** via [`Error::retry_after`] -- the
15//!   parsed `Retry-After` header (in seconds), used by the retry
16//!   layer to wait before the next attempt
17//! - **Structured kind** via [`ApiErrorKind`] for documented API
18//!   error types (rate-limit, authentication, not-found, etc.)
19//!
20//! # Quick start
21//!
22//! ```no_run
23//! use claude_api::{Client, messages::CreateMessageRequest, types::ModelId};
24//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
25//! let client = Client::new("sk-ant-...");
26//! let req = CreateMessageRequest::builder()
27//!     .model(ModelId::SONNET_4_6)
28//!     .max_tokens(8)
29//!     .user("hi")
30//!     .build()?;
31//!
32//! match client.messages().create(req).await {
33//!     Ok(resp) => println!("ok: {}", resp.id),
34//!     Err(e) if e.is_retryable() => {
35//!         // The retry layer already retried; if we're here, attempts
36//!         // were exhausted.
37//!         eprintln!("gave up after retries: {} (request_id={:?})",
38//!                   e, e.request_id());
39//!     }
40//!     Err(e) => {
41//!         // Permanent error -- 4xx, deserialization, signing, etc.
42//!         eprintln!("error: {} (request_id={:?})",
43//!                   e, e.request_id());
44//!     }
45//! }
46//! # Ok(()) }
47//! ```
48//!
49//! Variants tied to optional features (`async`/`sync` for
50//! [`Error::Network`], `streaming` for [`Error::Stream`]) are
51//! conditionally compiled out when those features are disabled.
52
53use std::time::Duration;
54
55use serde::{Deserialize, Serialize};
56
57/// Crate-wide result alias.
58pub type Result<T, E = Error> = std::result::Result<T, E>;
59
60/// Errors returned by this crate.
61///
62/// Variants tied to optional features (`async`/`sync` for [`Error::Network`],
63/// `streaming` for [`Error::Stream`]) are conditionally compiled out when
64/// those features are disabled. Use [`Error::is_retryable`] to decide
65/// whether to retry; the [`crate::retry`] layer uses the same logic.
66#[derive(Debug, thiserror::Error)]
67#[non_exhaustive]
68pub enum Error {
69    /// The Anthropic API returned an error response.
70    #[error("API error ({status}): {message}")]
71    #[non_exhaustive]
72    Api {
73        /// HTTP status code returned by the API.
74        status: http::StatusCode,
75        /// `request-id` header from the response, if present. Critical for
76        /// support tickets.
77        request_id: Option<String>,
78        /// Decoded error category from the response body.
79        kind: ApiErrorKind,
80        /// Human-readable error message from the response body.
81        message: String,
82        /// `Retry-After` value parsed from the response, if present.
83        retry_after: Option<Duration>,
84    },
85    /// Underlying HTTP transport failed (timeout, connection refused, DNS, etc.).
86    #[cfg(any(feature = "async", feature = "sync"))]
87    #[cfg_attr(docsrs, doc(cfg(any(feature = "async", feature = "sync"))))]
88    #[error("network error: {0}")]
89    Network(#[from] reqwest::Error),
90    /// Response body could not be parsed as JSON.
91    #[error("decode error: {0}")]
92    Decode(#[from] serde_json::Error),
93    /// Streaming error (parse, connection lost, server-emitted error event).
94    #[cfg(feature = "streaming")]
95    #[cfg_attr(docsrs, doc(cfg(feature = "streaming")))]
96    #[error("stream error: {0}")]
97    Stream(#[from] StreamError),
98    /// The [`crate::ClientBuilder`] was misconfigured.
99    #[error("invalid configuration: {0}")]
100    InvalidConfig(String),
101    /// Local I/O failed (e.g. reading a file to upload).
102    #[error("IO error: {0}")]
103    Io(#[from] std::io::Error),
104    /// The agent loop runner reached its iteration limit without the model
105    /// producing a non-`tool_use` stop reason.
106    #[error("agent loop exceeded max iterations ({max})")]
107    MaxIterationsExceeded {
108        /// Configured iteration cap.
109        max: u32,
110    },
111    /// The agent loop's configured cost budget was exceeded after a turn.
112    /// `spent_usd` reflects the cumulative cost recorded on the conversation
113    /// at the moment the budget check fired.
114    #[error("agent loop exceeded cost budget: ${spent_usd:.4} > ${budget_usd:.4}")]
115    CostBudgetExceeded {
116        /// Configured ceiling.
117        budget_usd: f64,
118        /// Cumulative spend at the time of the check.
119        spent_usd: f64,
120    },
121    /// A cancellation token signaled abort between iterations.
122    #[error("agent loop cancelled")]
123    Cancelled,
124    /// A `ToolApprover` returned `ApprovalDecision::Stop`, ending the loop
125    /// before the named tool could run.
126    #[error("agent loop stopped by approval gate at tool '{tool_name}': {reason}")]
127    ToolApprovalStopped {
128        /// Name of the tool whose approval check returned `Stop`.
129        tool_name: String,
130        /// Reason supplied by the approver.
131        reason: String,
132    },
133    /// A [`RequestSigner`](crate::auth::RequestSigner) returned an error
134    /// while signing an outbound request.
135    #[cfg(feature = "async")]
136    #[cfg_attr(docsrs, doc(cfg(feature = "async")))]
137    #[error("request signing failed: {0}")]
138    Signing(Box<dyn std::error::Error + Send + Sync + 'static>),
139}
140
141impl Error {
142    /// Returns `true` if the error represents a transient failure worth retrying.
143    ///
144    /// Single source of truth used by both [`crate::retry::RetryPolicy`] and
145    /// callers handling retries themselves.
146    pub fn is_retryable(&self) -> bool {
147        match self {
148            Error::Api { status, .. } => {
149                matches!(
150                    status.as_u16(),
151                    408 | 425 | 429 | 500 | 502 | 503 | 504 | 529
152                )
153            }
154            #[cfg(any(feature = "async", feature = "sync"))]
155            Error::Network(e) => e.is_timeout() || e.is_connect(),
156            #[cfg(feature = "streaming")]
157            Error::Stream(_) => false,
158            Error::Decode(_)
159            | Error::InvalidConfig(_)
160            | Error::Io(_)
161            | Error::MaxIterationsExceeded { .. }
162            | Error::CostBudgetExceeded { .. }
163            | Error::Cancelled
164            | Error::ToolApprovalStopped { .. } => false,
165            #[cfg(feature = "async")]
166            Error::Signing(_) => false,
167        }
168    }
169
170    /// `request-id` header from the API response, if this is an [`Error::Api`].
171    pub fn request_id(&self) -> Option<&str> {
172        match self {
173            Error::Api { request_id, .. } => request_id.as_deref(),
174            _ => None,
175        }
176    }
177
178    /// `Retry-After` value from the API response, if any.
179    pub fn retry_after(&self) -> Option<Duration> {
180        match self {
181            Error::Api { retry_after, .. } => *retry_after,
182            _ => None,
183        }
184    }
185
186    /// HTTP status code, if this is an [`Error::Api`].
187    pub fn status(&self) -> Option<http::StatusCode> {
188        match self {
189            Error::Api { status, .. } => Some(*status),
190            _ => None,
191        }
192    }
193
194    /// Build an [`Error::Api`] from the parts of an HTTP error response.
195    ///
196    /// `body` is the raw response body bytes; the function attempts to decode
197    /// it as the standard `{"type": "error", "error": ApiErrorPayload}`
198    /// envelope and falls back to a string-only payload if decoding fails.
199    ///
200    /// Used by the HTTP client; allowed-as-dead-code until task #8 lands.
201    #[allow(dead_code)]
202    pub(crate) fn from_response(
203        status: http::StatusCode,
204        request_id: Option<String>,
205        retry_after_header: Option<&str>,
206        body: &[u8],
207    ) -> Error {
208        let retry_after = retry_after_header.and_then(parse_retry_after);
209        let payload = serde_json::from_slice::<ErrorEnvelope>(body).map_or_else(
210            |_| ApiErrorPayload {
211                kind: ApiErrorKind::ApiError,
212                message: String::from_utf8_lossy(body).into_owned(),
213            },
214            |e| e.error,
215        );
216        Error::Api {
217            status,
218            request_id,
219            kind: payload.kind,
220            message: payload.message,
221            retry_after,
222        }
223    }
224}
225
226/// Parse a `Retry-After` header value to a [`Duration`].
227///
228/// Supports the delta-seconds form only (e.g. `"120"`); HTTP-date form
229/// returns `None`. Used by the HTTP client; allowed-as-dead-code until #8.
230#[allow(dead_code)]
231pub(crate) fn parse_retry_after(header: &str) -> Option<Duration> {
232    header.trim().parse::<u64>().ok().map(Duration::from_secs)
233}
234
235/// Internal wire envelope for HTTP error responses:
236/// `{"type": "error", "error": ApiErrorPayload}`.
237#[derive(Deserialize)]
238#[allow(dead_code)]
239struct ErrorEnvelope {
240    error: ApiErrorPayload,
241}
242
243/// Wire-format error payload, as it appears inside an HTTP error response or
244/// inside a streaming `error` event.
245///
246/// The wire shape is:
247///
248/// ```json
249/// {"type": "overloaded_error", "message": "..."}
250/// ```
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252#[non_exhaustive]
253pub struct ApiErrorPayload {
254    /// Error category. Renamed from the wire `"type"` field for ergonomics.
255    #[serde(rename = "type")]
256    pub kind: ApiErrorKind,
257    /// Human-readable error message.
258    pub message: String,
259}
260
261/// Categories of errors the Anthropic API can return.
262///
263/// The wire form uses `snake_case` strings ending in `_error`
264/// (e.g. `overloaded_error`). Unknown values deserialize to
265/// [`ApiErrorKind::Other`] so a new error category from the server does not
266/// break older SDK versions.
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
268#[serde(rename_all = "snake_case")]
269#[non_exhaustive]
270pub enum ApiErrorKind {
271    /// 400 -- request was malformed or violated API constraints.
272    InvalidRequestError,
273    /// 401 -- API key missing or invalid.
274    AuthenticationError,
275    /// 403 -- API key lacks permission for this resource.
276    PermissionError,
277    /// 404 -- resource does not exist.
278    NotFoundError,
279    /// 429 -- rate limit exceeded.
280    RateLimitError,
281    /// 500 -- internal server error.
282    ApiError,
283    /// 529 -- server is overloaded.
284    OverloadedError,
285    /// An unrecognized error category; the SDK is older than the API.
286    #[serde(other)]
287    Other,
288}
289
290/// Errors specific to the streaming layer.
291///
292/// Mid-stream failures cannot be retried safely (we'd silently drop content);
293/// see [`Error::is_retryable`].
294#[cfg(feature = "streaming")]
295#[cfg_attr(docsrs, doc(cfg(feature = "streaming")))]
296#[derive(Debug, thiserror::Error)]
297#[non_exhaustive]
298pub enum StreamError {
299    /// Failed to parse a wire-level SSE event.
300    #[error("stream parse error: {0}")]
301    Parse(String),
302    /// Connection dropped or other transport failure mid-stream.
303    #[error("stream connection lost: {0}")]
304    Connection(String),
305    /// Server emitted a typed `error` event mid-stream.
306    #[error("server emitted error event: {kind:?}: {message}")]
307    Server {
308        /// Error category from the event payload.
309        kind: ApiErrorKind,
310        /// Human-readable error message.
311        message: String,
312    },
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use pretty_assertions::assert_eq;
319    use serde_json::json;
320
321    #[test]
322    fn api_error_payload_round_trips() {
323        let payload = ApiErrorPayload {
324            kind: ApiErrorKind::OverloadedError,
325            message: "server overloaded".into(),
326        };
327        let v = serde_json::to_value(&payload).unwrap();
328        assert_eq!(
329            v,
330            json!({"type": "overloaded_error", "message": "server overloaded"})
331        );
332        let parsed: ApiErrorPayload = serde_json::from_value(v).unwrap();
333        assert_eq!(parsed, payload);
334    }
335
336    #[test]
337    fn api_error_kind_round_trips_known_variants() {
338        for (variant, wire) in [
339            (ApiErrorKind::InvalidRequestError, "invalid_request_error"),
340            (ApiErrorKind::AuthenticationError, "authentication_error"),
341            (ApiErrorKind::PermissionError, "permission_error"),
342            (ApiErrorKind::NotFoundError, "not_found_error"),
343            (ApiErrorKind::RateLimitError, "rate_limit_error"),
344            (ApiErrorKind::ApiError, "api_error"),
345            (ApiErrorKind::OverloadedError, "overloaded_error"),
346        ] {
347            let v = serde_json::to_value(variant).unwrap();
348            assert_eq!(v, json!(wire));
349            let parsed: ApiErrorKind = serde_json::from_value(v).unwrap();
350            assert_eq!(parsed, variant);
351        }
352    }
353
354    #[test]
355    fn api_error_kind_unknown_falls_to_other() {
356        let parsed: ApiErrorKind = serde_json::from_str("\"future_error_type\"").unwrap();
357        assert_eq!(parsed, ApiErrorKind::Other);
358    }
359
360    fn api_error(status: u16) -> Error {
361        Error::Api {
362            status: http::StatusCode::from_u16(status).unwrap(),
363            request_id: None,
364            kind: ApiErrorKind::ApiError,
365            message: "x".into(),
366            retry_after: None,
367        }
368    }
369
370    #[test]
371    fn is_retryable_for_transient_statuses() {
372        for s in [408u16, 425, 429, 500, 502, 503, 504, 529] {
373            assert!(api_error(s).is_retryable(), "{s} should retry");
374        }
375    }
376
377    #[test]
378    fn is_not_retryable_for_client_errors() {
379        for s in [400u16, 401, 403, 404, 422] {
380            assert!(!api_error(s).is_retryable(), "{s} should not retry");
381        }
382    }
383
384    #[test]
385    fn is_not_retryable_for_decode_invalidconfig_io() {
386        let decode = Error::Decode(serde_json::from_str::<u32>("\"oops\"").unwrap_err());
387        assert!(!decode.is_retryable());
388
389        let cfg = Error::InvalidConfig("missing api key".into());
390        assert!(!cfg.is_retryable());
391
392        let io = Error::Io(std::io::Error::other("bad"));
393        assert!(!io.is_retryable());
394    }
395
396    #[test]
397    fn parse_retry_after_seconds() {
398        assert_eq!(parse_retry_after("120"), Some(Duration::from_secs(120)));
399        assert_eq!(parse_retry_after("  5 "), Some(Duration::from_secs(5)));
400        assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
401    }
402
403    #[test]
404    fn parse_retry_after_rejects_garbage() {
405        assert_eq!(parse_retry_after("not a number"), None);
406        // HTTP-date form is not supported in v0.1; we return None.
407        assert_eq!(parse_retry_after("Wed, 21 Oct 2015 07:28:00 GMT"), None);
408        assert_eq!(parse_retry_after(""), None);
409    }
410
411    #[test]
412    fn from_response_decodes_typed_error_envelope() {
413        let body =
414            br#"{"type": "error", "error": {"type": "rate_limit_error", "message": "slow down"}}"#;
415        let err = Error::from_response(
416            http::StatusCode::TOO_MANY_REQUESTS,
417            Some("req_abc".into()),
418            Some("12"),
419            body,
420        );
421        match err {
422            Error::Api {
423                status,
424                request_id,
425                kind,
426                message,
427                retry_after,
428            } => {
429                assert_eq!(status, http::StatusCode::TOO_MANY_REQUESTS);
430                assert_eq!(request_id.as_deref(), Some("req_abc"));
431                assert_eq!(kind, ApiErrorKind::RateLimitError);
432                assert_eq!(message, "slow down");
433                assert_eq!(retry_after, Some(Duration::from_secs(12)));
434            }
435            other => panic!("expected Api, got {other:?}"),
436        }
437    }
438
439    #[test]
440    fn from_response_falls_back_for_non_json_body() {
441        let body = b"<html>oops</html>";
442        let err = Error::from_response(http::StatusCode::BAD_GATEWAY, None, None, body);
443        match err {
444            Error::Api {
445                status,
446                kind,
447                message,
448                retry_after,
449                ..
450            } => {
451                assert_eq!(status, http::StatusCode::BAD_GATEWAY);
452                assert_eq!(kind, ApiErrorKind::ApiError); // fallback
453                assert_eq!(message, "<html>oops</html>");
454                assert_eq!(retry_after, None);
455            }
456            other => panic!("expected Api, got {other:?}"),
457        }
458    }
459
460    #[test]
461    fn accessors_return_request_id_and_retry_after() {
462        let err = Error::Api {
463            status: http::StatusCode::INTERNAL_SERVER_ERROR,
464            request_id: Some("rid".into()),
465            kind: ApiErrorKind::ApiError,
466            message: "boom".into(),
467            retry_after: Some(Duration::from_secs(3)),
468        };
469        assert_eq!(err.request_id(), Some("rid"));
470        assert_eq!(err.retry_after(), Some(Duration::from_secs(3)));
471        assert_eq!(err.status(), Some(http::StatusCode::INTERNAL_SERVER_ERROR));
472
473        let cfg = Error::InvalidConfig("nope".into());
474        assert_eq!(cfg.request_id(), None);
475        assert_eq!(cfg.retry_after(), None);
476        assert_eq!(cfg.status(), None);
477    }
478
479    #[test]
480    fn display_impl_includes_status_and_message() {
481        let err = api_error(503);
482        let s = format!("{err}");
483        assert!(s.contains("503"), "{s}");
484        assert!(s.contains('x'), "{s}");
485    }
486
487    #[cfg(feature = "streaming")]
488    #[test]
489    fn stream_errors_are_not_retryable() {
490        let err = Error::Stream(StreamError::Connection("dropped".into()));
491        assert!(!err.is_retryable());
492    }
493}