Skip to main content

typesafe_ai_rs/
error.rs

1//! Errors and response metadata returned by the SDK.
2
3use std::{fmt, time::Duration};
4
5use reqwest::{header::HeaderMap, StatusCode, Url};
6use serde_json::Value;
7
8/// An error returned by the TypeSafe SDK.
9pub enum Error {
10    /// Invalid client configuration.
11    Configuration(String),
12    /// A request could not be constructed from the supplied arguments.
13    InvalidRequest(String),
14    /// The server returned an unsuccessful HTTP status.
15    Api(Box<ApiError>),
16    /// A successful response did not contain the required data.
17    ResponseValidation {
18        /// Dotted path to the first invalid or missing field.
19        field_path: String,
20        /// The original response, available for inspection.
21        response: Box<crate::RawResponse>,
22    },
23    /// The request or response body could not be delivered.
24    Connection(reqwest::Error),
25    /// The full response did not arrive within the request timeout.
26    Timeout {
27        /// The configured timeout for this attempt.
28        timeout: Duration,
29        /// The underlying transport error.
30        source: reqwest::Error,
31    },
32    /// The caller cancelled the request.
33    Cancelled,
34}
35
36impl Error {
37    /// HTTP status for an API or response-validation error.
38    pub fn status(&self) -> Option<StatusCode> {
39        match self {
40            Self::Api(error) => Some(error.status),
41            Self::ResponseValidation { response, .. } => Some(response.status),
42            _ => None,
43        }
44    }
45
46    /// Response request ID, if the server provided one.
47    pub fn request_id(&self) -> Option<&str> {
48        match self {
49            Self::Api(error) => error.request_id(),
50            Self::ResponseValidation { response, .. } => response.request_id(),
51            _ => None,
52        }
53    }
54
55    /// The structured server error, when this is an HTTP failure.
56    pub fn as_api_error(&self) -> Option<&ApiError> {
57        match self {
58            Self::Api(error) => Some(error),
59            _ => None,
60        }
61    }
62}
63
64impl fmt::Display for Error {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        match self {
67            Self::Configuration(message) => write!(f, "Invalid configuration: {message}"),
68            Self::InvalidRequest(message) => write!(f, "Invalid request: {message}"),
69            Self::Api(error) => error.fmt(f),
70            Self::ResponseValidation {
71                field_path,
72                response,
73            } => {
74                write!(
75                    f,
76                    "{} Invalid response data at {field_path:?}",
77                    response.status
78                )?;
79                if let Some(request_id) = response.request_id() {
80                    write!(f, " (request_id={request_id})")?;
81                }
82                Ok(())
83            }
84            Self::Connection(_) => f.write_str("Request connection failed"),
85            Self::Timeout { timeout, .. } => {
86                write!(f, "Request timed out (timeout={timeout:?})")
87            }
88            Self::Cancelled => f.write_str("Request was cancelled"),
89        }
90    }
91}
92
93// Transport errors and raw responses can contain credentials or document data.
94// Keep them accessible through explicit fields without including them in logs.
95impl fmt::Debug for Error {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        match self {
98            Self::Api(error) => f.debug_tuple("Api").field(error).finish(),
99            Self::ResponseValidation {
100                field_path,
101                response,
102            } => f
103                .debug_struct("ResponseValidation")
104                .field("field_path", field_path)
105                .field("status", &response.status)
106                .field("request_id", &response.request_id())
107                .finish_non_exhaustive(),
108            _ => fmt::Display::fmt(self, f),
109        }
110    }
111}
112
113impl std::error::Error for Error {
114    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
115        match self {
116            Self::Api(error) => Some(error.as_ref()),
117            Self::Connection(error) | Self::Timeout { source: error, .. } => Some(error),
118            _ => None,
119        }
120    }
121}
122
123impl From<ApiError> for Error {
124    fn from(error: ApiError) -> Self {
125        Self::Api(Box::new(error))
126    }
127}
128
129/// Classification of an unsuccessful HTTP response.
130#[derive(Clone, Copy, Debug, Eq, PartialEq)]
131#[non_exhaustive]
132pub enum ApiErrorKind {
133    /// HTTP 400.
134    BadRequest,
135    /// HTTP 401.
136    Authentication,
137    /// HTTP 403.
138    PermissionDenied,
139    /// HTTP 404.
140    NotFound,
141    /// HTTP 422.
142    UnprocessableEntity,
143    /// HTTP 429.
144    RateLimit,
145    /// HTTP 5xx.
146    InternalServer,
147    /// Another unsuccessful status.
148    Other,
149}
150
151/// An unsuccessful HTTP response with its body and request metadata.
152///
153/// `Display` and `Debug` omit the body and arbitrary headers. Inspect [`Self::message`]
154/// or `body` explicitly when server details are appropriate to expose.
155#[derive(Clone)]
156pub struct ApiError {
157    /// HTTP response status.
158    pub status: StatusCode,
159    /// JSON error data, response text as a string, or null for an empty body.
160    pub body: Value,
161    /// The original response headers.
162    pub headers: HeaderMap,
163    /// Request method and URL, without URL credentials, query, or fragment.
164    pub endpoint: Option<String>,
165}
166
167impl ApiError {
168    /// Construct an API error and sanitize the optional endpoint.
169    pub fn new(
170        status: StatusCode,
171        body: Value,
172        headers: HeaderMap,
173        endpoint: Option<String>,
174    ) -> Self {
175        Self {
176            status,
177            body,
178            headers,
179            endpoint: endpoint.map(|endpoint| sanitize_endpoint(&endpoint)),
180        }
181    }
182
183    /// Classify the error by its HTTP status.
184    pub fn kind(&self) -> ApiErrorKind {
185        match self.status.as_u16() {
186            400 => ApiErrorKind::BadRequest,
187            401 => ApiErrorKind::Authentication,
188            403 => ApiErrorKind::PermissionDenied,
189            404 => ApiErrorKind::NotFound,
190            422 => ApiErrorKind::UnprocessableEntity,
191            429 => ApiErrorKind::RateLimit,
192            500..=599 => ApiErrorKind::InternalServer,
193            _ => ApiErrorKind::Other,
194        }
195    }
196
197    /// The `x-typesafe-request-id` response header, if valid UTF-8.
198    pub fn request_id(&self) -> Option<&str> {
199        self.headers
200            .get("x-typesafe-request-id")
201            .and_then(|value| value.to_str().ok())
202    }
203
204    /// Server-requested retry delay, preferring `retry-after-ms`.
205    pub fn retry_after(&self) -> Option<Duration> {
206        crate::retry::parse_retry_after(&self.headers)
207    }
208
209    /// Extract the server's error message, including FastAPI validation details.
210    ///
211    /// This can contain response data; it is deliberately omitted from automatic
212    /// error formatting. Unstructured fallback bodies are limited to 200 characters.
213    pub fn message(&self) -> String {
214        if let Some(message) = extract_message(&self.body).filter(|message| !message.is_empty()) {
215            return message;
216        }
217        if self.body.is_null() {
218            return "status code (no body)".into();
219        }
220        let raw = self
221            .body
222            .as_str()
223            .map(str::to_owned)
224            .unwrap_or_else(|| self.body.to_string());
225        let mut chars = raw.chars();
226        let mut message: String = chars.by_ref().take(200).collect();
227        if chars.next().is_some() {
228            message.push('…');
229        }
230        message
231    }
232}
233
234impl fmt::Display for ApiError {
235    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236        if let Some(endpoint) = &self.endpoint {
237            write!(f, "{}: ", sanitize_endpoint(endpoint))?;
238        }
239        write!(f, "{}", self.status)?;
240        if let Some(request_id) = self.request_id() {
241            write!(f, " (request_id={request_id})")?;
242        }
243        Ok(())
244    }
245}
246
247impl fmt::Debug for ApiError {
248    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
249        f.debug_struct("ApiError")
250            .field("kind", &self.kind())
251            .field("status", &self.status)
252            .field("endpoint", &self.endpoint.as_deref().map(sanitize_endpoint))
253            .field("request_id", &self.request_id())
254            .finish_non_exhaustive()
255    }
256}
257
258impl std::error::Error for ApiError {}
259
260fn sanitize_endpoint(endpoint: &str) -> String {
261    let (method, raw_url) = match endpoint.split_once(' ') {
262        Some((method, url)) if method.bytes().all(|byte| byte.is_ascii_uppercase()) => {
263            (Some(method), url)
264        }
265        _ => (None, endpoint),
266    };
267    let sanitized = match Url::parse(raw_url) {
268        Ok(mut url) if matches!(url.scheme(), "http" | "https") => {
269            let _ = url.set_username("");
270            let _ = url.set_password(None);
271            url.set_query(None);
272            url.set_fragment(None);
273            url.to_string()
274        }
275        _ => "<invalid URL>".into(),
276    };
277    match method {
278        Some(method) => format!("{method} {sanitized}"),
279        None => sanitized,
280    }
281}
282
283fn extract_message(body: &Value) -> Option<String> {
284    if let Some(message) = body.as_str() {
285        return Some(message.to_owned());
286    }
287    let body = body.as_object()?;
288    let error = body.get("error");
289    let detail = body.get("detail");
290    let message = error
291        .and_then(Value::as_str)
292        .or_else(|| error?.get("message")?.as_str())
293        .or_else(|| body.get("message")?.as_str())
294        .or_else(|| detail?.as_str())
295        .or_else(|| detail?.get("message")?.as_str());
296    if let Some(message) = message {
297        return Some(message.to_owned());
298    }
299    let parts: Vec<_> = detail?
300        .as_array()?
301        .iter()
302        .filter_map(|entry| {
303            let message = entry.get("msg")?.as_str()?;
304            let path = entry
305                .get("loc")
306                .and_then(Value::as_array)
307                .map(|location| {
308                    location
309                        .iter()
310                        .filter(|part| part.as_str() != Some("body"))
311                        .map(|part| {
312                            part.as_str()
313                                .map(str::to_owned)
314                                .unwrap_or_else(|| part.to_string())
315                        })
316                        .collect::<Vec<_>>()
317                        .join(".")
318                })
319                .unwrap_or_default();
320            Some(if path.is_empty() {
321                message.to_owned()
322            } else {
323                format!("{path}: {message}")
324            })
325        })
326        .collect();
327    (!parts.is_empty()).then(|| parts.join("; "))
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use serde_json::json;
334
335    #[test]
336    fn classifies_all_specialized_statuses() {
337        for (status, kind) in [
338            (400, ApiErrorKind::BadRequest),
339            (401, ApiErrorKind::Authentication),
340            (403, ApiErrorKind::PermissionDenied),
341            (404, ApiErrorKind::NotFound),
342            (422, ApiErrorKind::UnprocessableEntity),
343            (429, ApiErrorKind::RateLimit),
344            (500, ApiErrorKind::InternalServer),
345            (599, ApiErrorKind::InternalServer),
346            (409, ApiErrorKind::Other),
347        ] {
348            assert_eq!(
349                ApiError::new(
350                    StatusCode::from_u16(status).unwrap(),
351                    Value::Null,
352                    HeaderMap::new(),
353                    None,
354                )
355                .kind(),
356                kind,
357            );
358        }
359    }
360
361    #[test]
362    fn extracts_message_shapes_and_validation_paths() {
363        for (body, expected) in [
364            (json!("plain"), "plain"),
365            (json!({"error": "error", "message": "message"}), "error"),
366            (json!({"error": {"message": "nested"}}), "nested"),
367            (json!({"message": "message"}), "message"),
368            (json!({"detail": "detail"}), "detail"),
369            (
370                json!({"detail": {"message": "nested detail"}}),
371                "nested detail",
372            ),
373            (
374                json!({"detail": [
375                    {"loc": ["body", "questions", 0, "name"], "msg": "required"},
376                    {"msg": "invalid input"},
377                    {"loc": ["ignored"]}
378                ]}),
379                "questions.0.name: required; invalid input",
380            ),
381        ] {
382            let error = ApiError::new(StatusCode::BAD_REQUEST, body, HeaderMap::new(), None);
383            assert_eq!(error.message(), expected);
384        }
385    }
386
387    #[test]
388    fn logging_omits_response_data_and_sanitizes_urls() {
389        let mut headers = HeaderMap::new();
390        headers.insert("authorization", "Bearer secret-header".parse().unwrap());
391        headers.insert("x-typesafe-request-id", "req-123".parse().unwrap());
392        let error = ApiError::new(
393            StatusCode::UNAUTHORIZED,
394            json!({"message": "secret-body"}),
395            headers,
396            Some("POST https://user:secret-password@example.com/v1/extract?key=secret-query#secret-fragment".into()),
397        );
398        assert_eq!(
399            error.endpoint.as_deref(),
400            Some("POST https://example.com/v1/extract")
401        );
402        for formatted in [
403            format!("{error}"),
404            format!("{error:?}"),
405            format!("{:?}", Error::from(error)),
406        ] {
407            assert!(formatted.contains("req-123"));
408            assert!(!formatted.contains("secret"));
409            assert!(!formatted.contains("user:"));
410        }
411    }
412
413    #[test]
414    fn raw_fallback_truncates_on_unicode_character_boundaries() {
415        let error = ApiError::new(
416            StatusCode::BAD_REQUEST,
417            json!(["é".repeat(300)]),
418            HeaderMap::new(),
419            None,
420        );
421        assert_eq!(error.message().chars().count(), 201);
422        assert!(error.message().ends_with('…'));
423    }
424
425    #[test]
426    fn validation_errors_retain_metadata_without_logging_raw_response() {
427        let mut headers = HeaderMap::new();
428        headers.insert("x-typesafe-request-id", "req-456".parse().unwrap());
429        headers.insert("set-cookie", "secret-session".parse().unwrap());
430        let error = Error::ResponseValidation {
431            field_path: "answers.tone.confidence".into(),
432            response: Box::new(crate::RawResponse {
433                status: StatusCode::OK,
434                headers,
435                body: b"secret-document".to_vec().into(),
436            }),
437        };
438        assert_eq!(error.status(), Some(StatusCode::OK));
439        assert_eq!(error.request_id(), Some("req-456"));
440        for formatted in [format!("{error}"), format!("{error:?}")] {
441            assert!(formatted.contains("answers.tone.confidence"));
442            assert!(formatted.contains("req-456"));
443            assert!(!formatted.contains("secret"));
444        }
445    }
446}