Skip to main content

typesafe/
error.rs

1//! Error types.
2//!
3//! The hierarchy mirrors the official Python SDK: configuration and request-validation errors are
4//! raised before anything is sent; [`ApiError`] covers unsuccessful HTTP responses;
5//! [`Error::Connection`] and [`Error::Timeout`] cover requests that never produced a response; and
6//! [`ResponseValidationError`] covers a 2xx response whose body does not match the schema.
7
8use std::fmt;
9use std::time::{Duration, SystemTime};
10
11use http::header::HeaderMap;
12use serde_json::Value;
13
14use crate::constants::{
15    MAX_ERROR_BODY_LENGTH, REQUEST_ID_HEADER, RETRY_AFTER_HEADER, RETRY_AFTER_MS_HEADER,
16};
17
18/// Convenience alias for results returned by this crate.
19pub type Result<T, E = Error> = std::result::Result<T, E>;
20
21/// Any failure produced by the SDK.
22#[derive(Debug, thiserror::Error)]
23#[non_exhaustive]
24pub enum Error {
25    /// The client could not be configured (missing API key, invalid base URL, invalid timeout,
26    /// invalid retry policy).
27    #[error("{0}")]
28    Config(String),
29
30    /// The request was rejected locally before being sent (no questions, empty choice or score
31    /// criteria, malformed raw question, or a body that cannot be encoded as JSON).
32    #[error("{0}")]
33    InvalidRequest(String),
34
35    /// The server returned an unsuccessful HTTP status after any retries.
36    #[error(transparent)]
37    Api(Box<ApiError>),
38
39    /// The request could not reach the server or the response could not be read (DNS, connect,
40    /// TLS, reset, body read). The underlying HTTP client's error is available via `source()`.
41    #[error("Connection error: {0}")]
42    Connection(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
43
44    /// The request exceeded its configured timeout.
45    #[error("Request timed out (timeout={}s).", .0.as_secs_f64())]
46    Timeout(Duration),
47
48    /// The server returned a successful status but the body is missing required data.
49    #[error(transparent)]
50    ResponseValidation(Box<ResponseValidationError>),
51}
52
53impl Error {
54    /// The API error, if this is one.
55    pub fn as_api(&self) -> Option<&ApiError> {
56        match self {
57            Error::Api(e) => Some(e),
58            _ => None,
59        }
60    }
61
62    /// The HTTP status associated with this error, if any.
63    pub fn status(&self) -> Option<u16> {
64        match self {
65            Error::Api(e) => Some(e.status),
66            Error::ResponseValidation(e) => Some(e.status),
67            _ => None,
68        }
69    }
70
71    /// The `x-typesafe-request-id` of the failing response, if any.
72    pub fn request_id(&self) -> Option<&str> {
73        match self {
74            Error::Api(e) => e.request_id(),
75            Error::ResponseValidation(e) => header_str(&e.headers, REQUEST_ID_HEADER),
76            _ => None,
77        }
78    }
79}
80
81/// Classification of an unsuccessful HTTP status.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
83#[non_exhaustive]
84pub enum ApiErrorKind {
85    /// 400
86    BadRequest,
87    /// 401
88    Authentication,
89    /// 403
90    PermissionDenied,
91    /// 404
92    NotFound,
93    /// 422 — the request body failed server-side validation.
94    UnprocessableEntity,
95    /// 429
96    RateLimit,
97    /// Any 5xx, including TypeSafe's `529 Overloaded`.
98    InternalServer,
99    /// Any other non-success status.
100    Other,
101}
102
103impl ApiErrorKind {
104    /// Map a status code to its kind.
105    pub fn from_status(status: u16) -> Self {
106        match status {
107            400 => Self::BadRequest,
108            401 => Self::Authentication,
109            403 => Self::PermissionDenied,
110            404 => Self::NotFound,
111            422 => Self::UnprocessableEntity,
112            429 => Self::RateLimit,
113            s if s >= 500 => Self::InternalServer,
114            _ => Self::Other,
115        }
116    }
117}
118
119/// An unsuccessful HTTP response.
120#[derive(Debug, Clone)]
121#[non_exhaustive]
122pub struct ApiError {
123    /// HTTP status code.
124    pub status: u16,
125    /// Classification of `status`.
126    pub kind: ApiErrorKind,
127    /// Human-readable message extracted from the body.
128    pub message: String,
129    /// The JSON error body, the raw text as [`Value::String`] when it is not JSON, or `None` when empty.
130    pub body: Option<Value>,
131    /// Response headers.
132    pub headers: HeaderMap,
133    /// `"METHOD url"` without credentials, query or fragment.
134    pub endpoint: Option<String>,
135}
136
137impl ApiError {
138    pub(crate) fn new(
139        status: u16,
140        body: Option<Value>,
141        headers: HeaderMap,
142        endpoint: Option<String>,
143    ) -> Self {
144        let message = match body.as_ref().and_then(extract_message) {
145            Some(m) => m,
146            None => match &body {
147                None => "status code (no body)".to_owned(),
148                Some(Value::String(s)) => truncate(s),
149                Some(v) => truncate(&v.to_string()),
150            },
151        };
152        Self {
153            status,
154            kind: ApiErrorKind::from_status(status),
155            message,
156            body,
157            headers,
158            endpoint,
159        }
160    }
161
162    /// The `x-typesafe-request-id` response header.
163    pub fn request_id(&self) -> Option<&str> {
164        header_str(&self.headers, REQUEST_ID_HEADER)
165    }
166
167    /// The wait the server asked for via `retry-after-ms` or `Retry-After`.
168    pub fn retry_after(&self) -> Option<Duration> {
169        parse_retry_after(&self.headers)
170    }
171}
172
173impl fmt::Display for ApiError {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        if let Some(endpoint) = &self.endpoint {
176            write!(f, "{endpoint}: ")?;
177        }
178        write!(f, "{} {}", self.status, self.message)?;
179        if let Some(id) = self.request_id() {
180            write!(f, " (request_id={id})")?;
181        }
182        Ok(())
183    }
184}
185
186impl std::error::Error for ApiError {}
187
188/// A successful response whose body is missing or has structurally invalid required data.
189#[derive(Debug, Clone)]
190#[non_exhaustive]
191pub struct ResponseValidationError {
192    /// HTTP status code (2xx).
193    pub status: u16,
194    /// Dotted path to the offending field, e.g. `answers.tone.confidence`.
195    pub field_path: String,
196    /// Underlying decoder message.
197    pub detail: String,
198    /// The decoded body (or raw text), if any.
199    pub body: Option<Value>,
200    /// Response headers.
201    pub headers: HeaderMap,
202    /// `"METHOD url"` without credentials, query or fragment.
203    pub endpoint: Option<String>,
204}
205
206impl fmt::Display for ResponseValidationError {
207    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208        if let Some(endpoint) = &self.endpoint {
209            write!(f, "{endpoint}: ")?;
210        }
211        write!(
212            f,
213            "{} Invalid response data at '{}': {}",
214            self.status, self.field_path, self.detail
215        )?;
216        if let Some(id) = header_str(&self.headers, REQUEST_ID_HEADER) {
217            write!(f, " (request_id={id})")?;
218        }
219        Ok(())
220    }
221}
222
223impl std::error::Error for ResponseValidationError {}
224
225fn header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
226    headers.get(name).and_then(|v| v.to_str().ok())
227}
228
229fn truncate(raw: &str) -> String {
230    if raw.chars().count() > MAX_ERROR_BODY_LENGTH {
231        let mut s: String = raw.chars().take(MAX_ERROR_BODY_LENGTH).collect();
232        s.push('…');
233        s
234    } else {
235        raw.to_owned()
236    }
237}
238
239/// Decode an error body leniently: empty → `None`, JSON → value, anything else → string.
240pub(crate) fn lenient_body(bytes: &[u8]) -> Option<Value> {
241    if bytes.is_empty() {
242        return None;
243    }
244    Some(
245        serde_json::from_slice(bytes)
246            .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(bytes).into_owned())),
247    )
248}
249
250/// Pull a human-readable message out of the error shapes the API (FastAPI) may return.
251pub(crate) fn extract_message(body: &Value) -> Option<String> {
252    let non_empty = |s: &str| (!s.is_empty()).then(|| s.to_owned());
253    let obj = match body {
254        Value::String(s) => return non_empty(s),
255        Value::Object(o) => o,
256        _ => return None,
257    };
258    let str_at = |v: Option<&Value>, key: &str| {
259        v.and_then(|v| v.get(key))
260            .and_then(Value::as_str)
261            .map(str::to_owned)
262    };
263    match obj.get("error") {
264        Some(Value::String(s)) => return Some(s.clone()),
265        e @ Some(Value::Object(_)) => {
266            if let Some(m) = str_at(e, "message") {
267                return Some(m);
268            }
269        }
270        _ => {}
271    }
272    if let Some(Value::String(m)) = obj.get("message") {
273        return Some(m.clone());
274    }
275    match obj.get("detail") {
276        Some(Value::String(s)) => Some(s.clone()),
277        d @ Some(Value::Object(_)) => str_at(d, "message"),
278        Some(Value::Array(entries)) => {
279            let parts: Vec<String> = entries
280                .iter()
281                .filter_map(|entry| {
282                    let msg = entry.get("msg")?.as_str()?;
283                    let path = entry
284                        .get("loc")
285                        .and_then(Value::as_array)
286                        .map(|loc| {
287                            loc.iter()
288                                .filter(|item| item.as_str() != Some("body"))
289                                .map(|item| match item {
290                                    Value::String(s) => s.clone(),
291                                    other => other.to_string(),
292                                })
293                                .collect::<Vec<_>>()
294                                .join(".")
295                        })
296                        .unwrap_or_default();
297                    Some(if path.is_empty() {
298                        msg.to_owned()
299                    } else {
300                        format!("{path}: {msg}")
301                    })
302                })
303                .collect();
304            (!parts.is_empty()).then(|| parts.join("; "))
305        }
306        _ => None,
307    }
308}
309
310/// Parse `retry-after-ms` (milliseconds) then `Retry-After` (seconds or HTTP date).
311pub(crate) fn parse_retry_after(headers: &HeaderMap) -> Option<Duration> {
312    if let Some(raw) = header_str(headers, RETRY_AFTER_MS_HEADER) {
313        let raw = raw.trim();
314        if let Ok(ms) = if raw.is_empty() {
315            Ok(0.0)
316        } else {
317            raw.parse::<f64>()
318        } && ms.is_finite()
319            && ms >= 0.0
320        {
321            return Some(Duration::from_secs_f64(ms / 1000.0));
322        }
323    }
324    let raw = header_str(headers, RETRY_AFTER_HEADER)?;
325    let trimmed = raw.trim();
326    match if trimmed.is_empty() {
327        Ok(0.0)
328    } else {
329        trimmed.parse::<f64>()
330    } {
331        Ok(secs) if secs.is_finite() && secs >= 0.0 => Duration::try_from_secs_f64(secs).ok(),
332        Ok(_) => None,
333        Err(_) => {
334            let at = httpdate::parse_http_date(trimmed).ok()?;
335            Some(
336                at.duration_since(SystemTime::now())
337                    .unwrap_or(Duration::ZERO),
338            )
339        }
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use http::header::HeaderValue;
347    use serde_json::json;
348
349    #[test]
350    fn extracts_fastapi_validation_details() {
351        let body = json!({"detail": [
352            {"loc": ["body", "questions", "x", "criteria"], "msg": "Field required", "type": "missing"},
353            {"loc": ["body", "model"], "msg": "Bad model", "type": "value_error"}
354        ]});
355        assert_eq!(
356            extract_message(&body).unwrap(),
357            "questions.x.criteria: Field required; model: Bad model"
358        );
359    }
360
361    #[test]
362    fn extracts_message_precedence() {
363        assert_eq!(
364            extract_message(&json!({"error": "e", "message": "m"})).unwrap(),
365            "e"
366        );
367        assert_eq!(
368            extract_message(&json!({"error": {"message": "em"}})).unwrap(),
369            "em"
370        );
371        assert_eq!(
372            extract_message(&json!({"message": "m", "detail": "d"})).unwrap(),
373            "m"
374        );
375        assert_eq!(
376            extract_message(&json!({"detail": {"message": "dm"}})).unwrap(),
377            "dm"
378        );
379        assert_eq!(extract_message(&json!({"other": 1})), None);
380        assert_eq!(extract_message(&json!("")), None);
381    }
382
383    #[test]
384    fn long_bodies_are_truncated() {
385        let err = ApiError::new(
386            500,
387            Some(json!({"x": "y".repeat(500)})),
388            HeaderMap::new(),
389            None,
390        );
391        assert_eq!(err.message.chars().count(), MAX_ERROR_BODY_LENGTH + 1);
392        assert!(err.message.ends_with('…'));
393    }
394
395    #[test]
396    fn empty_body_message() {
397        let err = ApiError::new(
398            503,
399            None,
400            HeaderMap::new(),
401            Some("GET http://x/v1/models".into()),
402        );
403        assert_eq!(
404            err.to_string(),
405            "GET http://x/v1/models: 503 status code (no body)"
406        );
407        assert_eq!(err.kind, ApiErrorKind::InternalServer);
408    }
409
410    #[test]
411    fn retry_after_variants() {
412        let mut h = HeaderMap::new();
413        h.insert(RETRY_AFTER_MS_HEADER, HeaderValue::from_static("250"));
414        h.insert(RETRY_AFTER_HEADER, HeaderValue::from_static("9"));
415        assert_eq!(parse_retry_after(&h), Some(Duration::from_millis(250)));
416
417        let mut h = HeaderMap::new();
418        h.insert(RETRY_AFTER_HEADER, HeaderValue::from_static("2"));
419        assert_eq!(parse_retry_after(&h), Some(Duration::from_secs(2)));
420
421        let mut h = HeaderMap::new();
422        h.insert(RETRY_AFTER_HEADER, HeaderValue::from_static("-1"));
423        assert_eq!(parse_retry_after(&h), None);
424
425        let mut h = HeaderMap::new();
426        h.insert(
427            RETRY_AFTER_HEADER,
428            HeaderValue::from_static("  Wed, 21 Oct 2015 07:28:00 GMT "),
429        );
430        assert_eq!(parse_retry_after(&h), Some(Duration::ZERO));
431    }
432}