Skip to main content

jev_sdk/
error.rs

1//! Error types for the SDK.
2
3use reqwest::StatusCode;
4use serde_json::Value;
5use std::fmt;
6
7/// Errors returned by this SDK.
8#[derive(Debug, thiserror::Error)]
9#[non_exhaustive]
10pub enum Error {
11    /// The client is misconfigured (e.g. a missing API key or invalid URL).
12    #[error("configuration error: {0}")]
13    Config(String),
14
15    /// The request failed client-side validation before anything was sent.
16    #[error("invalid request: {0}")]
17    InvalidRequest(String),
18
19    /// The API returned an unsuccessful HTTP response (after any retries).
20    #[error(transparent)]
21    Api(ApiError),
22
23    /// The request could not be sent, or the connection failed (after any
24    /// retries). Includes request timeouts.
25    #[error("connection error: {0}")]
26    Connection(String),
27
28    /// A successful response body could not be decoded.
29    #[error("failed to decode response body: {0}")]
30    Decode(String),
31}
32
33impl Error {
34    /// The HTTP status code, if this is an API error.
35    pub fn status(&self) -> Option<StatusCode> {
36        match self {
37            Error::Api(e) => Some(e.status),
38            _ => None,
39        }
40    }
41
42    /// Whether the request failed authentication (HTTP 401).
43    pub fn is_authentication(&self) -> bool {
44        self.status() == Some(StatusCode::UNAUTHORIZED)
45    }
46
47    /// Whether the request was rate-limited or the service was overloaded
48    /// (HTTP 429 or 529).
49    pub fn is_rate_limited(&self) -> bool {
50        matches!(self.status().map(|s| s.as_u16()), Some(429) | Some(529))
51    }
52
53    /// Whether the request body failed server-side validation
54    /// (HTTP 400 or 422).
55    pub fn is_validation(&self) -> bool {
56        matches!(self.status().map(|s| s.as_u16()), Some(400) | Some(422))
57    }
58}
59
60/// An unsuccessful HTTP response from the TypeSafe API.
61#[derive(Debug)]
62pub struct ApiError {
63    /// The HTTP status code.
64    pub status: StatusCode,
65    /// A human-readable error message extracted from the response body.
66    pub message: String,
67    /// The parsed response body, if it was JSON.
68    pub body: Option<Value>,
69}
70
71impl fmt::Display for ApiError {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        write!(f, "API error (status {}): {}", self.status, self.message)
74    }
75}
76
77impl std::error::Error for ApiError {}
78
79impl ApiError {
80    /// Build an `ApiError` from a status code and a raw response body,
81    /// extracting a message from the API's `{"detail": ...}` error shapes:
82    /// a plain string, `{"error_type": ..., "message": ...}`, or a list of
83    /// validation errors.
84    pub(crate) fn from_response(status: StatusCode, raw: &str) -> Self {
85        let body: Option<Value> = serde_json::from_str(raw).ok();
86        let message = body
87            .as_ref()
88            .and_then(|b| b.get("detail"))
89            .map(extract_detail_message)
90            .filter(|m| !m.is_empty())
91            .unwrap_or_else(|| {
92                if raw.is_empty() {
93                    status
94                        .canonical_reason()
95                        .unwrap_or("unknown error")
96                        .to_owned()
97                } else {
98                    raw.chars().take(512).collect()
99                }
100            });
101        ApiError {
102            status,
103            message,
104            body,
105        }
106    }
107}
108
109fn extract_detail_message(detail: &Value) -> String {
110    match detail {
111        Value::String(s) => s.clone(),
112        Value::Object(map) => map
113            .get("message")
114            .and_then(Value::as_str)
115            .map(str::to_owned)
116            .unwrap_or_else(|| detail.to_string()),
117        Value::Array(items) => items
118            .iter()
119            .map(|item| {
120                let loc = item
121                    .get("loc")
122                    .and_then(Value::as_array)
123                    .map(|parts| {
124                        parts
125                            .iter()
126                            .filter_map(|p| {
127                                p.as_str()
128                                    .map(str::to_owned)
129                                    .or_else(|| p.as_number().map(|n| n.to_string()))
130                            })
131                            .collect::<Vec<_>>()
132                            .join(".")
133                    })
134                    .unwrap_or_default();
135                let msg = item.get("msg").and_then(Value::as_str).unwrap_or("invalid");
136                if loc.is_empty() {
137                    msg.to_owned()
138                } else {
139                    format!("{loc}: {msg}")
140                }
141            })
142            .collect::<Vec<_>>()
143            .join("; "),
144        other => other.to_string(),
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn parses_object_detail() {
154        let err = ApiError::from_response(
155            StatusCode::UNAUTHORIZED,
156            r#"{"detail":{"error_type":"authentication_error","message":"Cannot authenticate with the server. Please check your API key and try again."}}"#,
157        );
158        assert_eq!(
159            err.message,
160            "Cannot authenticate with the server. Please check your API key and try again."
161        );
162        assert_eq!(err.status, StatusCode::UNAUTHORIZED);
163    }
164
165    #[test]
166    fn parses_string_detail() {
167        let err = ApiError::from_response(
168            StatusCode::BAD_REQUEST,
169            r#"{"detail":"Noul question must have criteria or instructions: q"}"#,
170        );
171        assert_eq!(
172            err.message,
173            "Noul question must have criteria or instructions: q"
174        );
175    }
176
177    #[test]
178    fn parses_fastapi_validation_list() {
179        let err = ApiError::from_response(
180            StatusCode::UNPROCESSABLE_ENTITY,
181            r#"{"detail":[{"loc":["body","questions"],"msg":"field required","type":"value_error.missing"}]}"#,
182        );
183        assert_eq!(err.message, "body.questions: field required");
184    }
185
186    #[test]
187    fn falls_back_to_raw_body() {
188        let err = ApiError::from_response(StatusCode::INTERNAL_SERVER_ERROR, "oops");
189        assert_eq!(err.message, "oops");
190        assert!(err.body.is_none());
191    }
192}