Skip to main content

nautilus_betfair/http/
error.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Betfair HTTP client error types.
17
18use std::fmt::Display;
19
20/// Represents HTTP client errors for the Betfair adapter.
21#[derive(Debug, Clone)]
22pub enum BetfairHttpError {
23    /// Missing API credentials.
24    MissingCredentials,
25    /// Login failed with a non-success status.
26    LoginFailed { status: String },
27    /// Betfair JSON-RPC error with its optional API exception details.
28    BetfairError {
29        code: i64,
30        message: String,
31        api_error_code: Option<String>,
32        api_error_details: Option<String>,
33    },
34    /// JSON serialization/deserialization error.
35    JsonError(String),
36    /// Malformed JSON-RPC response received after dispatch.
37    ResponseError(String),
38    /// Network-related error.
39    NetworkError(String),
40    /// Invalid client configuration.
41    InvalidConfiguration(String),
42    /// Request timeout.
43    Timeout(String),
44    /// Request canceled.
45    Canceled(String),
46    /// Unexpected HTTP status.
47    UnexpectedStatus { status: u16, body: String },
48    /// A later retry failed after an earlier attempt had an unknown outcome.
49    OrderRequestAmbiguous(String),
50}
51
52impl Display for BetfairHttpError {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            Self::MissingCredentials => write!(f, "Missing API credentials"),
56            Self::LoginFailed { status } => write!(f, "Login failed: {status}"),
57            Self::BetfairError {
58                code,
59                message,
60                api_error_code,
61                api_error_details,
62            } => match (
63                api_error_code.as_deref(),
64                api_error_details
65                    .as_deref()
66                    .filter(|details| !details.is_empty()),
67            ) {
68                (Some(error_code), Some(details)) => {
69                    write!(
70                        f,
71                        "Betfair error {code}: {message} ({error_code}: {details})"
72                    )
73                }
74                (Some(error_code), None) => {
75                    write!(f, "Betfair error {code}: {message} ({error_code})")
76                }
77                (None, _) => write!(f, "Betfair error {code}: {message}"),
78            },
79            Self::JsonError(msg) => write!(f, "JSON error: {msg}"),
80            Self::ResponseError(msg) => write!(f, "Response error: {msg}"),
81            Self::NetworkError(msg) => write!(f, "Network error: {msg}"),
82            Self::InvalidConfiguration(msg) => write!(f, "Invalid configuration: {msg}"),
83            Self::Timeout(msg) => write!(f, "Timeout: {msg}"),
84            Self::Canceled(msg) => write!(f, "Canceled: {msg}"),
85            Self::UnexpectedStatus { status, body } => {
86                write!(f, "Unexpected status {status}: {body}")
87            }
88            Self::OrderRequestAmbiguous(msg) => write!(f, "Ambiguous order request: {msg}"),
89        }
90    }
91}
92
93impl std::error::Error for BetfairHttpError {}
94
95impl From<serde_json::Error> for BetfairHttpError {
96    fn from(error: serde_json::Error) -> Self {
97        Self::JsonError(error.to_string())
98    }
99}
100
101impl From<anyhow::Error> for BetfairHttpError {
102    fn from(error: anyhow::Error) -> Self {
103        Self::NetworkError(error.to_string())
104    }
105}
106
107impl BetfairHttpError {
108    /// Returns whether this error is retryable.
109    #[must_use]
110    pub fn is_retryable(&self) -> bool {
111        match self {
112            Self::NetworkError(_) | Self::Timeout(_) => true,
113            Self::UnexpectedStatus { status, .. } => *status >= 500 || *status == 429,
114            Self::BetfairError {
115                code,
116                api_error_code,
117                ..
118            } => api_error_code.as_deref().map_or_else(
119                || is_retryable_error_code(*code),
120                is_retryable_api_error_code,
121            ),
122            _ => false,
123        }
124    }
125
126    /// Returns whether an order request can be retried with the same `customerRef`.
127    #[must_use]
128    pub fn is_order_retryable(&self) -> bool {
129        match self {
130            Self::NetworkError(_) | Self::Timeout(_) | Self::ResponseError(_) => true,
131            Self::UnexpectedStatus { status, .. } => *status >= 500 || *status == 429,
132            Self::BetfairError { api_error_code, .. } => api_error_code
133                .as_deref()
134                .is_some_and(is_order_retryable_api_error_code),
135            _ => false,
136        }
137    }
138
139    /// Returns whether this is a login/auth rejection from the Identity API.
140    ///
141    /// `keep_alive` returns this when the session is expired or unrecognised.
142    /// Transient errors (network, timeout) return different variants.
143    #[must_use]
144    pub fn is_login_failed(&self) -> bool {
145        matches!(self, Self::LoginFailed { .. })
146    }
147
148    /// Returns whether this error is a session expiry that should trigger reconnection.
149    ///
150    /// Session errors (`NO_SESSION`, `INVALID_SESSION_INFORMATION`) occur every
151    /// 12-24 hours and are resolved by re-authenticating.
152    #[must_use]
153    pub fn is_session_error(&self) -> bool {
154        match self {
155            Self::BetfairError { api_error_code, .. } => matches!(
156                api_error_code.as_deref(),
157                Some("NO_SESSION" | "INVALID_SESSION_INFORMATION")
158            ),
159            _ => false,
160        }
161    }
162
163    /// Returns whether this error is a rate limit (`TOO_MANY_REQUESTS`) error.
164    #[must_use]
165    pub fn is_rate_limit_error(&self) -> bool {
166        match self {
167            Self::BetfairError { api_error_code, .. } => {
168                api_error_code.as_deref() == Some("TOO_MANY_REQUESTS")
169            }
170            Self::UnexpectedStatus { status, .. } => *status == 429,
171            _ => false,
172        }
173    }
174
175    /// Returns whether this error leaves an order request in an ambiguous state.
176    ///
177    /// When true, the request may have been processed by Betfair despite the
178    /// error. Callers must NOT emit `OrderRejected` for ambiguous errors
179    /// because the order may be live on the exchange. The OCM stream will
180    /// reconcile the order via its `customerOrderRef`.
181    #[must_use]
182    pub fn is_order_ambiguous(&self) -> bool {
183        match self {
184            Self::NetworkError(_)
185            | Self::Timeout(_)
186            | Self::Canceled(_)
187            | Self::ResponseError(_)
188            | Self::OrderRequestAmbiguous(_) => true,
189            Self::UnexpectedStatus { status, .. } => *status >= 500,
190            Self::BetfairError {
191                code,
192                api_error_code,
193                ..
194            } => match api_error_code.as_deref() {
195                Some(api_error_code) => {
196                    matches!(api_error_code, "TIMEOUT_ERROR" | "UNEXPECTED_ERROR")
197                        || !is_known_api_error_code(api_error_code)
198                }
199                None => *code == -32603 || (-32099..=-32000).contains(code),
200            },
201            _ => false,
202        }
203    }
204
205    /// Returns whether this error leaves order placement in an ambiguous state.
206    #[must_use]
207    pub fn is_order_placement_ambiguous(&self) -> bool {
208        self.is_order_ambiguous()
209    }
210}
211
212fn is_retryable_api_error_code(code: &str) -> bool {
213    is_order_retryable_api_error_code(code) || code == "TIMEOUT_ERROR"
214}
215
216fn is_order_retryable_api_error_code(code: &str) -> bool {
217    matches!(
218        code,
219        "TOO_MANY_REQUESTS" | "SERVICE_BUSY" | "UNEXPECTED_ERROR"
220    )
221}
222
223fn is_known_api_error_code(code: &str) -> bool {
224    matches!(
225        code,
226        "TOO_MUCH_DATA"
227            | "INVALID_INPUT_DATA"
228            | "INVALID_SESSION_INFORMATION"
229            | "NO_APP_KEY"
230            | "NO_SESSION"
231            | "UNEXPECTED_ERROR"
232            | "INVALID_APP_KEY"
233            | "TOO_MANY_REQUESTS"
234            | "SERVICE_BUSY"
235            | "TIMEOUT_ERROR"
236            | "REQUEST_SIZE_EXCEEDS_LIMIT"
237            | "ACCESS_DENIED"
238    )
239}
240
241/// Returns whether a Betfair JSON-RPC error code is retryable.
242///
243/// Retryable codes are transient server-side errors. Permanent errors
244/// (invalid input, insufficient funds, etc.) should not be retried.
245fn is_retryable_error_code(code: i64) -> bool {
246    // -32099 is an unexpected internal server error,
247    // and -32700 is a potentially transient JSON parse error.
248    matches!(code, -32099 | -32700)
249}
250
251#[cfg(test)]
252mod tests {
253    use rstest::rstest;
254
255    use super::*;
256
257    fn betfair_error(code: i64, message: &str, api_error_code: Option<&str>) -> BetfairHttpError {
258        BetfairHttpError::BetfairError {
259            code,
260            message: message.to_string(),
261            api_error_code: api_error_code.map(str::to_string),
262            api_error_details: None,
263        }
264    }
265
266    #[rstest]
267    fn test_display_missing_credentials() {
268        let err = BetfairHttpError::MissingCredentials;
269        assert_eq!(err.to_string(), "Missing API credentials");
270    }
271
272    #[rstest]
273    fn test_display_login_failed() {
274        let err = BetfairHttpError::LoginFailed {
275            status: "CERT_AUTH_REQUIRED".to_string(),
276        };
277        assert_eq!(err.to_string(), "Login failed: CERT_AUTH_REQUIRED");
278    }
279
280    #[rstest]
281    fn test_display_betfair_error() {
282        let err = BetfairHttpError::BetfairError {
283            code: -32600,
284            message: "Invalid request".to_string(),
285            api_error_code: None,
286            api_error_details: None,
287        };
288        assert_eq!(err.to_string(), "Betfair error -32600: Invalid request");
289    }
290
291    #[rstest]
292    fn test_display_betfair_api_error() {
293        let err = BetfairHttpError::BetfairError {
294            code: -32099,
295            message: "ANGX-0001".to_string(),
296            api_error_code: Some("TOO_MUCH_DATA".to_string()),
297            api_error_details: Some("MaxResults must be less than or equal to 1000".to_string()),
298        };
299        assert_eq!(
300            err.to_string(),
301            "Betfair error -32099: ANGX-0001 (TOO_MUCH_DATA: MaxResults must be less than or equal to 1000)",
302        );
303    }
304
305    #[rstest]
306    fn test_display_unexpected_status() {
307        let err = BetfairHttpError::UnexpectedStatus {
308            status: 403,
309            body: "Forbidden".to_string(),
310        };
311        assert_eq!(err.to_string(), "Unexpected status 403: Forbidden");
312    }
313
314    #[rstest]
315    fn test_display_invalid_configuration() {
316        let err = BetfairHttpError::InvalidConfiguration("bad rate".to_string());
317        assert_eq!(err.to_string(), "Invalid configuration: bad rate");
318    }
319
320    #[rstest]
321    #[case(BetfairHttpError::NetworkError("timeout".to_string()), true)]
322    #[case(BetfairHttpError::Timeout("read".to_string()), true)]
323    #[case(BetfairHttpError::UnexpectedStatus { status: 500, body: String::new() }, true)]
324    #[case(BetfairHttpError::UnexpectedStatus { status: 429, body: String::new() }, true)]
325    #[case(BetfairHttpError::UnexpectedStatus { status: 403, body: String::new() }, false)]
326    #[case(BetfairHttpError::MissingCredentials, false)]
327    #[case(BetfairHttpError::LoginFailed { status: "FAIL".to_string() }, false)]
328    #[case(BetfairHttpError::JsonError("bad".to_string()), false)]
329    fn test_is_retryable(#[case] error: BetfairHttpError, #[case] expected: bool) {
330        assert_eq!(error.is_retryable(), expected);
331    }
332
333    #[rstest]
334    #[case(Some("TOO_MANY_REQUESTS"), true, true, false)]
335    #[case(Some("SERVICE_BUSY"), true, true, false)]
336    #[case(Some("UNEXPECTED_ERROR"), true, true, true)]
337    #[case(Some("TIMEOUT_ERROR"), true, false, true)]
338    #[case(Some("INVALID_INPUT_DATA"), false, false, false)]
339    #[case(Some("FUTURE_ERROR"), false, false, true)]
340    #[case(None, true, false, true)]
341    fn test_api_error_retry_matrix(
342        #[case] api_error_code: Option<&str>,
343        #[case] retryable: bool,
344        #[case] order_retryable: bool,
345        #[case] order_ambiguous: bool,
346    ) {
347        let error = betfair_error(-32099, "ANGX-0001", api_error_code);
348
349        assert_eq!(error.is_retryable(), retryable);
350        assert_eq!(error.is_order_retryable(), order_retryable);
351        assert_eq!(error.is_order_ambiguous(), order_ambiguous);
352    }
353
354    #[rstest]
355    fn test_from_serde_error() {
356        let json_err = serde_json::from_str::<String>("not json").unwrap_err();
357        let err: BetfairHttpError = json_err.into();
358        assert!(matches!(err, BetfairHttpError::JsonError(_)));
359    }
360
361    #[rstest]
362    fn test_from_anyhow_error() {
363        let anyhow_err = anyhow::anyhow!("network failure");
364        let err: BetfairHttpError = anyhow_err.into();
365        assert!(matches!(err, BetfairHttpError::NetworkError(_)));
366    }
367
368    #[rstest]
369    #[case(BetfairHttpError::NetworkError("connection reset".to_string()), true)]
370    #[case(BetfairHttpError::Timeout("read".to_string()), true)]
371    #[case(BetfairHttpError::UnexpectedStatus { status: 502, body: "error code: 502".to_string() }, true)]
372    #[case(BetfairHttpError::UnexpectedStatus { status: 500, body: String::new() }, true)]
373    #[case(BetfairHttpError::UnexpectedStatus { status: 429, body: String::new() }, false)]
374    #[case(BetfairHttpError::UnexpectedStatus { status: 403, body: String::new() }, false)]
375    #[case(betfair_error(-32600, "Invalid", None), false)]
376    #[case(betfair_error(32603, "Internal error", None), false)]
377    #[case(betfair_error(32000, "Invalid positive code", None), false)]
378    #[case(betfair_error(-32099, "ANGX-UNKNOWN", None), true)]
379    #[case(betfair_error(-32099, "ANGX-UNKNOWN", Some("FUTURE_ERROR")), true)]
380    #[case(betfair_error(-32099, "ANGX-0001", Some("INVALID_INPUT_DATA")), false)]
381    #[case(BetfairHttpError::JsonError("bad".to_string()), false)]
382    #[case(BetfairHttpError::MissingCredentials, false)]
383    #[case(BetfairHttpError::ResponseError("truncated".to_string()), true)]
384    #[case(BetfairHttpError::Canceled("shutdown".to_string()), true)]
385    #[case(betfair_error(-32099, "ANGX-0004", Some("TIMEOUT_ERROR")), true)]
386    #[case(betfair_error(-32099, "ANGX-0003", Some("NO_SESSION")), false)]
387    fn test_is_order_ambiguous(#[case] error: BetfairHttpError, #[case] expected: bool) {
388        assert_eq!(error.is_order_ambiguous(), expected);
389        assert_eq!(error.is_order_placement_ambiguous(), expected);
390    }
391
392    #[rstest]
393    #[case(BetfairHttpError::NetworkError("connection reset".to_string()), true)]
394    #[case(BetfairHttpError::ResponseError("truncated".to_string()), true)]
395    #[case(BetfairHttpError::UnexpectedStatus { status: 502, body: String::new() }, true)]
396    #[case(BetfairHttpError::UnexpectedStatus { status: 429, body: String::new() }, true)]
397    #[case(betfair_error(-32099, "ANGX-0002", Some("SERVICE_BUSY")), true)]
398    #[case(betfair_error(-32099, "ANGX-0003", Some("NO_SESSION")), false)]
399    #[case(betfair_error(-32099, "ANGX-0004", Some("TIMEOUT_ERROR")), false)]
400    #[case(BetfairHttpError::Canceled("shutdown".to_string()), false)]
401    fn test_is_order_retryable(#[case] error: BetfairHttpError, #[case] expected: bool) {
402        assert_eq!(error.is_order_retryable(), expected);
403    }
404
405    #[rstest]
406    #[case(betfair_error(-32099, "server error", None), false)]
407    #[case(betfair_error(-32099, "ANGX-0003", Some("NO_SESSION")), true)]
408    #[case(betfair_error(-32099, "ANGX-0003", Some("INVALID_SESSION_INFORMATION")), true)]
409    #[case(betfair_error(-32600, "Invalid request", None), false)]
410    #[case(BetfairHttpError::NetworkError("timeout".to_string()), false)]
411    #[case(BetfairHttpError::UnexpectedStatus { status: 429, body: String::new() }, false)]
412    fn test_is_session_error(#[case] error: BetfairHttpError, #[case] expected: bool) {
413        assert_eq!(error.is_session_error(), expected);
414    }
415
416    #[rstest]
417    #[case(BetfairHttpError::LoginFailed { status: "NO_SESSION".to_string() }, true)]
418    #[case(BetfairHttpError::LoginFailed { status: "CERT_AUTH_REQUIRED".to_string() }, true)]
419    #[case(BetfairHttpError::NetworkError("timeout".to_string()), false)]
420    #[case(BetfairHttpError::Timeout("read".to_string()), false)]
421    #[case(betfair_error(-32099, "server error", None), false)]
422    #[case(BetfairHttpError::JsonError("bad".to_string()), false)]
423    #[case(BetfairHttpError::MissingCredentials, false)]
424    fn test_is_login_failed(#[case] error: BetfairHttpError, #[case] expected: bool) {
425        assert_eq!(error.is_login_failed(), expected);
426    }
427
428    #[rstest]
429    #[case(betfair_error(-32099, "ANGX-0002", Some("TOO_MANY_REQUESTS")), true)]
430    #[case(BetfairHttpError::UnexpectedStatus { status: 429, body: String::new() }, true)]
431    #[case(betfair_error(-32099, "ANGX-0003", Some("NO_SESSION")), false)]
432    #[case(BetfairHttpError::UnexpectedStatus { status: 500, body: String::new() }, false)]
433    #[case(BetfairHttpError::NetworkError("timeout".to_string()), false)]
434    fn test_is_rate_limit_error(#[case] error: BetfairHttpError, #[case] expected: bool) {
435        assert_eq!(error.is_rate_limit_error(), expected);
436    }
437}