Skip to main content

hot_dev/
error.rs

1use std::fmt;
2
3use reqwest::header::HeaderMap;
4use serde_json::Value;
5
6use crate::JsonObject;
7
8/// The error type for SDK operations.
9#[derive(Debug, thiserror::Error)]
10pub enum Error {
11    /// The Hot API returned a non-2xx response.
12    #[error(transparent)]
13    Api(#[from] ApiError),
14    /// The HTTP request itself failed.
15    #[error("transport error: {0}")]
16    Http(#[from] reqwest::Error),
17    /// A response body was not valid JSON.
18    #[error("invalid JSON response: {0}")]
19    Json(#[from] serde_json::Error),
20    /// A run ended with `run:fail` or `run:cancel`; carries the run's result
21    /// message.
22    #[error("{0}")]
23    RunFailed(String),
24    /// Timed out waiting for a run result.
25    #[error("timeout waiting for run result")]
26    Timeout,
27    /// The stream ended before the expected event arrived.
28    #[error("{0}")]
29    Protocol(String),
30}
31
32/// Structured error for non-2xx Hot API responses.
33#[derive(Debug, Clone)]
34pub struct ApiError {
35    pub status_code: u16,
36    pub message: String,
37    pub code: Option<String>,
38    pub request_id: Option<String>,
39    /// Server-suggested retry delay in seconds, from the error body or
40    /// Retry-After header.
41    pub retry_after: Option<u64>,
42    /// The raw "error" object from the response body, if any.
43    pub error: Option<JsonObject>,
44}
45
46impl fmt::Display for ApiError {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        write!(f, "{} (status {})", self.message, self.status_code)
49    }
50}
51
52impl std::error::Error for ApiError {}
53
54pub(crate) fn parse_api_error(status_code: u16, text: &str, headers: &HeaderMap) -> ApiError {
55    let parsed: Value = serde_json::from_str(text).unwrap_or(Value::Null);
56    let error_object = parsed.get("error").and_then(Value::as_object);
57
58    match error_object {
59        Some(error) => {
60            let message = error
61                .get("message")
62                .and_then(Value::as_str)
63                .map(str::to_string)
64                .unwrap_or_else(|| format!("Hot API error ({status_code})"));
65            ApiError {
66                status_code,
67                message,
68                code: stringified(error.get("code")),
69                request_id: stringified(error.get("request_id")),
70                retry_after: retry_after(error.get("retry_after"), headers),
71                error: Some(error.clone()),
72            }
73        }
74        None => {
75            let message = if text.is_empty() {
76                format!("Hot API error ({status_code})")
77            } else {
78                text.to_string()
79            };
80            ApiError {
81                status_code,
82                message,
83                code: None,
84                request_id: None,
85                retry_after: retry_after(None, headers),
86                error: None,
87            }
88        }
89    }
90}
91
92fn stringified(value: Option<&Value>) -> Option<String> {
93    match value {
94        None | Some(Value::Null) => None,
95        Some(Value::String(text)) => Some(text.clone()),
96        Some(other) => Some(other.to_string()),
97    }
98}
99
100fn retry_after(value: Option<&Value>, headers: &HeaderMap) -> Option<u64> {
101    if let Some(seconds) = value.and_then(Value::as_u64) {
102        return Some(seconds);
103    }
104    headers
105        .get("retry-after")
106        .and_then(|header| header.to_str().ok())
107        .and_then(|header| header.parse().ok())
108}