Skip to main content

bezant/
error.rs

1//! Error types for bezant.
2
3use thiserror::Error;
4
5/// Result alias using [`enum@Error`].
6pub type Result<T> = std::result::Result<T, Error>;
7
8/// Boxed error suitable for round-tripping heterogeneous upstream errors.
9type DynError = Box<dyn std::error::Error + Send + Sync + 'static>;
10
11/// Errors that can arise when talking to the Client Portal Gateway.
12///
13/// The enum is `#[non_exhaustive]` — match on the variants you care about
14/// and handle the rest via a catch-all so adding new variants in a point
15/// release is not a breaking change.
16///
17/// # Retry hints
18///
19/// Use [`Error::is_retryable`] in retry loops rather than pattern-matching
20/// every variant. Transient transport failures, upstream 5xx, and
21/// `NoSession` are flagged retryable; caller-input errors and auth
22/// failures are not.
23#[derive(Debug, Error)]
24#[non_exhaustive]
25pub enum Error {
26    /// The base URL passed to [`Client::new`][crate::Client::new] could not be parsed.
27    #[error("invalid base URL: {0}")]
28    InvalidBaseUrl(String),
29
30    /// A URL we tried to manipulate isn't suitable as a base — either
31    /// the scheme can't have a hierarchical path (`data:`, `mailto:`)
32    /// or the URL has no host. Distinct from [`Error::InvalidBaseUrl`]
33    /// because it surfaces *during* a call rather than at builder time.
34    #[error("URL is not a base: {url}")]
35    UrlNotABase {
36        /// The URL whose base manipulation failed.
37        url: String,
38    },
39
40    /// HTTP transport failure (DNS, connection, TLS, timeouts).
41    #[error("http transport error: {0}")]
42    Http(#[from] reqwest::Error),
43
44    /// CPGateway returned a non-2xx status. Carries the endpoint name,
45    /// the upstream status code, and (where cheap) a short body preview.
46    /// Replaces a swathe of older `Error::Other("upstream 500")`-style
47    /// sites — callers can now branch on `.status` for retry logic.
48    #[error("upstream {endpoint} returned {status}")]
49    UpstreamStatus {
50        /// The endpoint that returned the bad status, e.g. `iserver/auth/status`.
51        endpoint: &'static str,
52        /// The HTTP status code received.
53        status: u16,
54        /// First few hundred bytes of the response body, if cheap to capture.
55        body_preview: Option<String>,
56    },
57
58    /// CPGateway returned a status code our typed client doesn't model.
59    /// Useful canary for OpenAPI spec drift — when a previously-undocumented
60    /// status appears in production, this surfaces the endpoint name so
61    /// telemetry can flag it without losing every detail to a string.
62    #[error("unknown response variant from {endpoint}")]
63    Unknown {
64        /// The endpoint whose response shape was unexpected.
65        endpoint: &'static str,
66    },
67
68    /// A JSON response body could not be decoded into the expected type —
69    /// typically a sign the Gateway sent an HTML error page on top of a
70    /// 2xx status (Akamai error pages, maintenance banners, etc.).
71    #[error("decode error from {endpoint} (status {status}): {message}")]
72    Decode {
73        /// The endpoint whose body failed to decode.
74        endpoint: String,
75        /// The HTTP status code on the response that failed to decode.
76        status: u16,
77        /// The underlying serde error, stringified.
78        message: String,
79    },
80
81    /// Caller-provided input was malformed (bad query parameter, oversize
82    /// body, unparseable URL, etc.). Distinct from [`Error::Other`] so
83    /// HTTP wrappers can map it to 400 rather than 500.
84    #[error("bad request: {0}")]
85    BadRequest(String),
86
87    /// A required query parameter was absent. Distinct from
88    /// [`Error::BadRequest`] so HTTP wrappers can map to a more specific
89    /// error code and so callers can hint the user about what's missing.
90    #[error("missing query parameter: {name}")]
91    MissingQuery {
92        /// The query-parameter name that was expected but absent.
93        name: &'static str,
94    },
95
96    /// HTTP header construction failed — the value contained bytes the
97    /// `http` crate refuses to put on the wire (control chars, non-visible
98    /// ASCII, CR/LF). Carries the header name + the underlying parse error.
99    #[error("invalid {name} header value: {source}")]
100    Header {
101        /// The header name we tried to construct.
102        name: &'static str,
103        /// The underlying value-validation failure.
104        #[source]
105        source: reqwest::header::InvalidHeaderValue,
106    },
107
108    /// An error bubbled up from the generated API layer. The inner
109    /// boxed error is whatever the generated client raised — this keeps
110    /// `bezant-core`'s public API free of a versioned `anyhow` type.
111    #[error("api error: {0}")]
112    Api(#[source] DynError),
113
114    /// Symbol → conid lookup returned no contracts. Distinct from
115    /// [`Error::UpstreamStatus`] because the upstream returned a
116    /// well-formed empty result, not a failure.
117    #[error("no contracts for symbol '{symbol}'")]
118    SymbolNotFound {
119        /// The symbol that returned no contracts.
120        symbol: String,
121    },
122
123    /// A contract IBKR returned for a symbol carried a conid that
124    /// couldn't be parsed as `i32` — surfaces as a typed signal so
125    /// callers don't have to string-match on `Error::Other`.
126    #[error("invalid conid '{raw}' for symbol '{symbol}': {source}")]
127    BadConid {
128        /// The symbol whose contract carried a bad conid.
129        symbol: String,
130        /// The raw string IBKR returned where a number was expected.
131        raw: String,
132        /// The underlying parse error.
133        #[source]
134        source: std::num::ParseIntError,
135    },
136
137    /// WebSocket handshake (HTTP upgrade) failed. Carries the URL we
138    /// were upgrading to plus the underlying tungstenite error so a
139    /// caller can branch on TLS vs DNS vs auth failures.
140    #[error("ws handshake to {url}: {source}")]
141    WsHandshake {
142        /// The WebSocket URL the handshake targeted.
143        url: String,
144        /// The underlying tungstenite error.
145        #[source]
146        source: tokio_tungstenite::tungstenite::Error,
147    },
148
149    /// WebSocket transport (read/write/close) failed mid-stream.
150    #[error("ws transport: {source}")]
151    WsTransport {
152        /// The underlying tungstenite error.
153        #[source]
154        source: tokio_tungstenite::tungstenite::Error,
155    },
156
157    /// WebSocket protocol violation or bezant-side serialisation issue.
158    #[error("ws protocol: {0}")]
159    WsProtocol(String),
160
161    /// Failed to construct an HTTP response — body assembly or header
162    /// validation. Server-side internal; shouldn't be observable in
163    /// normal operation.
164    #[error("response build: {0}")]
165    ResponseBuild(String),
166
167    /// The Gateway reports we are not authenticated — the user needs to log
168    /// in via the Gateway's browser UI.
169    #[error("gateway is not authenticated — log in at the Gateway URL first")]
170    NotAuthenticated,
171
172    /// The Gateway is reachable but has no active session (e.g. was just
173    /// booted, or session was invalidated by another login).
174    #[error("gateway has no active session")]
175    NoSession,
176
177    /// A catch-all for unexpected conditions that don't fit other variants.
178    /// Prefer adding a typed variant — this is the documented escape hatch
179    /// of last resort, and is mapped to a generic 500 at the HTTP boundary.
180    #[error("{0}")]
181    Other(String),
182}
183
184impl Error {
185    /// Construct a free-form error.
186    ///
187    /// Prefer a typed variant — `Error::Other` maps to a generic 500 at
188    /// the HTTP boundary and gives callers no programmatic handle.
189    pub fn other(msg: impl Into<String>) -> Self {
190        Self::Other(msg.into())
191    }
192
193    /// Hint for retry loops: returns `true` iff this error *might*
194    /// succeed on retry. Use this in backoff loops instead of
195    /// pattern-matching every variant by hand.
196    ///
197    /// Retryable:
198    /// * Transport timeouts / connect failures / general request errors
199    /// * Upstream 5xx and 429 (rate-limited)
200    /// * `NoSession` (session may come back)
201    /// * WebSocket transport failures (reconnect)
202    ///
203    /// Not retryable:
204    /// * Caller-input errors (`BadRequest`, `MissingQuery`,
205    ///   `InvalidBaseUrl`, `UrlNotABase`)
206    /// * Auth failures (`NotAuthenticated`)
207    /// * `SymbolNotFound`, `BadConid`, `Header` (data-shape problems)
208    /// * `Decode` / `Api` / `Unknown` (semantically broken response)
209    /// * `Other` (unknown — be conservative, don't retry)
210    #[must_use]
211    pub fn is_retryable(&self) -> bool {
212        match self {
213            Self::Http(e) => e.is_timeout() || e.is_connect() || e.is_request(),
214            Self::UpstreamStatus { status, .. } => *status >= 500 || *status == 429,
215            Self::NoSession => true,
216            Self::WsTransport { .. } | Self::WsHandshake { .. } => true,
217            _ => false,
218        }
219    }
220}
221
222// `bezant-api`'s generated client returns `anyhow::Result<T>` from every
223// typed call, so this `From` impl is load-bearing for `?` propagation
224// in `auth.rs` / `helpers.rs`. Tracked for future cleanup: redrive
225// helpers off the generated client's Result shape directly so anyhow
226// can be made optional without breaking internal `?`.
227impl From<anyhow::Error> for Error {
228    fn from(e: anyhow::Error) -> Self {
229        Error::Api(e.into())
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    /// Compile-time assertion that `Error` is `Send + Sync` so it can
238    /// cross task boundaries. Regression guard against future variants
239    /// accidentally embedding non-Send state.
240    #[test]
241    fn error_is_send_sync() {
242        const fn assert<T: Send + Sync>() {}
243        assert::<Error>();
244    }
245
246    #[test]
247    fn is_retryable_flags_transient_errors() {
248        assert!(Error::NoSession.is_retryable());
249        assert!(Error::UpstreamStatus {
250            endpoint: "x",
251            status: 500,
252            body_preview: None
253        }
254        .is_retryable());
255        assert!(Error::UpstreamStatus {
256            endpoint: "x",
257            status: 503,
258            body_preview: None
259        }
260        .is_retryable());
261        assert!(Error::UpstreamStatus {
262            endpoint: "x",
263            status: 429,
264            body_preview: None
265        }
266        .is_retryable());
267    }
268
269    #[test]
270    fn is_retryable_rejects_caller_errors() {
271        assert!(!Error::NotAuthenticated.is_retryable());
272        assert!(!Error::BadRequest("bad".into()).is_retryable());
273        assert!(!Error::MissingQuery { name: "x" }.is_retryable());
274        assert!(!Error::InvalidBaseUrl("nope".into()).is_retryable());
275        assert!(!Error::UrlNotABase {
276            url: "data:".into()
277        }
278        .is_retryable());
279        assert!(!Error::SymbolNotFound {
280            symbol: "ZZZ".into()
281        }
282        .is_retryable());
283        // 4xx upstream errors aren't retryable either — the request is
284        // rejected for input reasons, not transient capacity.
285        assert!(!Error::UpstreamStatus {
286            endpoint: "x",
287            status: 401,
288            body_preview: None
289        }
290        .is_retryable());
291        assert!(!Error::UpstreamStatus {
292            endpoint: "x",
293            status: 404,
294            body_preview: None
295        }
296        .is_retryable());
297        // Catch-all `Other` is conservatively non-retryable — we don't
298        // know what we don't know.
299        assert!(!Error::Other("mystery".into()).is_retryable());
300    }
301
302    #[test]
303    fn display_includes_endpoint_and_status() {
304        let e = Error::UpstreamStatus {
305            endpoint: "iserver/auth/status",
306            status: 503,
307            body_preview: None,
308        };
309        let s = e.to_string();
310        assert!(s.contains("iserver/auth/status"), "got: {s}");
311        assert!(s.contains("503"), "got: {s}");
312    }
313
314    #[test]
315    fn display_symbol_not_found_carries_symbol() {
316        let e = Error::SymbolNotFound {
317            symbol: "PLTRX".into(),
318        };
319        let s = e.to_string();
320        assert!(s.contains("PLTRX"), "got: {s}");
321    }
322}