Skip to main content

brokapi_bcs/
error.rs

1//! Maps the errors of the protocol layer ([`BcsApiError`]) to the public [`brokapi_core::Error`].
2//!
3//! The protocol layer first sorts an HTTP answer. A body with `type = "VALIDATION_ERROR"` becomes
4//! `ValidationError`, `type = "NOT_FOUND"` becomes `NotFound`, any other JSON body with a `type`
5//! becomes `HttpError` with that type as the message. A body that is not that JSON becomes
6//! `RateLimitExceeded` for HTTP 429 and `HttpError` with the body text for any other status. This
7//! module then picks the public error.
8//!
9//! There are two mappings, and the difference matters. [`map_err`] is for reads. [`map_command_err`]
10//! and [`map_order_cmd_err`] are for commands that change the account, where "no answer" must never
11//! look like "nothing happened".
12
13use std::time::Duration;
14
15use brokapi_bcs_protocol::error::BcsApiError;
16use brokapi_core::{ConfigError, Error, TransportError};
17
18/// Maps a failed read (or a failed step before a command was sent) to a public error.
19///
20/// | Protocol error | Public error |
21/// |---|---|
22/// | `RateLimitExceeded`, or `HttpError` with status 429 | [`Error::RateLimited`] with `retry_after` of 1 second (the broker does not say how long: the value is a fixed guess) |
23/// | `AuthError` (the token is refused) | [`Error::Config`] with the broker's message: fix the token, a repeat will not help |
24/// | `SubscriptionError` | [`Error::Transport`] with the broker's message |
25/// | `HttpError` (any other status or no status), `WsError`, `ParseError` | [`Error::Transport`] with the message |
26/// | `NotFound` (HTTP 404, `type = "NOT_FOUND"`) | [`Error::Rejected`] with code `not_found` (an unknown instrument or order, as at the other brokers) |
27/// | `ValidationError` (HTTP 400, `type = "VALIDATION_ERROR"`) | [`Error::Rejected`] with code `validation` and the field errors as the message |
28///
29/// All [`Error::Transport`] results are retryable for a read. Do not use this function for a
30/// command: it would turn a lost answer into a plain transport error. Use [`map_command_err`].
31///
32/// # Examples
33///
34/// ```
35/// use brokapi_bcs_protocol::error::BcsApiError;
36/// use brokapi_bcs::error::map_err;
37/// use brokapi_core::Error;
38///
39/// assert!(matches!(map_err(BcsApiError::RateLimitExceeded), Error::RateLimited { .. }));
40/// assert!(map_err(BcsApiError::NotFound { trace_id: None }).is_not_found());
41/// assert!(matches!(map_err(BcsApiError::AuthError { message: "bad token".into() }), Error::Config(_)));
42/// let validation = BcsApiError::ValidationError { trace_id: None, errors: vec![] };
43/// assert!(matches!(map_err(validation), Error::Rejected { .. }));
44/// ```
45pub fn map_err(err: BcsApiError) -> Error {
46    match err {
47        BcsApiError::RateLimitExceeded => Error::RateLimited {
48            retry_after: Duration::from_secs(1),
49        },
50        BcsApiError::AuthError { message } => Error::Config(ConfigError::Message(format!(
51            "authentication refused: {message}"
52        ))),
53        BcsApiError::SubscriptionError { message } => {
54            Error::Transport(TransportError::Message(message))
55        }
56        BcsApiError::HttpError {
57            status: Some(429), ..
58        } => Error::RateLimited {
59            retry_after: Duration::from_secs(1),
60        },
61        BcsApiError::HttpError { message, .. }
62        | BcsApiError::WsError { message, .. }
63        | BcsApiError::ParseError(message) => Error::Transport(TransportError::Message(message)),
64        BcsApiError::NotFound { .. } => {
65            Error::not_found("the broker does not know this order or instrument")
66        }
67        BcsApiError::ValidationError { errors, .. } => Error::Rejected {
68            code: "validation".into(),
69            message: format!("{errors:?}"),
70        },
71    }
72}
73
74/// Maps a failure to build a client from the configuration to [`Error::Config`], with the text of
75/// the original error.
76pub fn map_config_err(err: BcsApiError) -> Error {
77    Error::Config(ConfigError::Message(err.to_string()))
78}
79
80/// Maps a failure to encode a client order id to [`Error::Config`], with the text of the original
81/// error.
82pub fn map_cid_err(err: brokapi_core::CidError) -> Error {
83    Error::Config(ConfigError::Message(err.to_string()))
84}
85
86/// A command that failed: only answers that prove the broker did not act become definite errors.
87/// No answer, a break, HTTP 5xx or an unreadable answer may have reached the broker: `Error::Unknown`,
88/// never retried by the library.
89///
90/// | Protocol error | Public error |
91/// |---|---|
92/// | `HttpError` without a status (no answer, a break, a timeout) or with status 500 and above | [`Error::Unknown`] |
93/// | `ParseError` (the answer cannot be read), `WsError` | [`Error::Unknown`] |
94/// | `HttpError` with status 400 to 499, except 429 | [`Error::Rejected`] with code `http_<status>` and the broker's message |
95/// | everything else (429, `AuthError`, `NotFound`, `ValidationError`, ...) | as in [`map_err`] |
96///
97/// Note that `ValidationError` (the usual way BCS refuses an order) is [`Error::Rejected`] with code
98/// `validation`, and that `NotFound` is [`Error::Rejected`] with code `not_found`, as in [`map_err`].
99///
100/// # Examples
101///
102/// ```
103/// use brokapi_bcs_protocol::error::BcsApiError;
104/// use brokapi_bcs::error::map_command_err;
105/// use brokapi_core::Error;
106///
107/// // No answer at all: the order may exist.
108/// let lost = BcsApiError::HttpError { status: None, message: "timeout".into(), trace_id: None };
109/// assert!(matches!(map_command_err(lost), Error::Unknown));
110/// // The broker answered 503: it may have acted.
111/// let busy = BcsApiError::HttpError { status: Some(503), message: "busy".into(), trace_id: None };
112/// assert!(matches!(map_command_err(busy), Error::Unknown));
113/// // The broker answered 403: it did nothing.
114/// let denied = BcsApiError::HttpError { status: Some(403), message: "no".into(), trace_id: None };
115/// match map_command_err(denied) {
116///     Error::Rejected { code, .. } => assert_eq!(code, "http_403"),
117///     other => panic!("{other:?}"),
118/// }
119/// ```
120pub fn map_command_err(err: BcsApiError) -> Error {
121    match &err {
122        BcsApiError::HttpError { status, .. } if status.is_none_or(|s| s >= 500) => Error::Unknown,
123        BcsApiError::ParseError(_) | BcsApiError::WsError { .. } => Error::Unknown,
124        // The broker answered and said no (400, 403, 409, 422 ...): nothing was done.
125        BcsApiError::HttpError {
126            status: Some(s),
127            message,
128            ..
129        } if (400..500).contains(s) && *s != 429 => Error::Rejected {
130            code: format!("http_{s}"),
131            message: message.clone(),
132        },
133        _ => map_err(err),
134    }
135}
136
137/// [`map_err`] for a read of one order. Since "not found" is `Rejected` with code `not_found` in
138/// [`map_err`] already, this is the same mapping, kept for the code that calls it.
139///
140/// Used by `order_state`. The way out of an `Unknown` is to ask again until the
141/// order is found or the answer stays `not_found` long enough ([`Error::Unknown`]).
142///
143/// # Examples
144///
145/// ```
146/// use brokapi_bcs_protocol::error::BcsApiError;
147/// use brokapi_bcs::error::map_order_read_err;
148/// use brokapi_core::Error;
149///
150/// match map_order_read_err(BcsApiError::NotFound { trace_id: None }) {
151///     Error::Rejected { code, .. } => assert_eq!(code, "not_found"),
152///     other => panic!("{other:?}"),
153/// }
154/// assert!(matches!(map_order_read_err(BcsApiError::RateLimitExceeded), Error::RateLimited { .. }));
155/// ```
156pub fn map_order_read_err(err: BcsApiError) -> Error {
157    map_err(err)
158}
159/// [`map_command_err`] for commands on an existing order. "Not found" is a definite refusal
160/// (`Rejected`, code `not_found`) in [`map_command_err`] already; this is the same mapping, kept for the
161/// code that calls it.
162///
163/// Used by `cancel`, `replace` and `cancel_stop`.
164///
165/// # Examples
166///
167/// ```
168/// use brokapi_bcs_protocol::error::BcsApiError;
169/// use brokapi_bcs::error::map_order_cmd_err;
170/// use brokapi_core::Error;
171///
172/// match map_order_cmd_err(BcsApiError::NotFound { trace_id: None }) {
173///     Error::Rejected { code, .. } => assert_eq!(code, "not_found"),
174///     other => panic!("{other:?}"),
175/// }
176/// ```
177pub fn map_order_cmd_err(err: BcsApiError) -> Error {
178    map_command_err(err)
179}