Skip to main content

honcho_ai/
error.rs

1//! Error types for the Honcho SDK.
2
3use std::time::{Duration, SystemTime};
4
5use chrono::{DateTime, Utc};
6use httpdate::parse_http_date;
7use reqwest::StatusCode;
8use reqwest::header::{HeaderMap, HeaderValue};
9
10/// Error type for all Honcho SDK operations.
11#[derive(Debug, thiserror::Error)]
12#[non_exhaustive]
13pub enum HonchoError {
14    /// 400 Bad Request
15    #[error("Honcho API error: HTTP 400 {message}")]
16    BadRequest {
17        /// Error message from the API.
18        message: String,
19        /// Full response body if available.
20        body: Option<serde_json::Value>,
21    },
22    /// 401 Authentication Error
23    #[error("Honcho API error: HTTP 401 {message}")]
24    Authentication {
25        /// Error message.
26        message: String,
27    },
28    /// 403 Permission Denied
29    #[error("Honcho API error: HTTP 403 {message}")]
30    PermissionDenied {
31        /// Error message.
32        message: String,
33    },
34    /// 404 Not Found
35    #[error("Honcho API error: HTTP 404 {message}")]
36    NotFound {
37        /// Error message.
38        message: String,
39    },
40    /// 409 Conflict
41    #[error("Honcho API error: HTTP 409 {message}")]
42    Conflict {
43        /// Error message.
44        message: String,
45        /// Full response body if available.
46        body: Option<serde_json::Value>,
47    },
48    /// 422 Unprocessable Entity
49    #[error("Honcho API error: HTTP 422 {message}")]
50    UnprocessableEntity {
51        /// Error message.
52        message: String,
53        /// Full response body if available.
54        body: Option<serde_json::Value>,
55    },
56    /// 429 Rate Limit Exceeded
57    #[error("Honcho API error: HTTP 429 {message}")]
58    RateLimit {
59        /// Error message.
60        message: String,
61        /// Suggested wait time from Retry-After header.
62        retry_after: Option<Duration>,
63    },
64    /// Unmapped or unexpected HTTP status not covered by a dedicated variant.
65    ///
66    /// `from_response` routes every 4xx status without a dedicated variant here
67    /// (e.g. 405, 408, 413), as well as unexpected 3xx redirects and any other
68    /// status that does not match a known category (e.g. `600+` from a
69    /// misbehaving proxy). The `status` field preserves the original code.
70    #[error("Honcho API error: HTTP {status} {message}")]
71    Client {
72        /// HTTP status code.
73        status: u16,
74        /// Error message.
75        message: String,
76    },
77    /// 5xx Server Error
78    #[error("Honcho API error: HTTP {status} {message}")]
79    Server {
80        /// HTTP status code.
81        status: u16,
82        /// Error message.
83        message: String,
84    },
85    /// Request timed out.
86    #[error("Request timed out: {message}")]
87    Timeout {
88        /// Error message.
89        message: String,
90    },
91    /// Connection error.
92    #[error("Connection error: {message}")]
93    Connection {
94        /// Error message.
95        message: String,
96    },
97    /// HTTP transport error from reqwest.
98    #[error(transparent)]
99    Transport(#[from] reqwest::Error),
100    /// Failed to decode response body.
101    #[error("Failed to decode response at {path}: {source}")]
102    Decode {
103        /// JSON path where decoding failed.
104        path: String,
105        /// The underlying serde error.
106        #[source]
107        source: serde_json::Error,
108    },
109    /// Failed to serialize a value before sending it to the API.
110    #[error("Failed to serialize {path}: {source}")]
111    Serialization {
112        /// Logical name of the value being serialized (e.g. a request DTO name).
113        path: String,
114        /// The underlying serde error.
115        #[source]
116        source: serde_json::Error,
117    },
118    /// IO error.
119    #[error(transparent)]
120    Io(#[from] std::io::Error),
121    /// Configuration error.
122    #[error("Configuration error: {0}")]
123    Configuration(String),
124    /// Validation error (e.g. duplicate inputs, invalid arguments).
125    #[error("Validation error: {0}")]
126    Validation(String),
127    /// Partial failure in a chunked batch operation.
128    ///
129    /// Some chunks succeeded before an error occurred. The `messages` field
130    /// contains the successfully created messages from earlier chunks, and
131    /// `error` holds the underlying error that caused the failure.
132    #[error("Partial failure after {sent} messages: {error}")]
133    PartialFailure {
134        /// Messages that were successfully created before the failure.
135        messages: Vec<crate::Message>,
136        /// The number of messages successfully sent.
137        sent: usize,
138        /// The underlying error that caused the partial failure.
139        #[source]
140        error: Box<HonchoError>,
141    },
142}
143
144impl HonchoError {
145    /// Returns a stable error code string for pattern matching.
146    ///
147    /// Parity with Python SDK's `error.code` field.
148    #[must_use]
149    pub fn code(&self) -> &'static str {
150        match self {
151            Self::BadRequest { .. } => "bad_request",
152            Self::Authentication { .. } => "authentication_error",
153            Self::PermissionDenied { .. } => "permission_denied",
154            Self::NotFound { .. } => "not_found",
155            Self::Conflict { .. } => "conflict",
156            Self::UnprocessableEntity { .. } => "unprocessable_entity",
157            Self::RateLimit { .. } => "rate_limit_exceeded",
158            Self::Client { .. } => "client_error",
159            Self::Server { .. } => "server_error",
160            Self::Timeout { .. } => "timeout",
161            Self::Connection { .. } => "connection_error",
162            Self::Transport(_) => "transport_error",
163            Self::Decode { .. } => "decode_error",
164            Self::Serialization { .. } => "serialization_error",
165            Self::Io(_) => "io_error",
166            Self::Configuration(_) => "configuration_error",
167            Self::Validation(_) => "validation_error",
168            Self::PartialFailure { .. } => "partial_failure",
169        }
170    }
171
172    /// Returns the HTTP status code if this error originated from an HTTP response.
173    #[must_use]
174    pub fn status_code(&self) -> Option<u16> {
175        match self {
176            Self::BadRequest { .. } => Some(400),
177            Self::Authentication { .. } => Some(401),
178            Self::PermissionDenied { .. } => Some(403),
179            Self::NotFound { .. } => Some(404),
180            Self::Conflict { .. } => Some(409),
181            Self::UnprocessableEntity { .. } => Some(422),
182            Self::RateLimit { .. } => Some(429),
183            Self::Client { status, .. } | Self::Server { status, .. } => Some(*status),
184            Self::Timeout { .. }
185            | Self::Connection { .. }
186            | Self::Transport(_)
187            | Self::Decode { .. }
188            | Self::Serialization { .. }
189            | Self::Io(_)
190            | Self::Configuration(_)
191            | Self::Validation(_) => None,
192            Self::PartialFailure { error, .. } => error.status_code(),
193        }
194    }
195
196    /// Returns whether the error matches the SDK retry policy.
197    ///
198    /// `PartialFailure` is **never** retryable: the chunked batch already sent
199    /// earlier messages, so auto-retrying the whole request would duplicate
200    /// them. This is intentionally decoupled from
201    /// [`retry_after`](Self::retry_after), which still surfaces any
202    /// `Retry-After` hint from the underlying error so callers can decide how
203    /// long to wait before a manual retry.
204    #[must_use]
205    pub fn is_retryable(&self) -> bool {
206        if matches!(self, Self::PartialFailure { .. }) {
207            return false;
208        }
209        matches!(self, Self::Timeout { .. } | Self::Connection { .. })
210            || matches!(self.status_code(), Some(429 | 500 | 502 | 503 | 504))
211    }
212
213    /// Returns the suggested wait time for rate-limited requests.
214    ///
215    /// For [`PartialFailure`](Self::PartialFailure), delegates to the underlying
216    /// error so callers still learn how long to wait even though the batch is
217    /// not auto-retried (see [`is_retryable`](Self::is_retryable)).
218    #[must_use]
219    pub fn retry_after(&self) -> Option<Duration> {
220        match self {
221            Self::RateLimit { retry_after, .. } => *retry_after,
222            Self::PartialFailure { error, .. } => error.retry_after(),
223            _ => None,
224        }
225    }
226
227    /// Returns `true` if this is a partial failure with some successful messages.
228    #[must_use]
229    pub fn is_partial_failure(&self) -> bool {
230        matches!(self, Self::PartialFailure { .. })
231    }
232
233    /// Extract the partial failure data, consuming the error.
234    ///
235    /// Returns `Some((messages, error))` if this is a `PartialFailure`,
236    /// `None` otherwise.
237    #[must_use]
238    pub fn into_partial_failure(self) -> Option<(Vec<crate::Message>, Box<HonchoError>)> {
239        match self {
240            Self::PartialFailure {
241                messages, error, ..
242            } => Some((messages, error)),
243            _ => None,
244        }
245    }
246
247    /// Returns the human-readable error message.
248    ///
249    /// **Limitation (planned for a future breaking change):** for `Transport`,
250    /// `Io`, `Decode`, and `Serialization` the returned string is a fixed
251    /// placeholder rather than the underlying source error's detail. Inspect
252    /// the source via [`Error::source`](std::error::Error::source) for the full
253    /// description.
254    #[must_use]
255    // Each variant maps 1:1 to its `message` field today, so several arms look
256    // textually identical. Kept explicit per-variant for readability; the arms
257    // will diverge once the planned `message() -> Cow` change lands in a future major release.
258    #[allow(clippy::match_same_arms)]
259    pub fn message(&self) -> &str {
260        match self {
261            Self::BadRequest { message, .. } => message,
262            Self::Authentication { message } => message,
263            Self::PermissionDenied { message } => message,
264            Self::NotFound { message } => message,
265            Self::Conflict { message, .. } => message,
266            Self::UnprocessableEntity { message, .. } => message,
267            Self::RateLimit { message, .. } => message,
268            Self::Client { message, .. } => message,
269            Self::Server { message, .. } => message,
270            Self::Timeout { message } => message,
271            Self::Connection { message } => message,
272            Self::Transport(_) => "transport error",
273            Self::Io(_) => "I/O error",
274            Self::Decode { .. } => "failed to decode response",
275            Self::Serialization { .. } => "failed to serialize request",
276            Self::Configuration(s) => s,
277            Self::Validation(s) => s,
278            Self::PartialFailure { error, .. } => error.message(),
279        }
280    }
281}
282
283/// Alias for `Result<T, HonchoError>`.
284pub type Result<T> = std::result::Result<T, HonchoError>;
285
286/// Parse an error response body, extracting message and body.
287///
288/// Tries to extract `detail`, `message`, or `error` fields in order (`FastAPI` convention).
289#[must_use]
290pub fn parse_error_body(body: &[u8]) -> (String, Option<serde_json::Value>) {
291    let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) else {
292        let msg = String::from_utf8_lossy(body).into_owned();
293        return (msg, None);
294    };
295
296    if let Some(obj) = value.as_object() {
297        if let Some(readable) = obj.get("detail").and_then(detail_message) {
298            return (readable, Some(value));
299        }
300        if let Some(message) = obj.get("message").and_then(|v| v.as_str()) {
301            return (message.to_string(), Some(value));
302        }
303        if let Some(error) = obj.get("error").and_then(|v| v.as_str()) {
304            return (error.to_string(), Some(value));
305        }
306        return (value.to_string(), Some(value));
307    }
308
309    if let Some(s) = value.as_str() {
310        return (s.to_string(), Some(value));
311    }
312
313    (value.to_string(), Some(value))
314}
315
316/// Build a human-readable message from a `detail` JSON value.
317///
318/// `FastAPI` returns validation errors as an array of objects, each typically
319/// shaped like `{"loc": [...], "msg": "...", "type": "..."}`. Without this
320/// helper the whole array would be stringified into the error message. We
321/// instead join the `"msg"` fields (or bare strings) with `"; "`.
322///
323/// Returns `None` when no readable text can be extracted, so the caller can
324/// fall back to other fields or the raw JSON.
325fn detail_message(detail: &serde_json::Value) -> Option<String> {
326    match detail {
327        serde_json::Value::String(s) => Some(s.clone()),
328        serde_json::Value::Array(arr) => {
329            let parts: Vec<String> = arr.iter().filter_map(item_message).collect();
330            if parts.is_empty() {
331                None
332            } else {
333                Some(parts.join("; "))
334            }
335        }
336        _ => None,
337    }
338}
339
340/// Extract a single readable message from one element of a `FastAPI` `detail` array.
341fn item_message(item: &serde_json::Value) -> Option<String> {
342    match item {
343        serde_json::Value::String(s) => Some(s.clone()),
344        serde_json::Value::Object(obj) => {
345            obj.get("msg").and_then(|m| m.as_str()).map(str::to_owned)
346        }
347        _ => None,
348    }
349}
350
351/// Parse a Retry-After header value.
352///
353/// Accepts either seconds (parsed as `f64`) or HTTP-date format
354/// ([RFC 9110](https://datatracker.ietf.org/doc/html/rfc9110) §10.2.3
355/// "Retry-After").
356///
357/// The accepted format is **looser** than RFC 9110: the standard only permits
358/// non-negative integer seconds (e.g. `"120"`) or an HTTP-date, but this
359/// parser uses `str::parse::<f64>()`, which additionally accepts values like
360/// `"+5"`, `"1e3"`, and `"inf"`. Non-finite values (`NaN`, `±inf`) and
361/// magnitudes beyond `Duration::MAX` are rejected and yield `None` rather than
362/// panicking — important because the header is attacker/proxy controlled.
363///
364/// Returns `None` if the value cannot be parsed. Negative seconds are clamped
365/// to zero (parity with Python's `max(0.0, ...)`).
366#[must_use]
367pub fn parse_retry_after(value: &HeaderValue, now: DateTime<Utc>) -> Option<Duration> {
368    let s = value.to_str().ok()?;
369
370    if let Ok(secs) = s.parse::<f64>() {
371        // Reject non-finite values explicitly: `f64::max` ignores NaN and would
372        // otherwise turn `NaN`/`-inf` into `0.0` (returning the non-NaN operand),
373        // contradicting the documented "non-finite -> None" contract.
374        if !secs.is_finite() {
375            return None;
376        }
377        return Duration::try_from_secs_f64(secs.max(0.0)).ok();
378    }
379
380    let target = parse_http_date(s).ok()?;
381    let now_systime: SystemTime = now.into();
382    match target.duration_since(now_systime) {
383        Ok(diff) => Some(diff),
384        Err(_) => Some(Duration::ZERO),
385    }
386}
387
388/// Construct a `HonchoError` from an HTTP response.
389#[must_use]
390pub fn from_response(
391    status: StatusCode,
392    headers: &HeaderMap,
393    body: &bytes::Bytes,
394    now: DateTime<Utc>,
395) -> HonchoError {
396    let (message, body_value) = parse_error_body(body);
397
398    match status.as_u16() {
399        400 => HonchoError::BadRequest {
400            message,
401            body: body_value,
402        },
403        401 => HonchoError::Authentication { message },
404        403 => HonchoError::PermissionDenied { message },
405        404 => HonchoError::NotFound { message },
406        409 => HonchoError::Conflict {
407            message,
408            body: body_value,
409        },
410        422 => HonchoError::UnprocessableEntity {
411            message,
412            body: body_value,
413        },
414        429 => {
415            let retry_after = headers
416                .get(reqwest::header::RETRY_AFTER)
417                .and_then(|v| parse_retry_after(v, now));
418            HonchoError::RateLimit {
419                message,
420                retry_after,
421            }
422        }
423        s if s >= 500 => HonchoError::Server { status: s, message },
424        s if (400..500).contains(&s) => HonchoError::Client { status: s, message },
425        s if (300..400).contains(&s) => HonchoError::Client {
426            status: s,
427            message: format!("unexpected redirect status {s}"),
428        },
429        _ => HonchoError::Client {
430            status: status.as_u16(),
431            message: format!("unexpected response status {}", status.as_u16()),
432        },
433    }
434}