Skip to main content

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}