Skip to main content

typesafe_system_one/
errors.rs

1//! Errors, API error kinds, message extraction, and error formatting.
2//!
3//! The error string for a non-2xx response is exactly
4//! `"<METHOD> <URL>: <status> <message> (request_id=<id>)"`, where the
5//! request-ID suffix appears only when the `x-typesafe-request-id` header is
6//! present. The API key never appears in any error.
7
8use std::fmt;
9use std::time::Duration;
10
11use serde_json::Value;
12
13use crate::REQUEST_ID_HEADER;
14
15/// The maximum length of a raw body embedded in an error message before truncation.
16const MAX_RAW_BODY_IN_MESSAGE: usize = 200;
17
18/// A request failed because the HTTP response carried an error status.
19///
20/// The `Display` format is `"<METHOD> <URL>: <status> <message> (request_id=<id>)"`,
21/// with the request-ID suffix only when present.
22#[derive(Debug, Clone)]
23pub struct ApiError {
24    /// HTTP response status code.
25    pub status: u16,
26    /// The status-derived error kind.
27    pub kind: ApiErrorKind,
28    /// The extracted or synthesized message (never the raw body over 200 chars).
29    pub message: String,
30    /// The server's JSON error body when parseable, the raw text otherwise,
31    /// and `None` when the body was empty.
32    pub body: Option<Value>,
33    /// Response headers (the API key is never present in responses).
34    pub headers: reqwest::header::HeaderMap,
35    /// `x-typesafe-request-id` response header value, when present.
36    pub request_id: Option<String>,
37    /// `"<METHOD> <URL>"` of the request, without query parameters.
38    pub endpoint: String,
39    /// Parsed `retry-after-ms` / `Retry-After` delay, when present.
40    pub retry_after: Option<Duration>,
41}
42
43/// The kind of API error, derived from the HTTP status code.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45#[non_exhaustive]
46pub enum ApiErrorKind {
47    /// 400: the request was invalid.
48    BadRequest,
49    /// 401: authentication failed.
50    Authentication,
51    /// 403: access was denied.
52    PermissionDenied,
53    /// 404: the resource was not found.
54    NotFound,
55    /// 422: the request failed server validation.
56    UnprocessableEntity,
57    /// 429: the rate limit was exceeded.
58    RateLimit,
59    /// 5xx: the server failed to process the request.
60    InternalServer,
61    /// Any other status.
62    Other,
63}
64
65impl ApiErrorKind {
66    /// Maps an HTTP status code to its error kind.
67    pub fn from_status(status: u16) -> Self {
68        match status {
69            400 => Self::BadRequest,
70            401 => Self::Authentication,
71            403 => Self::PermissionDenied,
72            404 => Self::NotFound,
73            422 => Self::UnprocessableEntity,
74            429 => Self::RateLimit,
75            s if (500..=599).contains(&s) => Self::InternalServer,
76            _ => Self::Other,
77        }
78    }
79}
80
81impl fmt::Display for ApiErrorKind {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        let name = match self {
84            Self::BadRequest => "bad request",
85            Self::Authentication => "authentication",
86            Self::PermissionDenied => "permission denied",
87            Self::NotFound => "not found",
88            Self::UnprocessableEntity => "unprocessable entity",
89            Self::RateLimit => "rate limit",
90            Self::InternalServer => "internal server",
91            Self::Other => "other",
92        };
93        f.write_str(name)
94    }
95}
96
97impl fmt::Display for ApiError {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        write!(f, "{}: {} {}", self.endpoint, self.status, self.message)?;
100        if let Some(request_id) = &self.request_id {
101            write!(f, " (request_id={request_id})")?;
102        }
103        Ok(())
104    }
105}
106
107impl std::error::Error for ApiError {}
108
109/// Extracts a message from a text, error, or validation response body.
110///
111/// First match wins: non-empty string body (truncated to 200 Unicode scalar
112/// values plus `…`); `error` (string); `error.message`; `message`; `detail`
113/// (string); `detail.message`; `detail[]` as FastAPI validation errors
114/// rendered as `"<loc without 'body' joined by '.'>: <msg>"` joined by `"; "`.
115/// Returns `None` otherwise.
116///
117/// The plain-string body is truncated deliberately, unlike the official
118/// SDKs: gateway error pages can be huge.
119pub(super) fn extract_message(body: &Value) -> Option<String> {
120    match body {
121        Value::String(s) => {
122            if s.is_empty() {
123                return None;
124            }
125            Some(truncate_chars(s))
126        }
127        Value::Object(map) => {
128            let error = map.get("error");
129            if let Some(Value::String(error)) = error {
130                return Some(error.clone());
131            }
132            if let Some(Value::Object(error)) = error {
133                if let Some(Value::String(message)) = error.get("message") {
134                    return Some(message.clone());
135                }
136            }
137            if let Some(Value::String(message)) = map.get("message") {
138                return Some(message.clone());
139            }
140            let detail = map.get("detail");
141            if let Some(Value::String(detail)) = detail {
142                return Some(detail.clone());
143            }
144            if let Some(Value::Object(detail)) = detail {
145                if let Some(Value::String(message)) = detail.get("message") {
146                    return Some(message.clone());
147                }
148            }
149            if let Some(Value::Array(entries)) = detail {
150                let mut parts = Vec::new();
151                for entry in entries {
152                    let Value::Object(entry) = entry else {
153                        continue;
154                    };
155                    let Some(Value::String(msg)) = entry.get("msg") else {
156                        continue;
157                    };
158                    let path = match entry.get("loc") {
159                        Some(Value::Array(location)) => location
160                            .iter()
161                            .filter(|item| **item != Value::String("body".into()))
162                            .map(|item| match item {
163                                Value::String(s) => s.clone(),
164                                other => other.to_string(),
165                            })
166                            .collect::<Vec<_>>()
167                            .join("."),
168                        _ => String::new(),
169                    };
170                    parts.push(if path.is_empty() {
171                        msg.clone()
172                    } else {
173                        format!("{path}: {msg}")
174                    });
175                }
176                if !parts.is_empty() {
177                    return Some(parts.join("; "));
178                }
179            }
180            None
181        }
182        _ => None,
183    }
184}
185
186/// Truncates a string to 200 Unicode scalar values, appending `…` when
187/// truncation happened.
188fn truncate_chars(raw: &str) -> String {
189    if raw.chars().count() > MAX_RAW_BODY_IN_MESSAGE {
190        let mut truncated: String = raw.chars().take(MAX_RAW_BODY_IN_MESSAGE).collect();
191        truncated.push('…');
192        truncated
193    } else {
194        raw.to_owned()
195    }
196}
197
198/// Renders a body for embedding in an error message: the raw text or
199/// compact JSON, truncated to 200 characters plus `…`.
200pub(super) fn truncated_body(body: &Value) -> String {
201    let raw = match body {
202        Value::String(s) => s.clone(),
203        other => other.to_string(),
204    };
205    truncate_chars(&raw)
206}
207
208/// Constructs the message for an API error from its parsed body.
209///
210/// When extraction finds nothing, the raw body (truncated) is used; an empty
211/// body yields `"status code (no body)"`.
212pub(super) fn api_error_message(body: &Option<Value>) -> String {
213    if let Some(body) = body {
214        if let Some(message) = extract_message(body) {
215            return message;
216        }
217        if matches!(body, Value::String(s) if s.is_empty()) || (body.is_null() && false) {
218            return "status code (no body)".into();
219        }
220        return truncated_body(body);
221    }
222    "status code (no body)".into()
223}
224
225/// Every error this crate can return.
226///
227/// The API key never appears in any error. The top-level error is
228/// `#[non_exhaustive]`: match on the variants you handle and keep a wildcard
229/// arm for forward compatibility.
230#[derive(Debug)]
231#[non_exhaustive]
232pub enum Error {
233    /// The client could not be constructed: a missing, empty, or invalid API
234    /// key, an invalid timeout, or invalid retry policy settings.
235    Config(String),
236    /// The request failed client-side validation before any network I/O.
237    InvalidRequest(String),
238    /// The server returned a non-2xx response.
239    Api(Box<ApiError>),
240    /// The error variant: no HTTP response arrived.
241    Connection {
242        /// Description of the transport failure.
243        message: String,
244        /// The underlying transport error, when available.
245        source: Option<Box<dyn std::error::Error + Send + Sync>>,
246    },
247    /// The attempt exceeded the per-attempt timeout. A kind of connection error.
248    Timeout {
249        /// The per-attempt timeout that expired.
250        timeout: Duration,
251    },
252    /// A 2xx response body did not match the expected schema.
253    ResponseValidation {
254        /// HTTP status of the offending response.
255        status: u16,
256        /// Dotted path to the first missing or invalid field, e.g.
257        /// `answers.tone.confidence`; `""` for a non-object body.
258        field_path: String,
259        /// `x-typesafe-request-id` of the offending response, when present.
260        request_id: Option<String>,
261        /// `"<METHOD> <URL>"` of the request.
262        endpoint: String,
263    },
264}
265
266impl fmt::Display for Error {
267    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268        match self {
269            Self::Config(message) => write!(f, "configuration error: {message}"),
270            Self::InvalidRequest(message) => write!(f, "invalid request: {message}"),
271            Self::Api(error) => write!(f, "{error}"),
272            Self::Connection { message, .. } => write!(f, "connection error: {message}"),
273            Self::Timeout { timeout } => {
274                write!(f, "request timed out (timeout={:?})", timeout)
275            }
276            Self::ResponseValidation { field_path, .. } => {
277                write!(f, "invalid response data at '{field_path}'")
278            }
279        }
280    }
281}
282
283impl std::error::Error for Error {
284    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
285        match self {
286            Self::Api(error) => Some(error.as_ref()),
287            Self::Connection {
288                source: Some(source),
289                ..
290            } => Some(source.as_ref()),
291            _ => None,
292        }
293    }
294}
295
296impl From<ApiError> for Error {
297    fn from(error: ApiError) -> Self {
298        Self::Api(Box::new(error))
299    }
300}
301
302impl Error {
303    /// Returns `true` when this error is a timeout.
304    pub fn is_timeout(&self) -> bool {
305        matches!(self, Self::Timeout { .. })
306    }
307
308    /// Returns `true` when this error is a connection error. Timeouts count
309    /// as connection errors.
310    pub fn is_connection(&self) -> bool {
311        matches!(self, Self::Connection { .. } | Self::Timeout { .. })
312    }
313
314    /// Returns the HTTP status code when this error came from a response.
315    pub fn status(&self) -> Option<u16> {
316        match self {
317            Self::Api(error) => Some(error.status),
318            Self::ResponseValidation { status, .. } => Some(*status),
319            _ => None,
320        }
321    }
322
323    /// Returns the `x-typesafe-request-id` when this error came from a response.
324    pub fn request_id(&self) -> Option<&str> {
325        match self {
326            Self::Api(error) => error.request_id.as_deref(),
327            Self::ResponseValidation { request_id, .. } => request_id.as_deref(),
328            _ => None,
329        }
330    }
331
332    /// Returns the underlying [`ApiError`] when this is a non-2xx response error.
333    pub fn as_api(&self) -> Option<&ApiError> {
334        match self {
335            Self::Api(error) => Some(error),
336            _ => None,
337        }
338    }
339}
340
341/// Reads `x-typesafe-request-id` from a response header map.
342pub(super) fn request_id_of(headers: &reqwest::header::HeaderMap) -> Option<String> {
343    headers
344        .get(REQUEST_ID_HEADER)
345        .and_then(|value| value.to_str().ok())
346        .map(str::to_owned)
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use serde_json::json;
353
354    fn api_error(status: u16, body: Value, request_id: Option<&str>) -> ApiError {
355        let mut headers = reqwest::header::HeaderMap::new();
356        if let Some(request_id) = request_id {
357            headers.insert(
358                REQUEST_ID_HEADER,
359                reqwest::header::HeaderValue::from_str(request_id).unwrap(),
360            );
361        }
362        let body = if body.is_null() { None } else { Some(body) };
363        ApiError {
364            status,
365            kind: ApiErrorKind::from_status(status),
366            message: api_error_message(&body),
367            body,
368            headers,
369            request_id: request_id.map(str::to_owned),
370            endpoint: "POST https://api.typesafe.ai/v1/systemone".into(),
371            retry_after: None,
372        }
373    }
374
375    #[test]
376    fn kind_per_status() {
377        for (status, kind) in [
378            (400, ApiErrorKind::BadRequest),
379            (401, ApiErrorKind::Authentication),
380            (403, ApiErrorKind::PermissionDenied),
381            (404, ApiErrorKind::NotFound),
382            (422, ApiErrorKind::UnprocessableEntity),
383            (429, ApiErrorKind::RateLimit),
384            (500, ApiErrorKind::InternalServer),
385            (503, ApiErrorKind::InternalServer),
386            (599, ApiErrorKind::InternalServer),
387            (418, ApiErrorKind::Other),
388        ] {
389            assert_eq!(ApiErrorKind::from_status(status), kind, "{status}");
390        }
391    }
392
393    #[test]
394    fn message_extraction_first_match_wins() {
395        let cases = [
396            (json!("plain string"), "plain string"),
397            (json!({"error": "an error"}), "an error"),
398            (
399                json!({"error": {"message": "nested"}, "message": "flat"}),
400                "nested",
401            ),
402            (json!({"message": "flat"}), "flat"),
403            (json!({"detail": "details"}), "details"),
404            (
405                json!({"detail": {"message": "detail message"}}),
406                "detail message",
407            ),
408            (
409                json!({"detail": [
410                    {"loc": ["body", "questions", "urgency", "score", "criteria"], "msg": "Field required"},
411                    {"loc": ["body", "state"], "msg": "Bad state"}
412                ]}),
413                "questions.urgency.score.criteria: Field required; state: Bad state",
414            ),
415        ];
416        for (body, expected) in cases {
417            assert_eq!(extract_message(&body).as_deref(), Some(expected), "{body}");
418        }
419    }
420
421    #[test]
422    fn plain_string_body_is_truncated_to_200_chars() {
423        // A JSON string body is truncated to 200 Unicode scalar values + "…",
424        // even when its byte length differs wildly (multi-byte characters).
425        let long = "é".repeat(250);
426        let message = extract_message(&Value::String(long.clone())).unwrap();
427        assert_eq!(message.chars().count(), 201);
428        assert!(message.ends_with('…'));
429        assert_eq!(message.chars().filter(|c| *c == 'é').count(), 200);
430        assert_eq!(message.len(), 200 * 2 + 3); // é is two bytes in UTF-8.
431
432        // Exactly 200 chars is not truncated.
433        let exact = "é".repeat(200);
434        let message = extract_message(&Value::String(exact.clone())).unwrap();
435        assert_eq!(message, exact);
436
437        // Non-JSON raw text parses to a JSON string at the transport layer
438        // and truncates the same way.
439        let raw_html = format!("<html>{}</html>", "z".repeat(300));
440        let message = extract_message(&Value::String(raw_html)).unwrap();
441        assert_eq!(message.chars().count(), 201);
442        assert!(message.ends_with('…'));
443    }
444
445    #[test]
446    fn fastapi_detail_renders_loc_without_body() {
447        let error = api_error(
448            422,
449            json!({"detail": [
450                {"loc": ["body", "questions", "urgency", "score", "criteria"], "msg": "Field required", "type": "missing"}
451            ]}),
452            None,
453        );
454        assert_eq!(
455            error.message,
456            "questions.urgency.score.criteria: Field required"
457        );
458    }
459
460    #[test]
461    fn message_falls_back_to_raw_body_truncated() {
462        let long = "x".repeat(300);
463        let error = api_error(500, json!({"unexpected": long.clone()}), None);
464        assert_eq!(error.message.chars().count(), 201);
465        assert!(error.message.ends_with('…'));
466        assert!(error.message.starts_with("{\"unexpected\":\"xxx"));
467        // A body whose compact JSON is exactly 200 chars is not truncated.
468        // {"unexpected":"..."} with 183 inner chars = 200 total.
469        let exact = "y".repeat(183);
470        let error = api_error(500, json!({"unexpected": exact}), None);
471        assert_eq!(
472            error.message,
473            "{\"unexpected\":\"".to_owned() + &"y".repeat(183) + "\"}"
474        );
475        // Raw non-JSON text is the string body itself, truncated the same way.
476        let raw = "z".repeat(250);
477        let error = api_error(500, Value::String(raw.clone()), None);
478        assert_eq!(error.message, "z".repeat(200) + "…");
479    }
480
481    #[test]
482    fn empty_body_message() {
483        // Empty body: Value::String("") parses to None at the transport layer;
484        // simulate that with None.
485        let error = api_error(404, Value::Null, None);
486        assert_eq!(error.message, "status code (no body)");
487        // Non-empty string body uses the string.
488        let error = api_error(404, json!("whoops"), None);
489        assert_eq!(error.message, "whoops");
490    }
491
492    #[test]
493    fn display_format() {
494        let error = api_error(429, json!({"error": "too fast"}), Some("req-123"));
495        assert_eq!(
496            error.to_string(),
497            "POST https://api.typesafe.ai/v1/systemone: 429 too fast (request_id=req-123)"
498        );
499        let error = api_error(500, json!("boom"), None);
500        assert_eq!(
501            error.to_string(),
502            "POST https://api.typesafe.ai/v1/systemone: 500 boom"
503        );
504    }
505
506    #[test]
507    fn error_helpers() {
508        let error = Error::from(api_error(429, json!("rl"), Some("req-1")));
509        assert_eq!(error.status(), Some(429));
510        assert_eq!(error.request_id(), Some("req-1"));
511        assert!(error.as_api().is_some());
512        assert!(!error.is_connection());
513        assert!(!error.is_timeout());
514
515        let error = Error::Timeout {
516            timeout: Duration::from_secs(2),
517        };
518        assert!(error.is_timeout());
519        assert!(error.is_connection());
520        assert_eq!(error.status(), None);
521
522        let error = Error::Connection {
523            message: "reset".into(),
524            source: None,
525        };
526        assert!(error.is_connection());
527        assert!(!error.is_timeout());
528    }
529}