brokapi_bcs_protocol/error.rs
1//! Errors of the BCS protocol layer.
2//!
3//! [`BcsApiError`] is what every client method returns. The other types in this module describe the
4//! error bodies the broker sends. The connector crate maps [`BcsApiError`] to the broker-independent
5//! error of `brokapi-core`.
6
7use thiserror::Error;
8
9/// The error of every REST call and of the subscription calls of a WebSocket session.
10///
11/// Read the variant to decide what to do:
12///
13/// * [`BcsApiError::ValidationError`] and [`BcsApiError::NotFound`] are answers of the broker. The
14/// request reached it and was refused. Retrying the same request gives the same answer.
15/// * [`BcsApiError::RateLimitExceeded`] means "slow down". The broker limits REST calls per account,
16/// about 10 per second for most services and 3 per second for non-trade operations (broker docs).
17/// * [`BcsApiError::AuthError`] means the refresh token was refused. A new token is needed.
18/// * [`BcsApiError::HttpError`] with no status is a transport failure (connection, timeout). For a
19/// command that creates an order this is an **unknown outcome**: the order may exist. Never repeat
20/// such a command blindly, ask for the order by its client order id first.
21///
22/// # Examples
23///
24/// ```
25/// use brokapi_bcs_protocol::error::BcsApiError;
26///
27/// let err = BcsApiError::NotFound { trace_id: Some("abc".into()) };
28/// assert!(matches!(err, BcsApiError::NotFound { .. }));
29/// // The messages are for logs, not for parsing.
30/// assert!(!err.to_string().is_empty());
31/// ```
32#[derive(Error, Debug)]
33pub enum BcsApiError {
34 /// An HTTP-level failure.
35 ///
36 /// Three sources: a transport failure (`status` is `None`, `message` is the transport text), a
37 /// non-success status with the broker's JSON error body (`message` is the broker's error `type`
38 /// such as `UNAUTHORIZED`, `FORBIDDEN` or `RESOURCE_EXHAUSTED`, and `trace_id` is set), and a
39 /// non-success status with a body that is not that JSON (`message` is the raw body).
40 #[error("HTTP ошибка: {message}")]
41 HttpError {
42 /// HTTP status code, if the broker answered at all.
43 status: Option<u16>,
44 /// The broker's error type, the raw body, or the transport error text.
45 message: String,
46 /// The broker's trace id, to quote to the broker support. `None` for transport failures.
47 trace_id: Option<String>,
48 },
49
50 /// A WebSocket-level failure.
51 ///
52 /// The session raises it when it cannot open a connection. The session logs it and retries by
53 /// itself, so you rarely see it as a return value.
54 #[error("WebSocket ошибка: {message}")]
55 WsError {
56 /// Error code, if the broker gave one.
57 code: Option<String>,
58 /// Description of the failure.
59 message: String,
60 },
61
62 /// The token endpoint refused the refresh token (expired, deleted or of a wrong kind).
63 ///
64 /// `message` is the `error_description` of the token service, or its `error` if there is no
65 /// description. Fix the token. Repeating the call does not help.
66 #[error("Ошибка авторизации: {message}")]
67 AuthError {
68 /// The text from the token service.
69 message: String,
70 },
71
72 /// The broker refused the request body or parameters (`type = VALIDATION_ERROR`).
73 ///
74 /// For an order this is a refusal: nothing was placed. `errors` names the fields. A price outside
75 /// the exchange band is a typical cause (not verified against the live API which field is named).
76 #[error("Ошибка валидации: {errors:?}")]
77 ValidationError {
78 /// The broker's trace id.
79 trace_id: Option<String>,
80 /// The refused fields, possibly empty.
81 errors: Vec<ValidationFieldError>,
82 },
83
84 /// The broker has no such object (`type = NOT_FOUND`), for example an unknown order.
85 #[error("Не найдено: {trace_id:?}")]
86 NotFound {
87 /// The broker's trace id.
88 trace_id: Option<String>,
89 },
90
91 /// A reply or a URL could not be parsed.
92 ///
93 /// The text holds the parser's message. For a reply, it also holds the first 200 bytes of the
94 /// body. The request itself did go through: for an order command this is an **unknown outcome**
95 /// of the order, look it up before you repeat it.
96 #[error("Ошибка парсинга: {0}")]
97 ParseError(String),
98
99 /// HTTP 429 without the broker's JSON error body.
100 ///
101 /// A 429 that comes with the JSON body is [`BcsApiError::HttpError`] with `status = Some(429)`
102 /// and `message = "RESOURCE_EXHAUSTED"`. Handle both, or use the rate limiter in
103 /// [`crate::utils::rate_limiter`] to stay below the limit.
104 #[error("Превышен лимит запросов")]
105 RateLimitExceeded,
106
107 /// A subscription command was refused locally, before anything was sent.
108 ///
109 /// Causes: the session is not a market data session, the limit of 100 subscriptions per session is
110 /// reached, or the session task has ended.
111 #[error("Ошибка подписки: {message}")]
112 SubscriptionError {
113 /// Why the command was refused.
114 message: String,
115 },
116}
117
118/// One refused field in a [`BcsApiError::ValidationError`] (an entry of `errors` in the broker's body).
119///
120/// # Examples
121///
122/// ```
123/// use brokapi_bcs_protocol::error::ValidationFieldError;
124///
125/// let e: ValidationFieldError =
126/// serde_json::from_str(r#"{"type":"INVALID_VALUE","field":"price","payload":{"message":"too low"}}"#)?;
127/// assert_eq!(e.error_type, "INVALID_VALUE");
128/// assert_eq!(e.field.as_deref(), Some("price"));
129/// # Ok::<(), serde_json::Error>(())
130/// ```
131#[derive(Debug, Clone, serde::Deserialize)]
132pub struct ValidationFieldError {
133 /// Kind of the problem, as the broker names it (JSON field `type`).
134 #[serde(rename = "type")]
135 pub error_type: String,
136 /// Name of the request field the problem is about. `None` if the problem is not about one field.
137 #[serde(default)]
138 pub field: Option<String>,
139 /// Extra data of the broker, free-form JSON. The shape depends on `error_type`.
140 #[serde(default)]
141 pub payload: Option<serde_json::Value>,
142}
143
144/// The JSON body the broker sends with a non-success status.
145///
146/// The client reads it to pick the [`BcsApiError`] variant. You see this type only if you parse a raw
147/// reply yourself.
148///
149/// # Examples
150///
151/// ```
152/// use brokapi_bcs_protocol::error::ApiErrorResponse;
153///
154/// let body = r#"{"timestamp":1760860000000,"traceId":"t-1","type":"VALIDATION_ERROR",
155/// "errors":[{"type":"INVALID_VALUE","field":"orderQuantity"}]}"#;
156/// let e: ApiErrorResponse = serde_json::from_str(body)?;
157/// assert_eq!(e.error_type, "VALIDATION_ERROR");
158/// assert_eq!(e.trace_id.as_deref(), Some("t-1"));
159/// assert_eq!(e.errors.len(), 1);
160/// # Ok::<(), serde_json::Error>(())
161/// ```
162#[derive(Debug, serde::Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub struct ApiErrorResponse {
165 /// Time of the error. The docs give an integer and no unit (not verified, looks like epoch
166 /// milliseconds).
167 #[serde(default)]
168 pub timestamp: Option<u64>,
169 /// The broker's trace id of the request, to quote to the broker support.
170 #[serde(default, rename = "traceId")]
171 pub trace_id: Option<String>,
172 /// Class of the error (JSON field `type`). The docs list `VALIDATION_ERROR`, `RESOURCE_EXHAUSTED`,
173 /// `USER_BLOCKED`, `BAD_REQUEST`, `NOT_FOUND`, `UNAUTHORIZED`, `FORBIDDEN`, `CONFLICT`,
174 /// `INTERNAL_SERVER_ERROR`, `SESSION_NOT_FOUND_ERROR`, `SESSION_EXPIRED_ERROR`,
175 /// `SESSION_FAILED_ERROR` and `USER_NOT_FOUND`. Only the first and `NOT_FOUND` get their own
176 /// [`BcsApiError`] variant.
177 #[serde(rename = "type")]
178 pub error_type: String,
179 /// Per-field details, empty when the error is not about fields.
180 #[serde(default)]
181 pub errors: Vec<ValidationFieldError>,
182}
183
184/// The error body of the token service (it is a Keycloak server).
185///
186/// # Examples
187///
188/// ```
189/// use brokapi_bcs_protocol::error::AuthErrorResponse;
190///
191/// let e: AuthErrorResponse =
192/// serde_json::from_str(r#"{"error":"invalid_grant","error_description":"Token is not active"}"#)?;
193/// assert_eq!(e.error, "invalid_grant");
194/// # Ok::<(), serde_json::Error>(())
195/// ```
196#[derive(Debug, serde::Deserialize)]
197pub struct AuthErrorResponse {
198 /// Machine code, for example `invalid_grant`.
199 pub error: String,
200 /// Human text. The client puts it in [`BcsApiError::AuthError`] when it is present.
201 pub error_description: Option<String>,
202}
203
204impl From<reqwest::Error> for BcsApiError {
205 fn from(err: reqwest::Error) -> Self {
206 BcsApiError::HttpError {
207 status: err.status().map(|s| s.as_u16()),
208 message: err.to_string(),
209 trace_id: None,
210 }
211 }
212}
213
214impl From<serde_json::Error> for BcsApiError {
215 fn from(err: serde_json::Error) -> Self {
216 BcsApiError::ParseError(err.to_string())
217 }
218}
219
220impl From<url::ParseError> for BcsApiError {
221 fn from(err: url::ParseError) -> Self {
222 BcsApiError::ParseError(err.to_string())
223 }
224}