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