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}