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