Skip to main content

brokapi_tbank/
error.rs

1//! gRPC status -> public connector errors.
2//!
3//! T-Bank error codes (errors.md): `RESOURCE_EXHAUSTED` 800xx, `NOT_FOUND` 500xx (becomes `not_found`), `UNAUTHENTICATED` 40003,
4//! `PERMISSION_DENIED` 4000x, `INVALID_ARGUMENT` 300xx. The broker puts the number in the status
5//! message (and may repeat it at the start of the `message` header), see [`broker_code`].
6//!
7//! `RESOURCE_EXHAUSTED` is not always a rate limit. Only the codes of the table that are about time
8//! (80002 requests a minute, 80003 SMS, 80006 errors a minute) become [`Error::RateLimited`]. The codes
9//! that a repeat cannot fix become [`Error::Rejected`] with a code of their own: 80001
10//! `streams_limit`, 80004 `no_subscriptions`, 80005 `stream_lifetime`, 80007 `stop_orders_limit`.
11//! A `RESOURCE_EXHAUSTED` without a known code stays a rate limit, as before.
12//!
13//! Two functions decide the mapping. [`map_status`] is for reads, [`map_command_status`] is for
14//! commands. The difference matters: a failed read may be repeated, a command whose outcome is
15//! hidden must never be repeated, so it becomes [`Error::Unknown`].
16//!
17//! The error codes above are taken from the broker's documentation and the mock. They are not
18//! verified against the live API.
19//!
20//! # Examples
21//!
22//! ```
23//! use brokapi_core::Error;
24//! use brokapi_tbank::error::{map_command_status, map_status};
25//! use tonic::Status;
26//!
27//! // A read that timed out is a transport error: repeat it later.
28//! assert!(matches!(map_status(&Status::deadline_exceeded("slow")), Error::Transport(_)));
29//! // The same status on a command hides what the broker did.
30//! assert!(matches!(map_command_status(&Status::deadline_exceeded("slow")), Error::Unknown));
31//! // A refusal is a refusal either way.
32//! assert!(matches!(map_command_status(&Status::invalid_argument("30042")), Error::Rejected { .. }));
33//! ```
34
35use std::time::Duration;
36
37use brokapi_core::{ConfigError, Error, TransportError};
38use tonic::{Code, Status};
39
40/// `Error::Rejected` code for the broker's answer 30261: the list of stop orders is too long for one reply.
41pub const STOP_ORDERS_RESPONSE_LIMIT: &str = "stop_orders_response_limit";
42
43fn retry_after(status: &Status) -> Duration {
44    // x-ratelimit-reset: seconds until the counter resets.
45    status
46        .metadata()
47        .get("x-ratelimit-reset")
48        .and_then(|v| v.to_str().ok())
49        .and_then(|v| v.trim().parse::<u64>().ok())
50        .map(Duration::from_secs)
51        .unwrap_or(Duration::from_secs(1))
52}
53
54fn rejected(status: &Status) -> Error {
55    let code = match status.code() {
56        Code::NotFound => brokapi_core::NOT_FOUND.to_string(),
57        other => format!("{other:?}"),
58    };
59    Error::Rejected {
60        code,
61        message: status.message().to_string(),
62    }
63}
64
65/// The five-digit T-Invest error code of a status (`80007`, `30261`), if the broker sent one.
66///
67/// T-Bank puts the number in the status message and the text in the `message` header. The number is
68/// read from the start of either one, so both layouts work.
69///
70/// # Examples
71///
72/// ```
73/// use brokapi_tbank::error::broker_code;
74/// use tonic::Status;
75///
76/// assert_eq!(broker_code(&Status::resource_exhausted("80007")), Some(80007));
77/// assert_eq!(broker_code(&Status::resource_exhausted(" 80007: limit")), Some(80007));
78/// assert_eq!(broker_code(&Status::resource_exhausted("mock failure")), None);
79/// ```
80pub fn broker_code(status: &Status) -> Option<u32> {
81    fn lead(text: &str) -> Option<u32> {
82        let t = text.trim_start();
83        let digits = t.chars().take_while(char::is_ascii_digit).count();
84        if digits != 5 {
85            return None;
86        }
87        t[..5].parse().ok()
88    }
89    lead(status.message()).or_else(|| {
90        status
91            .metadata()
92            .get("message")
93            .and_then(|v| v.to_str().ok())
94            .and_then(lead)
95    })
96}
97
98/// Machine code of the T-Bank refusals that came as `RESOURCE_EXHAUSTED` but are not a rate limit.
99fn exhausted_refusal(code: u32) -> Option<(&'static str, &'static str)> {
100    match code {
101        80001 => Some((
102            "streams_limit",
103            "the limit of open streams is reached: close a stream first, a repeat will not help",
104        )),
105        80004 => Some(("no_subscriptions", "the stream has no active subscriptions")),
106        80005 => Some((
107            "stream_lifetime",
108            "the stream lived too long and was closed: connect again",
109        )),
110        80007 => Some((
111            "stop_orders_limit",
112            "the limit of stop orders for this instrument is reached: cancel one first, a repeat will not help",
113        )),
114        _ => None,
115    }
116}
117
118/// How long to wait after a rate limit with no `x-ratelimit-reset` header. The broker's table says
119/// "try again in a minute" for 80003 (SMS) and counts 80006 (errors) per minute.
120fn exhausted_wait(status: &Status) -> Duration {
121    match broker_code(status) {
122        Some(80003 | 80006) if status.metadata().get("x-ratelimit-reset").is_none() => {
123            Duration::from_secs(60)
124        }
125        _ => retry_after(status),
126    }
127}
128
129/// Status of a read-only call (snapshots, reference data).
130///
131/// - `ResourceExhausted` gives [`Error::RateLimited`], with `retry_after` taken from the
132///   `x-ratelimit-reset` header (seconds), else 1 s (60 s for the codes 80003 and 80006, which the
133///   broker counts per minute). Except the codes that are not a limit of time: 80001, 80004, 80005
134///   and 80007 give [`Error::Rejected`] with the code `streams_limit`, `no_subscriptions`,
135///   `stream_lifetime` or `stop_orders_limit`, because a repeat cannot help.
136/// - `InvalidArgument` with the code 30261 (too many stop orders in the answer) gives
137///   [`Error::Rejected`] with the code `stop_orders_response_limit`.
138/// - `NotFound` gives [`Error::Rejected`] with the code `not_found` ([`brokapi_core::NOT_FOUND`], the
139///   same at every broker).
140/// - `InvalidArgument`, `PermissionDenied`, `FailedPrecondition`, `Unimplemented`,
141///   `AlreadyExists`, `OutOfRange` gives [`Error::Rejected`], with the status code name as `code` (for
142///   example `"InvalidArgument"`) and the status message as `message`.
143/// - `Unauthenticated` gives [`Error::Config`]: the token is wrong or expired, fix it.
144/// - everything else (`Unavailable`, `DeadlineExceeded`, `Internal`, ...) gives [`Error::Transport`].
145///
146/// # Examples
147///
148/// ```
149/// use brokapi_core::Error;
150/// use brokapi_tbank::error::map_status;
151/// use tonic::Status;
152///
153/// match map_status(&Status::not_found("50002")) {
154///     Error::Rejected { code, message } => {
155///         assert_eq!(code, "not_found");
156///         assert_eq!(message, "50002");
157///     }
158///     other => panic!("unexpected {other:?}"),
159/// }
160/// assert!(matches!(map_status(&Status::resource_exhausted("80002")), Error::RateLimited { .. }));
161/// // 80007 is a business refusal, not a limit of time.
162/// match map_status(&Status::resource_exhausted("80007")) {
163///     Error::Rejected { code, .. } => assert_eq!(code, "stop_orders_limit"),
164///     other => panic!("unexpected {other:?}"),
165/// }
166/// assert!(matches!(map_status(&Status::unauthenticated("40003")), Error::Config(_)));
167/// ```
168pub fn map_status(status: &Status) -> Error {
169    match status.code() {
170        Code::ResourceExhausted => {
171            match broker_code(status).and_then(|c| Some((c, exhausted_refusal(c)?))) {
172                Some((num, (code, text))) => Error::Rejected {
173                    code: code.to_string(),
174                    message: format!("{num}: {text}"),
175                },
176                None => Error::RateLimited {
177                    retry_after: exhausted_wait(status),
178                },
179            }
180        }
181        Code::InvalidArgument if broker_code(status) == Some(30261) => Error::Rejected {
182            code: STOP_ORDERS_RESPONSE_LIMIT.to_string(),
183            message:
184                "30261: too many stop orders for one answer: narrow the request with from and to"
185                    .to_string(),
186        },
187        Code::InvalidArgument
188        | Code::NotFound
189        | Code::PermissionDenied
190        | Code::FailedPrecondition
191        | Code::Unimplemented
192        | Code::AlreadyExists
193        | Code::OutOfRange => rejected(status),
194        Code::Unauthenticated => Error::Config(ConfigError::Message(format!(
195            "unauthenticated: {}",
196            status.message()
197        ))),
198        _ => Error::Transport(TransportError::Message(format!(
199            "{:?}: {}",
200            status.code(),
201            status.message()
202        ))),
203    }
204}
205
206/// Status of a command (place / cancel / replace). Only statuses that prove the broker did not act
207/// become `Rejected`; anything that can hide a processed request is `Unknown`.
208///
209/// The statuses that prove the broker did not act (`ResourceExhausted`, `InvalidArgument`,
210/// `NotFound`, `PermissionDenied`, `Unauthenticated`, `FailedPrecondition`, `Unimplemented`,
211/// `AlreadyExists`, `OutOfRange`) are mapped like in [`map_status`]. Every other status
212/// (`DeadlineExceeded`, `Unavailable`, `Internal`, `Aborted`, `Cancelled`, `Unknown`, ...) is
213/// [`Error::Unknown`], because the request may have been processed before the answer was lost.
214/// Do not repeat the command after it: ask the broker for the order first.
215///
216/// # Examples
217///
218/// ```
219/// use brokapi_core::Error;
220/// use brokapi_tbank::error::map_command_status;
221/// use tonic::Status;
222///
223/// assert!(matches!(map_command_status(&Status::unavailable("reset")), Error::Unknown));
224/// assert!(matches!(map_command_status(&Status::not_found("50005")), Error::Rejected { .. }));
225/// ```
226pub fn map_command_status(status: &Status) -> Error {
227    match status.code() {
228        Code::ResourceExhausted
229        | Code::InvalidArgument
230        | Code::NotFound
231        | Code::PermissionDenied
232        | Code::Unauthenticated
233        | Code::FailedPrecondition
234        | Code::Unimplemented
235        | Code::AlreadyExists
236        | Code::OutOfRange => map_status(status),
237        _ => Error::Unknown,
238    }
239}
240
241/// `true` for the `NotFound` status: the broker does not know the order or the instrument.
242///
243/// # Examples
244///
245/// ```
246/// use brokapi_tbank::error::is_not_found;
247/// use tonic::Status;
248///
249/// assert!(is_not_found(&Status::not_found("50005")));
250/// assert!(!is_not_found(&Status::unavailable("x")));
251/// ```
252pub fn is_not_found(status: &Status) -> bool {
253    status.code() == Code::NotFound
254}
255
256/// Builds a [`Error::Transport`] with a text message.
257///
258/// # Examples
259///
260/// ```
261/// use brokapi_core::Error;
262///
263/// assert!(matches!(brokapi_tbank::error::transport("stream closed"), Error::Transport(_)));
264/// ```
265pub fn transport(msg: impl Into<String>) -> Error {
266    Error::Transport(TransportError::Message(msg.into()))
267}
268
269/// Builds a [`Error::Config`] with a text message.
270///
271/// # Examples
272///
273/// ```
274/// use brokapi_core::Error;
275///
276/// assert!(matches!(brokapi_tbank::error::config("no account"), Error::Config(_)));
277/// ```
278pub fn config(msg: impl Into<String>) -> Error {
279    Error::Config(ConfigError::Message(msg.into()))
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn limits_are_rate_limited() {
288        let e = map_status(&Status::resource_exhausted("80002"));
289        assert!(matches!(e, Error::RateLimited { .. }));
290    }
291
292    fn refusal_code(status: &Status) -> String {
293        match map_status(status) {
294            Error::Rejected { code, .. } => code,
295            other => panic!("expected Rejected, got {other:?}"),
296        }
297    }
298
299    #[test]
300    fn exhausted_codes_that_are_not_a_limit_of_time_are_refusals() {
301        // The number can be in the status message or at the start of the `message` header.
302        assert_eq!(
303            refusal_code(&Status::resource_exhausted("80007")),
304            "stop_orders_limit"
305        );
306        assert_eq!(
307            refusal_code(&Status::resource_exhausted("80001")),
308            "streams_limit"
309        );
310        assert_eq!(
311            refusal_code(&Status::resource_exhausted("80004")),
312            "no_subscriptions"
313        );
314        assert_eq!(
315            refusal_code(&Status::resource_exhausted("80005")),
316            "stream_lifetime"
317        );
318        let mut by_header = Status::resource_exhausted("");
319        by_header
320            .metadata_mut()
321            .insert("message", "80007 Stop-orders limit".parse().unwrap());
322        assert_eq!(refusal_code(&by_header), "stop_orders_limit");
323        // A command refused with 80007 is a refusal, the broker did not act.
324        assert!(matches!(
325            map_command_status(&Status::resource_exhausted("80007")),
326            Error::Rejected { .. }
327        ));
328    }
329
330    #[test]
331    fn exhausted_codes_about_time_and_unknown_ones_stay_rate_limits() {
332        for text in ["80002", "80003", "80006", "mock failure", "80099"] {
333            let e = map_status(&Status::resource_exhausted(text));
334            assert!(matches!(e, Error::RateLimited { .. }), "{text}: {e:?}");
335        }
336        let wait = |s: &Status| match map_status(s) {
337            Error::RateLimited { retry_after } => retry_after,
338            other => panic!("{other:?}"),
339        };
340        assert_eq!(
341            wait(&Status::resource_exhausted("80002")),
342            Duration::from_secs(1)
343        );
344        assert_eq!(
345            wait(&Status::resource_exhausted("80003")),
346            Duration::from_secs(60)
347        );
348        assert_eq!(
349            wait(&Status::resource_exhausted("80006")),
350            Duration::from_secs(60)
351        );
352        let mut with_header = Status::resource_exhausted("80006");
353        with_header
354            .metadata_mut()
355            .insert("x-ratelimit-reset", "7".parse().unwrap());
356        assert_eq!(wait(&with_header), Duration::from_secs(7));
357    }
358
359    #[test]
360    fn too_many_stop_orders_in_the_answer_is_a_named_refusal() {
361        assert_eq!(
362            refusal_code(&Status::invalid_argument("30261")),
363            STOP_ORDERS_RESPONSE_LIMIT
364        );
365        // Other InvalidArgument codes keep the status name.
366        assert_eq!(
367            refusal_code(&Status::invalid_argument("30042")),
368            "InvalidArgument"
369        );
370    }
371
372    #[test]
373    fn command_timeout_is_unknown_not_rejected() {
374        assert!(matches!(
375            map_command_status(&Status::deadline_exceeded("t")),
376            Error::Unknown
377        ));
378        assert!(matches!(
379            map_command_status(&Status::unavailable("x")),
380            Error::Unknown
381        ));
382        assert!(matches!(
383            map_command_status(&Status::internal("x")),
384            Error::Unknown
385        ));
386    }
387
388    #[test]
389    fn command_refusal_is_rejected() {
390        let e = map_command_status(&Status::invalid_argument("30016"));
391        assert!(matches!(e, Error::Rejected { .. }));
392    }
393
394    #[test]
395    fn query_failure_is_transport() {
396        assert!(matches!(
397            map_status(&Status::unavailable("x")),
398            Error::Transport(_)
399        ));
400    }
401}