alpaca_sdk/error.rs
1//! Error types for every fallible operation in this crate.
2//!
3//! Alpaca reports a failure as a JSON body carrying `code` and `message`, but
4//! not every failure reaches the caller that way: a gateway can answer 502 with
5//! HTML. The body is parsed once here, at construction, and a non-JSON body
6//! degrades to [`ApiError::body`] with `code` left as `None`.
7//!
8//! Parsing once matters for the degenerate case: a gateway's HTML is not JSON,
9//! and an error type that fails while reporting an error is the worst place to
10//! fail.
11
12use std::fmt;
13
14/// The result type returned by every fallible operation in this crate.
15pub type Result<T, E = Error> = std::result::Result<T, E>;
16
17/// Anything that can go wrong while talking to Alpaca.
18#[derive(Debug, thiserror::Error)]
19#[non_exhaustive]
20pub enum Error {
21 /// Alpaca returned a non-success HTTP status.
22 #[error(transparent)]
23 Api(#[from] ApiError),
24
25 /// The request never completed: DNS, TLS, connection, or timeout failure.
26 #[error("http transport error")]
27 Transport(#[source] TransportError),
28
29 /// A response arrived but could not be deserialized into the expected type.
30 ///
31 /// `body` carries the raw payload so the mismatch can be diagnosed without
32 /// re-issuing the request.
33 #[error("failed to decode response from {path}: {source}")]
34 Decode {
35 /// The request path whose response failed to decode.
36 path: String,
37 /// The raw response body, truncated to a reasonable length.
38 body: String,
39 /// The underlying deserialization error.
40 #[source]
41 source: serde_json::Error,
42 },
43
44 /// The supplied credentials are structurally invalid.
45 #[error("invalid credentials: {0}")]
46 Credentials(String),
47
48 /// A request was rejected locally, before any network call was made.
49 #[error("invalid request: {0}")]
50 InvalidRequest(String),
51
52 /// A stream failed: the connection, the handshake, or a frame carried on it.
53 ///
54 /// This covers both stream transports — the msgpack and JSON websockets and
55 /// the SSE event streams — because the failures are the same shape and a
56 /// caller matching on them is not usually asking which one broke.
57 ///
58 /// The boundary against [`InvalidRequest`](Self::InvalidRequest) is *where*
59 /// the failure happened, not what caused it. Anything the crate determines
60 /// locally before a socket is opened — an empty subscription set, a
61 /// non-positive timeout, an unrecognised feed — stays `InvalidRequest`.
62 /// Everything that fails on the wire is this.
63 ///
64 /// A stream error is not necessarily the end of the stream. The market data
65 /// stream reconnects from most of them and stops on the two that never
66 /// resolve by retrying — a subscription the account is not entitled to, and
67 /// credentials the server rejects.
68 #[error("stream error: {0}")]
69 Stream(String),
70
71 /// The configured base URL could not be joined with the request path.
72 #[error("invalid url: {0}")]
73 InvalidUrl(String),
74
75 /// Every retry attempt returned a retryable status.
76 ///
77 /// Carries the final [`ApiError`] so the caller still sees what Alpaca said.
78 #[error("giving up after {attempts} attempts")]
79 RetriesExhausted {
80 /// Total number of requests issued, including the first.
81 attempts: u32,
82 /// The error returned by the final attempt.
83 #[source]
84 last: ApiError,
85 },
86}
87
88impl Error {
89 /// A response that parsed as JSON but is not the document the route returns.
90 ///
91 /// The distinction from [`Error::InvalidRequest`] is *where* the failure is.
92 /// Nothing about the request was wrong and the response arrived intact; it
93 /// simply does not have the shape this crate expects — a market data payload
94 /// under no known key, or a `latest` response missing the very field it is
95 /// named for. That is a decode failure, and reporting it as an invalid
96 /// request sends the caller to look at their own parameters.
97 ///
98 /// `serde_json` never saw these, because they are found after a successful
99 /// parse, so the source is synthesized to carry the reason.
100 ///
101 /// Gated on `data`: the market data payloads are the only shapes checked
102 /// this way, and an ungated helper is dead code in a `trading`-only build.
103 #[cfg(feature = "data")]
104 pub(crate) fn decode_shape(path: &str, body: &str, reason: impl fmt::Display) -> Self {
105 use serde::de::Error as _;
106 Self::Decode {
107 path: path.to_owned(),
108 body: crate::rest::truncate(body),
109 source: serde_json::Error::custom(reason),
110 }
111 }
112
113 /// The HTTP status code, when the failure came from a response.
114 #[must_use]
115 pub fn status(&self) -> Option<u16> {
116 match self {
117 Self::Api(e) => Some(e.status),
118 Self::RetriesExhausted { last, .. } => Some(last.status),
119 _ => None,
120 }
121 }
122
123 /// Whether this failure is transient — worth trying again *if the request
124 /// is safe to repeat*.
125 ///
126 /// Named for what it can actually tell you. The obvious reading of a method
127 /// called `is_retryable` is "it is safe to send this again", and that is not
128 /// something an error alone can answer — which is why it is not called that.
129 ///
130 /// **Transient does not mean safe to replay.** A timed-out `POST /v2/orders`
131 /// and a 504 on the same request are both transient, and both are
132 /// indistinguishable from an order Alpaca accepted whose response was lost;
133 /// replaying either places a second order. The one transport failure that
134 /// *is* safe is a connect error, because nothing was sent.
135 ///
136 /// The safety question belongs to the request method, and the client answers
137 /// it internally: `GET`, `PUT` and `DELETE` are replayed, `POST` and `PATCH`
138 /// are not, except on a 429. If you are deciding whether to re-issue a call
139 /// yourself, that is the rule to apply — this predicate only tells you
140 /// whether trying again could plausibly work.
141 #[must_use]
142 pub fn is_transient(&self) -> bool {
143 match self {
144 Self::Api(e) => e.is_retried_by_default(),
145 Self::Transport(e) => e.is_timeout() || e.is_connect(),
146 _ => false,
147 }
148 }
149}
150
151impl Error {
152 /// Wraps a transport failure.
153 ///
154 /// An inherent constructor rather than `impl From<reqwest::Error> for
155 /// Error`, and for the same reason [`TransportError`] exists at all: a
156 /// `From` impl names `reqwest::Error` in this crate's *public* API, so a
157 /// `0.13 → 0.14` bump would still be a breaking change here — which is
158 /// exactly what the newtype is meant to prevent. Keeping the conversion
159 /// crate-private finishes the job.
160 pub(crate) fn transport(error: reqwest::Error) -> Self {
161 Self::Transport(TransportError(error))
162 }
163}
164
165/// An HTTP transport failure.
166///
167/// An opaque wrapper rather than `reqwest::Error` itself, and that is the whole
168/// point of it. reqwest is a `0.x` crate, so under cargo's rules `0.13 → 0.14`
169/// is a breaking change — and a `reqwest::Error` in this crate's public API
170/// would make every such bump a breaking change *here*, for a dependency that
171/// has nothing to do with Alpaca. Callers on `0.1.x` would be pinned to
172/// reqwest 0.13 for the life of the line.
173///
174/// This crate already re-exports `polars` and [`crate::rust_decimal`]
175/// with two paragraphs each explaining why
176/// a version-skewed type is worth avoiding. reqwest got neither; now it does not
177/// need one.
178///
179/// The `reqwest::Error` is deliberately **not** reachable — an accessor would
180/// put the type back in the public API and undo the point of the wrapper. What a
181/// caller actually asks of a transport error is answered by the predicates
182/// below, and [`std::error::Error::source`] continues the chain past this layer
183/// into reqwest's own cause (the `hyper` or TLS error underneath), which is the
184/// part that carries information this type does not already forward.
185///
186/// If you need a predicate that is not here, open an issue rather than reaching
187/// through: adding one is cheap and keeps the dependency contained.
188#[derive(Debug)]
189pub struct TransportError(reqwest::Error);
190
191impl TransportError {
192 /// Whether the failure was a timeout.
193 ///
194 /// A timeout says nothing about whether the server acted on the request:
195 /// see [`Error::is_transient`] before using this to decide on a retry.
196 #[must_use]
197 pub fn is_timeout(&self) -> bool {
198 self.0.is_timeout()
199 }
200
201 /// Whether the connection was never established.
202 ///
203 /// The one transport failure that guarantees the request was not processed.
204 #[must_use]
205 pub fn is_connect(&self) -> bool {
206 self.0.is_connect()
207 }
208
209 /// Whether the failure happened while reading or writing the body.
210 #[must_use]
211 pub fn is_body(&self) -> bool {
212 self.0.is_body()
213 }
214
215 /// Whether the failure was in decoding the response.
216 #[must_use]
217 pub fn is_decode(&self) -> bool {
218 self.0.is_decode()
219 }
220
221 /// The HTTP status, when the failure carried one.
222 #[must_use]
223 pub fn status(&self) -> Option<u16> {
224 self.0.status().map(|status| status.as_u16())
225 }
226
227 /// The URL the request was for, if reqwest recorded one.
228 #[must_use]
229 pub fn url(&self) -> Option<&str> {
230 self.0.url().map(url::Url::as_str)
231 }
232}
233
234impl fmt::Display for TransportError {
235 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236 self.0.fmt(f)
237 }
238}
239
240impl std::error::Error for TransportError {
241 /// The inner error's *cause*, not the inner error itself.
242 ///
243 /// `Display` already delegates to `reqwest::Error`, so returning it here as
244 /// well would print the same sentence twice in any formatter that walks the
245 /// chain — which is most of them. Skipping a link that carries no new text
246 /// keeps the chain informative.
247 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
248 std::error::Error::source(&self.0)
249 }
250}
251
252/// A non-success HTTP response from Alpaca.
253///
254/// Every field is public to read. Building one goes through
255/// [`from_body`](Self::from_body), which is also how the transport builds the
256/// ones a caller receives.
257#[derive(Debug, Clone, PartialEq, Eq)]
258#[non_exhaustive]
259pub struct ApiError {
260 /// The HTTP status code.
261 pub status: u16,
262 /// Alpaca's numeric error code, when the body was JSON carrying one.
263 pub code: Option<i64>,
264 /// The human-readable message, falling back to the raw body.
265 pub message: String,
266 /// The unparsed response body.
267 pub body: String,
268 /// The request path that produced this error.
269 pub path: String,
270}
271
272impl ApiError {
273 /// Builds an `ApiError` from a status, a path, and the raw response body.
274 ///
275 /// This is the only way to construct one, here and in a caller's own code:
276 /// the struct is `#[non_exhaustive]`, so a field-by-field literal is not
277 /// available outside the crate. That is deliberate rather than an oversight
278 /// — [`code`](Self::code) and [`message`](Self::message) are *read out of*
279 /// [`body`](Self::body), and a constructor taking all three separately would
280 /// let a caller build an error whose fields contradict each other, which is
281 /// a state no response can produce.
282 ///
283 /// It is public so a caller can build one to test their own error handling
284 /// against, and what they get behaves exactly like a real failure — the same
285 /// parse, including the degradation below:
286 ///
287 /// ```
288 /// use alpaca_sdk::ApiError;
289 ///
290 /// let error = ApiError::from_body(
291 /// 403,
292 /// "/v2/orders",
293 /// r#"{"code":40310000,"message":"insufficient buying power"}"#,
294 /// );
295 ///
296 /// assert_eq!(error.code, Some(40_310_000));
297 /// assert_eq!(error.message, "insufficient buying power");
298 /// assert!(!error.is_retried_by_default());
299 /// ```
300 ///
301 /// A body that is not the JSON object Alpaca normally sends — a gateway's
302 /// HTML, say — leaves `code` as `None` and becomes the message verbatim,
303 /// rather than failing. An error type that errors while reporting an error
304 /// is the worst place to be strict.
305 ///
306 /// ```
307 /// # use alpaca_sdk::ApiError;
308 /// let error = ApiError::from_body(502, "/v2/account", "<html>bad gateway</html>");
309 ///
310 /// assert_eq!(error.code, None);
311 /// assert_eq!(error.message, "<html>bad gateway</html>");
312 /// ```
313 #[must_use]
314 pub fn from_body(status: u16, path: impl Into<String>, body: impl Into<String>) -> Self {
315 #[derive(serde::Deserialize)]
316 struct Payload {
317 code: Option<i64>,
318 message: Option<String>,
319 }
320
321 let body = body.into();
322 let parsed = serde_json::from_str::<Payload>(&body).ok();
323 let (code, message) = match parsed {
324 Some(p) => (p.code, p.message),
325 None => (None, None),
326 };
327
328 Self {
329 status,
330 code,
331 message: message.unwrap_or_else(|| body.clone()),
332 body,
333 path: path.into(),
334 }
335 }
336
337 /// Whether this status is in the crate's **default** retry set.
338 ///
339 /// The default set, not the policy the client that produced this error was
340 /// built with — an [`ApiError`] does not carry one. A client configured with
341 /// `RetryConfig::status_codes([500, 502])` gets `false` here for the 500 it
342 /// actually retried, and `true` for the 429 it did not. The name says
343 /// "by default" so that gap is visible at the call site rather than
344 /// surprising.
345 #[must_use]
346 pub fn is_retried_by_default(&self) -> bool {
347 crate::config::DEFAULT_RETRY_STATUS_CODES.contains(&self.status)
348 }
349}
350
351impl fmt::Display for ApiError {
352 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
353 write!(f, "alpaca api error {} on {}", self.status, self.path)?;
354 if let Some(code) = self.code {
355 write!(f, " (code {code})")?;
356 }
357 write!(f, ": {}", self.message)
358 }
359}
360
361impl std::error::Error for ApiError {}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 #[test]
368 fn extracts_code_and_message_from_json_body() {
369 let err = ApiError::from_body(
370 403,
371 "/v2/orders",
372 r#"{"code":40310000,"message":"insufficient buying power"}"#.to_owned(),
373 );
374
375 assert_eq!(err.status, 403);
376 assert_eq!(err.code, Some(40_310_000));
377 assert_eq!(err.message, "insufficient buying power");
378 }
379
380 #[test]
381 fn non_json_body_degrades_instead_of_panicking() {
382 // A 502 from a gateway is HTML, and this is the path where an error
383 // type must not itself error.
384 let err = ApiError::from_body(502, "/v2/account", "<html>bad gateway</html>".to_owned());
385
386 assert_eq!(err.code, None);
387 assert_eq!(err.message, "<html>bad gateway</html>");
388 assert_eq!(err.body, "<html>bad gateway</html>");
389 }
390
391 #[test]
392 fn only_429_and_504_are_retryable() {
393 for status in [429, 504] {
394 assert!(
395 ApiError::from_body(status, "/v2/account", String::new()).is_retried_by_default()
396 );
397 }
398 for status in [400, 401, 403, 404, 500] {
399 assert!(
400 !ApiError::from_body(status, "/v2/account", String::new()).is_retried_by_default()
401 );
402 }
403 }
404}