Skip to main content

kunobi_jev/
error.rs

1//! Error types.
2
3use std::fmt;
4use std::time::Duration;
5
6use reqwest::StatusCode;
7use reqwest::header::HeaderMap;
8use serde_json::Value;
9
10use crate::retry::parse_retry_after;
11
12/// Header carrying the server's request ID.
13pub const REQUEST_ID_HEADER: &str = "x-typesafe-request-id";
14
15/// Longest raw body quoted in an [`ApiError`] message, in characters.
16const MAX_RAW_BODY_IN_MESSAGE: usize = 200;
17
18/// A `Result` whose error defaults to [`Error`].
19pub type Result<T, E = Error> = std::result::Result<T, E>;
20
21/// Everything a client call can fail with.
22#[derive(Debug, thiserror::Error)]
23#[non_exhaustive]
24pub enum Error {
25    /// The client configuration is missing or invalid.
26    #[error("{0}")]
27    Config(String),
28
29    /// The request was rejected before it was sent.
30    #[error("{0}")]
31    InvalidRequest(String),
32
33    /// The server answered with a non-2xx status after retries.
34    #[error(transparent)]
35    Api(Box<ApiError>),
36
37    /// The request or the response body failed in transit (DNS, TLS, reset).
38    #[error("Connection error: {source}")]
39    Connection {
40        /// The underlying transport error.
41        #[source]
42        source: reqwest::Error,
43    },
44
45    /// The full response did not arrive within the per-attempt timeout.
46    #[error("Request timed out after {}ms.", .timeout.as_millis())]
47    Timeout {
48        /// The per-attempt timeout that elapsed.
49        timeout: Duration,
50    },
51
52    /// A 2xx response body did not have the expected shape.
53    #[error("{message}")]
54    Decode {
55        /// What was wrong with the body.
56        message: String,
57        /// Request ID from `x-typesafe-request-id`.
58        request_id: Option<String>,
59        /// The JSON parse error, when there was one.
60        #[source]
61        source: Option<serde_json::Error>,
62    },
63
64    /// The credential provider failed, timed out, or returned an unusable token.
65    ///
66    /// The message never includes the token. Not retried.
67    #[error("Could not get credentials: {source}")]
68    Credentials {
69        /// What went wrong.
70        #[source]
71        source: crate::credentials::BoxError,
72    },
73
74    /// An answer is missing, has another type, or uses a label the typed key doesn't know.
75    #[error("Unexpected answer \"{name}\": {reason}.")]
76    UnexpectedAnswer {
77        /// The question name.
78        name: String,
79        /// What didn't match.
80        reason: String,
81    },
82}
83
84impl Error {
85    /// The HTTP status, for [`Error::Api`].
86    pub fn status(&self) -> Option<StatusCode> {
87        self.api_error().map(ApiError::status)
88    }
89
90    /// The server's request ID, when the response carried one.
91    pub fn request_id(&self) -> Option<&str> {
92        match self {
93            Error::Api(err) => err.request_id(),
94            Error::Decode { request_id, .. } => request_id.as_deref(),
95            _ => None,
96        }
97    }
98
99    /// The API error, for [`Error::Api`].
100    pub fn api_error(&self) -> Option<&ApiError> {
101        match self {
102            Error::Api(err) => Some(err),
103            _ => None,
104        }
105    }
106
107    /// Whether the call timed out.
108    pub fn is_timeout(&self) -> bool {
109        matches!(self, Error::Timeout { .. })
110    }
111
112    /// Whether the call failed in transit. Timeouts count as connection errors.
113    pub fn is_connection(&self) -> bool {
114        matches!(self, Error::Connection { .. } | Error::Timeout { .. })
115    }
116}
117
118impl From<ApiError> for Error {
119    fn from(err: ApiError) -> Self {
120        Error::Api(Box::new(err))
121    }
122}
123
124/// The class of an unsuccessful HTTP status.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
126#[non_exhaustive]
127pub enum ApiErrorKind {
128    /// HTTP 400: the request is invalid.
129    BadRequest,
130    /// HTTP 401: authentication failed.
131    Authentication,
132    /// HTTP 403: access is denied.
133    PermissionDenied,
134    /// HTTP 404: the resource was not found.
135    NotFound,
136    /// HTTP 422: request validation failed.
137    UnprocessableEntity,
138    /// HTTP 429: the rate limit was exceeded.
139    RateLimit,
140    /// HTTP 529: TypeSafe is temporarily overloaded. Documented as "retry after
141    /// a short delay", which the default policy already does.
142    Overloaded,
143    /// HTTP 5xx: the server failed to handle the request.
144    InternalServer,
145    /// Any other non-2xx status.
146    Other,
147}
148
149impl ApiErrorKind {
150    /// Classify an HTTP status.
151    pub fn from_status(status: StatusCode) -> Self {
152        match status.as_u16() {
153            400 => Self::BadRequest,
154            401 => Self::Authentication,
155            403 => Self::PermissionDenied,
156            404 => Self::NotFound,
157            422 => Self::UnprocessableEntity,
158            429 => Self::RateLimit,
159            529 => Self::Overloaded,
160            500.. => Self::InternalServer,
161            _ => Self::Other,
162        }
163    }
164}
165
166/// A response body kept for diagnostics.
167#[derive(Debug, Clone, PartialEq)]
168pub enum ErrorBody {
169    /// The body was empty.
170    Empty,
171    /// The body parsed as JSON.
172    Json(Value),
173    /// The body was not JSON.
174    Text(String),
175}
176
177impl ErrorBody {
178    /// Parse a body leniently: JSON when it parses, text otherwise.
179    ///
180    /// The content type is ignored because servers and proxies don't always set it.
181    pub fn parse(bytes: &[u8]) -> Self {
182        if bytes.is_empty() {
183            return Self::Empty;
184        }
185        match serde_json::from_slice(bytes) {
186            Ok(value) => Self::Json(value),
187            Err(_) => Self::Text(String::from_utf8_lossy(bytes).into_owned()),
188        }
189    }
190}
191
192/// An unsuccessful HTTP response from the API.
193#[derive(Debug, Clone)]
194pub struct ApiError {
195    kind: ApiErrorKind,
196    status: StatusCode,
197    headers: HeaderMap,
198    body: ErrorBody,
199    request_id: Option<String>,
200    message: String,
201}
202
203impl ApiError {
204    /// Build an error from a raw response, deriving its kind and message.
205    pub fn from_response(status: StatusCode, headers: HeaderMap, body: &[u8]) -> Self {
206        let body = ErrorBody::parse(body);
207        let message = describe(status, &body);
208        Self {
209            kind: ApiErrorKind::from_status(status),
210            request_id: request_id_from(&headers),
211            status,
212            headers,
213            body,
214            message,
215        }
216    }
217
218    /// The class of the status code.
219    pub fn kind(&self) -> ApiErrorKind {
220        self.kind
221    }
222
223    /// The HTTP status.
224    pub fn status(&self) -> StatusCode {
225        self.status
226    }
227
228    /// The response headers.
229    pub fn headers(&self) -> &HeaderMap {
230        &self.headers
231    }
232
233    /// The parsed response body.
234    pub fn body(&self) -> &ErrorBody {
235        &self.body
236    }
237
238    /// The request ID from `x-typesafe-request-id`.
239    pub fn request_id(&self) -> Option<&str> {
240        self.request_id.as_deref()
241    }
242
243    /// The server's retry delay from `retry-after-ms` or `Retry-After`.
244    ///
245    /// Usually present on [`ApiErrorKind::RateLimit`], sometimes on 503.
246    pub fn retry_after(&self) -> Option<Duration> {
247        parse_retry_after(&self.headers, std::time::SystemTime::now())
248    }
249
250    /// The error message, `"<status> <detail>"`.
251    pub fn message(&self) -> &str {
252        &self.message
253    }
254}
255
256impl fmt::Display for ApiError {
257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258        f.write_str(&self.message)
259    }
260}
261
262impl std::error::Error for ApiError {}
263
264pub(crate) fn request_id_from(headers: &HeaderMap) -> Option<String> {
265    headers
266        .get(REQUEST_ID_HEADER)
267        .and_then(|value| value.to_str().ok())
268        .map(str::to_owned)
269}
270
271/// `"<status> <detail>"`, falling back to a truncated raw body.
272fn describe(status: StatusCode, body: &ErrorBody) -> String {
273    let status = status.as_u16();
274    // An empty message is not a message; quote the body instead.
275    if let Some(detail) = extract_message(body).filter(|detail| !detail.is_empty()) {
276        return format!("{status} {detail}");
277    }
278    let raw = match body {
279        ErrorBody::Empty => return format!("{status} status code (no body)"),
280        ErrorBody::Text(text) | ErrorBody::Json(Value::String(text)) => text.clone(),
281        ErrorBody::Json(value) => value.to_string(),
282    };
283    match raw.char_indices().nth(MAX_RAW_BODY_IN_MESSAGE) {
284        Some((cut, _)) => format!("{status} {}…", &raw[..cut]),
285        None => format!("{status} {raw}"),
286    }
287}
288
289/// Pull a message out of a text, error, or validation response body.
290fn extract_message(body: &ErrorBody) -> Option<String> {
291    let value = match body {
292        ErrorBody::Empty => return None,
293        ErrorBody::Text(text) => return non_empty(text),
294        ErrorBody::Json(value) => value,
295    };
296    if let Value::String(text) = value {
297        return non_empty(text);
298    }
299    let object = value.as_object()?;
300    let error = object.get("error");
301    let detail = object.get("detail");
302    if let Some(Value::String(text)) = error {
303        return Some(text.clone());
304    }
305    if let Some(text) = error.and_then(Value::as_object).and_then(nested_message) {
306        return Some(text);
307    }
308    if let Some(Value::String(text)) = object.get("message") {
309        return Some(text.clone());
310    }
311    match detail {
312        Some(Value::String(text)) => Some(text.clone()),
313        Some(Value::Object(inner)) => nested_message(inner),
314        Some(Value::Array(errors)) => describe_validation_errors(errors),
315        _ => None,
316    }
317}
318
319/// The readable part of a nested error object.
320///
321/// `message` when the server wrote one. Otherwise `error_type`, which is all
322/// TypeSafe sends for some failures: an oversized request answers with
323/// `{"detail":{"error_type":"max_tokens_exceeded"}}`, and quoting that JSON at
324/// the caller is worse than naming the error.
325fn nested_message(object: &serde_json::Map<String, Value>) -> Option<String> {
326    ["message", "error_type"]
327        .into_iter()
328        .find_map(|key| object.get(key).and_then(Value::as_str))
329        .and_then(non_empty)
330}
331
332fn non_empty(text: &str) -> Option<String> {
333    (!text.is_empty()).then(|| text.to_owned())
334}
335
336/// Format validation errors as `path: message` entries joined by `"; "`.
337fn describe_validation_errors(errors: &[Value]) -> Option<String> {
338    let parts: Vec<String> = errors
339        .iter()
340        .filter_map(|error| {
341            let msg = error.get("msg")?.as_str()?;
342            let loc = error
343                .get("loc")
344                .and_then(Value::as_array)
345                .map(|segments| {
346                    segments
347                        .iter()
348                        .filter(|segment| segment.as_str() != Some("body"))
349                        .map(loc_segment)
350                        .collect::<Vec<_>>()
351                        .join(".")
352                })
353                .unwrap_or_default();
354            Some(if loc.is_empty() {
355                msg.to_owned()
356            } else {
357                format!("{loc}: {msg}")
358            })
359        })
360        .collect();
361    (!parts.is_empty()).then(|| parts.join("; "))
362}
363
364fn loc_segment(segment: &Value) -> String {
365    match segment {
366        Value::String(text) => text.clone(),
367        Value::Null => String::new(),
368        other => other.to_string(),
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use reqwest::header::HeaderValue;
376    use serde_json::json;
377
378    fn message(status: u16, body: &[u8]) -> String {
379        ApiError::from_response(
380            StatusCode::from_u16(status).unwrap(),
381            HeaderMap::new(),
382            body,
383        )
384        .to_string()
385    }
386
387    #[test]
388    fn classifies_statuses() {
389        let kind = |s| ApiErrorKind::from_status(StatusCode::from_u16(s).unwrap());
390        assert_eq!(kind(400), ApiErrorKind::BadRequest);
391        assert_eq!(kind(401), ApiErrorKind::Authentication);
392        assert_eq!(kind(403), ApiErrorKind::PermissionDenied);
393        assert_eq!(kind(404), ApiErrorKind::NotFound);
394        assert_eq!(kind(422), ApiErrorKind::UnprocessableEntity);
395        assert_eq!(kind(429), ApiErrorKind::RateLimit);
396        assert_eq!(kind(500), ApiErrorKind::InternalServer);
397        assert_eq!(kind(529), ApiErrorKind::Overloaded);
398        assert_eq!(kind(599), ApiErrorKind::InternalServer);
399        assert_eq!(kind(409), ApiErrorKind::Other);
400        assert_eq!(kind(302), ApiErrorKind::Other);
401    }
402
403    #[test]
404    fn extracts_messages_from_common_shapes() {
405        assert_eq!(message(400, br#"{"error":"bad"}"#), "400 bad");
406        assert_eq!(
407            message(400, br#"{"error":{"message":"nested"}}"#),
408            "400 nested"
409        );
410        assert_eq!(message(400, br#"{"message":"plain"}"#), "400 plain");
411        assert_eq!(message(400, br#"{"detail":"why"}"#), "400 why");
412        assert_eq!(
413            message(400, br#"{"detail":{"message":"deep"}}"#),
414            "400 deep"
415        );
416        assert_eq!(message(400, br#""quoted""#), "400 quoted");
417        assert_eq!(message(502, b"Bad Gateway"), "502 Bad Gateway");
418    }
419
420    /// The live API answers an oversized request with this exact body, and the
421    /// error_type is the only thing in it worth reading.
422    #[test]
423    fn error_type_is_used_when_there_is_no_message() {
424        assert_eq!(
425            message(400, br#"{"detail":{"error_type":"max_tokens_exceeded"}}"#),
426            "400 max_tokens_exceeded"
427        );
428        assert_eq!(
429            message(400, br#"{"error":{"error_type":"rate_limited"}}"#),
430            "400 rate_limited"
431        );
432        // A message still wins when both are present.
433        assert_eq!(
434            message(
435                400,
436                br#"{"detail":{"error_type":"bad","message":"be specific"}}"#
437            ),
438            "400 be specific"
439        );
440    }
441
442    #[test]
443    fn prefers_error_over_message_and_detail() {
444        assert_eq!(
445            message(400, br#"{"detail":"d","message":"m","error":"e"}"#),
446            "400 e"
447        );
448        assert_eq!(message(400, br#"{"detail":"d","message":"m"}"#), "400 m");
449    }
450
451    #[test]
452    fn formats_validation_errors_without_the_body_prefix() {
453        let body = json!({"detail": [
454            {"loc": ["body", "questions", "x", 0], "msg": "too short"},
455            {"loc": [], "msg": "bad state"},
456            {"msg": 3},
457        ]});
458        assert_eq!(
459            message(422, body.to_string().as_bytes()),
460            "422 questions.x.0: too short; bad state"
461        );
462    }
463
464    #[test]
465    fn falls_back_to_raw_body_or_no_body() {
466        assert_eq!(message(500, b""), "500 status code (no body)");
467        assert_eq!(message(500, br#"{"other":1}"#), r#"500 {"other":1}"#);
468        assert_eq!(message(500, br#"{"detail":[]}"#), r#"500 {"detail":[]}"#);
469    }
470
471    #[test]
472    fn empty_extracted_messages_fall_back_to_the_raw_body() {
473        // The first matching field wins even when empty, like the JS SDK, and an
474        // empty detail is never used as the message.
475        assert_eq!(
476            message(400, br#"{"error":"","message":"m"}"#),
477            r#"400 {"error":"","message":"m"}"#
478        );
479        assert_eq!(message(400, br#"{"message":""}"#), r#"400 {"message":""}"#);
480        assert_eq!(
481            message(400, br#"{"error":{"message":""}}"#),
482            r#"400 {"error":{"message":""}}"#
483        );
484        assert_eq!(message(400, br#"{"detail":""}"#), r#"400 {"detail":""}"#);
485    }
486
487    #[test]
488    fn json_string_bodies_are_not_quoted() {
489        assert_eq!(message(400, br#""""#), "400 ");
490    }
491
492    #[test]
493    fn truncates_long_json_bodies_on_char_boundaries() {
494        let body = json!({ "x": "é".repeat(250) }).to_string();
495        let expected: String = body.chars().take(200).collect();
496        assert_eq!(message(500, body.as_bytes()), format!("500 {expected}…"));
497
498        let exact = json!({ "x": "a".repeat(192) }).to_string();
499        assert_eq!(exact.chars().count(), 200);
500        assert_eq!(message(500, exact.as_bytes()), format!("500 {exact}"));
501    }
502
503    #[test]
504    fn text_bodies_are_the_message_in_full() {
505        let long = "é".repeat(250);
506        assert_eq!(message(502, long.as_bytes()), format!("502 {long}"));
507    }
508
509    #[test]
510    fn exposes_request_id_and_retry_after() {
511        let mut headers = HeaderMap::new();
512        headers.insert(REQUEST_ID_HEADER, HeaderValue::from_static("req_1"));
513        headers.insert("retry-after", HeaderValue::from_static("7"));
514        let err = ApiError::from_response(StatusCode::TOO_MANY_REQUESTS, headers, b"{}");
515        assert_eq!(err.request_id(), Some("req_1"));
516        assert_eq!(err.retry_after(), Some(Duration::from_secs(7)));
517        assert_eq!(err.kind(), ApiErrorKind::RateLimit);
518
519        let wrapped = Error::from(err);
520        assert_eq!(wrapped.request_id(), Some("req_1"));
521        assert_eq!(wrapped.status(), Some(StatusCode::TOO_MANY_REQUESTS));
522        assert!(!wrapped.is_connection());
523    }
524
525    #[test]
526    fn timeouts_are_connection_errors() {
527        let err = Error::Timeout {
528            timeout: Duration::from_millis(1000),
529        };
530        assert!(err.is_timeout());
531        assert!(err.is_connection());
532        assert_eq!(err.to_string(), "Request timed out after 1000ms.");
533    }
534}