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}