Skip to main content

boxxkite_client/
error.rs

1//! Error types returned by every fallible call in this crate.
2
3use std::fmt;
4
5/// Everything this crate's `Result<T, BoxxkiteError>` can fail with.
6///
7/// Mirrors `sdk-python`'s `BoxxkiteApiError`/`BoxxkiteConnectionError` split
8/// (both subclass `BoxxkiteError` there; here both are variants of one enum,
9/// the idiomatic Rust shape for a small, closed set of error kinds).
10#[derive(Debug, thiserror::Error)]
11pub enum BoxxkiteError {
12    /// The control-plane responded with a non-2xx status. `code` is the
13    /// machine-readable `error.code` field from the response envelope
14    /// (`{"error": {"code", "message"}}`) -- see `docs/API.md`'s "Error
15    /// codes" table for the full list this API can return.
16    #[error("boxxkite API error {status}: {code} - {message}")]
17    Api {
18        status: u16,
19        code: String,
20        message: String,
21        retryable: bool,
22        remediation: Option<String>,
23    },
24
25    /// The request never reached the control-plane, or its response
26    /// couldn't be read (DNS failure, connection refused, TLS error,
27    /// timeout, etc).
28    #[error("connection error: {0}")]
29    Connection(#[source] reqwest::Error),
30
31    /// A response body that was expected to be well-formed JSON wasn't, or
32    /// couldn't be deserialized into the expected shape.
33    #[error("failed to decode response body: {0}")]
34    Decode(#[source] serde_json::Error),
35
36    /// A WebSocket-based call (`takeover`) failed to connect or errored
37    /// mid-stream.
38    #[error("websocket error: {0}")]
39    WebSocket(#[source] tokio_tungstenite::tungstenite::Error),
40
41    /// The Server-Sent Events stream (`watch`) errored. Boxed: the largest
42    /// variant of the underlying `reqwest_eventsource::Error` embeds a full
43    /// `reqwest::Response`, which would otherwise make every `BoxxkiteError`
44    /// (including cheap ones like `Config`) pay for that size on the stack.
45    #[error("event stream error: {0}")]
46    EventStream(#[source] Box<reqwest_eventsource::Error>),
47
48    /// A caller-supplied argument was invalid before any request was even
49    /// sent -- e.g. a non-`https://` `base_url` that isn't `localhost` (see
50    /// `ClientBuilder::build`'s doc comment for why this is rejected rather
51    /// than silently sent in cleartext).
52    #[error("invalid configuration: {0}")]
53    Config(String),
54}
55
56impl From<reqwest::Error> for BoxxkiteError {
57    fn from(err: reqwest::Error) -> Self {
58        BoxxkiteError::Connection(err)
59    }
60}
61
62impl From<serde_json::Error> for BoxxkiteError {
63    fn from(err: serde_json::Error) -> Self {
64        BoxxkiteError::Decode(err)
65    }
66}
67
68impl From<tokio_tungstenite::tungstenite::Error> for BoxxkiteError {
69    fn from(err: tokio_tungstenite::tungstenite::Error) -> Self {
70        BoxxkiteError::WebSocket(err)
71    }
72}
73
74impl From<reqwest_eventsource::Error> for BoxxkiteError {
75    fn from(err: reqwest_eventsource::Error) -> Self {
76        BoxxkiteError::EventStream(Box::new(err))
77    }
78}
79
80impl BoxxkiteError {
81    /// The machine-readable error code from an `Api` variant, if this is
82    /// one -- e.g. `"concurrent_sandbox_limit_reached"`. `None` for every
83    /// other variant.
84    pub fn code(&self) -> Option<&str> {
85        match self {
86            BoxxkiteError::Api { code, .. } => Some(code),
87            _ => None,
88        }
89    }
90
91    /// The HTTP status code from an `Api` variant, if this is one.
92    pub fn status(&self) -> Option<u16> {
93        match self {
94            BoxxkiteError::Api { status, .. } => Some(*status),
95            _ => None,
96        }
97    }
98
99    pub fn retryable(&self) -> bool {
100        match self { BoxxkiteError::Api { retryable, .. } => *retryable, _ => false }
101    }
102
103    pub fn remediation(&self) -> Option<&str> {
104        match self { BoxxkiteError::Api { remediation, .. } => remediation.as_deref(), _ => None }
105    }
106}
107
108/// Parsed shape of this API's error envelope: `{"error": {"code", "message"}}`.
109#[derive(Debug, serde::Deserialize)]
110pub(crate) struct ErrorEnvelope {
111    pub error: ErrorBody,
112}
113
114#[derive(Debug, serde::Deserialize)]
115pub(crate) struct ErrorBody {
116    #[serde(default = "default_error_code")]
117    pub code: String,
118    #[serde(default)]
119    pub message: Option<String>,
120    #[serde(default)]
121    pub retryable: bool,
122    #[serde(default)]
123    pub remediation: Option<String>,
124}
125
126fn default_error_code() -> String {
127    "error".to_string()
128}
129
130/// Parse this API's `{"error": {"code", "message"}}` envelope out of a
131/// response body, falling back to a generic message if the body isn't (or
132/// doesn't contain) that shape. Shared by [`crate::client::Client`]'s
133/// regular request path and `watch`'s Server-Sent Events path, which both
134/// need to turn a non-2xx response into the same `BoxxkiteError::Api` shape.
135pub(crate) fn api_error_from_bytes(status: u16, bytes: &[u8]) -> BoxxkiteError {
136    let parsed = serde_json::from_slice::<ErrorEnvelope>(bytes).ok();
137    let (code, message, retryable, remediation) = parsed
138        .map(|env| (env.error.code, env.error.message.unwrap_or_default(), env.error.retryable, env.error.remediation))
139        .unwrap_or_else(|| ("error".to_string(), format!("HTTP {status}"), status >= 500, None));
140    BoxxkiteError::Api {
141        status,
142        code,
143        message,
144        retryable,
145        remediation,
146    }
147}
148
149impl fmt::Display for ErrorBody {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        write!(
152            f,
153            "{}: {}",
154            self.code,
155            self.message.as_deref().unwrap_or("")
156        )
157    }
158}