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 code and message.
28    BetfairError { code: i64, message: String },
29    /// JSON serialization/deserialization error.
30    JsonError(String),
31    /// Network-related error.
32    NetworkError(String),
33    /// Invalid client configuration.
34    InvalidConfiguration(String),
35    /// Request timeout.
36    Timeout(String),
37    /// Request canceled.
38    Canceled(String),
39    /// Unexpected HTTP status.
40    UnexpectedStatus { status: u16, body: String },
41}
42
43impl Display for BetfairHttpError {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            Self::MissingCredentials => write!(f, "Missing API credentials"),
47            Self::LoginFailed { status } => write!(f, "Login failed: {status}"),
48            Self::BetfairError { code, message } => {
49                write!(f, "Betfair error {code}: {message}")
50            }
51            Self::JsonError(msg) => write!(f, "JSON error: {msg}"),
52            Self::NetworkError(msg) => write!(f, "Network error: {msg}"),
53            Self::InvalidConfiguration(msg) => write!(f, "Invalid configuration: {msg}"),
54            Self::Timeout(msg) => write!(f, "Timeout: {msg}"),
55            Self::Canceled(msg) => write!(f, "Canceled: {msg}"),
56            Self::UnexpectedStatus { status, body } => {
57                write!(f, "Unexpected status {status}: {body}")
58            }
59        }
60    }
61}
62
63impl std::error::Error for BetfairHttpError {}
64
65impl From<serde_json::Error> for BetfairHttpError {
66    fn from(error: serde_json::Error) -> Self {
67        Self::JsonError(error.to_string())
68    }
69}
70
71impl From<anyhow::Error> for BetfairHttpError {
72    fn from(error: anyhow::Error) -> Self {
73        Self::NetworkError(error.to_string())
74    }
75}
76
77impl BetfairHttpError {
78    /// Returns whether this error is retryable.
79    #[must_use]
80    pub fn is_retryable(&self) -> bool {
81        match self {
82            Self::NetworkError(_) | Self::Timeout(_) => true,
83            Self::UnexpectedStatus { status, .. } => *status >= 500 || *status == 429,
84            Self::BetfairError { code, .. } => is_retryable_error_code(*code),
85            _ => false,
86        }
87    }
88
89    /// Returns whether this is a login/auth rejection from the Identity API.
90    ///
91    /// `keep_alive` returns this when the session is expired or unrecognised.
92    /// Transient errors (network, timeout) return different variants.
93    #[must_use]
94    pub fn is_login_failed(&self) -> bool {
95        matches!(self, Self::LoginFailed { .. })
96    }
97
98    /// Returns whether this error is a session expiry that should trigger reconnection.
99    ///
100    /// Session errors (`NO_SESSION`, `INVALID_SESSION_INFORMATION`) occur every
101    /// 12-24 hours and are resolved by re-authenticating. Undocumented JSON-RPC
102    /// server errors (-32099) are also treated as session errors.
103    #[must_use]
104    pub fn is_session_error(&self) -> bool {
105        match self {
106            Self::BetfairError { code, message } => {
107                message.contains("NO_SESSION")
108                    || message.contains("INVALID_SESSION_INFORMATION")
109                    || *code == -32099
110            }
111            _ => false,
112        }
113    }
114
115    /// Returns whether this error is a rate limit (`TOO_MANY_REQUESTS`) error.
116    #[must_use]
117    pub fn is_rate_limit_error(&self) -> bool {
118        match self {
119            Self::BetfairError { message, .. } => message.contains("TOO_MANY_REQUESTS"),
120            Self::UnexpectedStatus { status, .. } => *status == 429,
121            _ => false,
122        }
123    }
124
125    /// Returns whether this error leaves order placement in an ambiguous state.
126    ///
127    /// When true, the request may have been processed by Betfair despite the
128    /// error. Callers must NOT emit `OrderRejected` for ambiguous errors
129    /// because the order may be live on the exchange. The OCM stream will
130    /// reconcile the order via its `customerOrderRef`.
131    #[must_use]
132    pub fn is_order_placement_ambiguous(&self) -> bool {
133        match self {
134            Self::NetworkError(_) | Self::Timeout(_) => true,
135            Self::UnexpectedStatus { status, .. } => *status >= 500,
136            _ => false,
137        }
138    }
139}
140
141/// Returns whether a Betfair JSON-RPC error code is retryable.
142///
143/// Retryable codes are transient server-side errors. Permanent errors
144/// (invalid input, insufficient funds, etc.) should not be retried.
145fn is_retryable_error_code(code: i64) -> bool {
146    matches!(
147        code,
148        -32099 // Unexpected internal server error
149        | -32700 // JSON parse error (may be transient corruption)
150    )
151}
152
153#[cfg(test)]
154mod tests {
155    use rstest::rstest;
156
157    use super::*;
158
159    #[rstest]
160    fn test_display_missing_credentials() {
161        let err = BetfairHttpError::MissingCredentials;
162        assert_eq!(err.to_string(), "Missing API credentials");
163    }
164
165    #[rstest]
166    fn test_display_login_failed() {
167        let err = BetfairHttpError::LoginFailed {
168            status: "CERT_AUTH_REQUIRED".to_string(),
169        };
170        assert_eq!(err.to_string(), "Login failed: CERT_AUTH_REQUIRED");
171    }
172
173    #[rstest]
174    fn test_display_betfair_error() {
175        let err = BetfairHttpError::BetfairError {
176            code: -32600,
177            message: "Invalid request".to_string(),
178        };
179        assert_eq!(err.to_string(), "Betfair error -32600: Invalid request");
180    }
181
182    #[rstest]
183    fn test_display_unexpected_status() {
184        let err = BetfairHttpError::UnexpectedStatus {
185            status: 403,
186            body: "Forbidden".to_string(),
187        };
188        assert_eq!(err.to_string(), "Unexpected status 403: Forbidden");
189    }
190
191    #[rstest]
192    fn test_display_invalid_configuration() {
193        let err = BetfairHttpError::InvalidConfiguration("bad rate".to_string());
194        assert_eq!(err.to_string(), "Invalid configuration: bad rate");
195    }
196
197    #[rstest]
198    #[case(BetfairHttpError::NetworkError("timeout".to_string()), true)]
199    #[case(BetfairHttpError::Timeout("read".to_string()), true)]
200    #[case(BetfairHttpError::UnexpectedStatus { status: 500, body: String::new() }, true)]
201    #[case(BetfairHttpError::UnexpectedStatus { status: 429, body: String::new() }, true)]
202    #[case(BetfairHttpError::UnexpectedStatus { status: 403, body: String::new() }, false)]
203    #[case(BetfairHttpError::MissingCredentials, false)]
204    #[case(BetfairHttpError::LoginFailed { status: "FAIL".to_string() }, false)]
205    #[case(BetfairHttpError::JsonError("bad".to_string()), false)]
206    fn test_is_retryable(#[case] error: BetfairHttpError, #[case] expected: bool) {
207        assert_eq!(error.is_retryable(), expected);
208    }
209
210    #[rstest]
211    fn test_from_serde_error() {
212        let json_err = serde_json::from_str::<String>("not json").unwrap_err();
213        let err: BetfairHttpError = json_err.into();
214        assert!(matches!(err, BetfairHttpError::JsonError(_)));
215    }
216
217    #[rstest]
218    fn test_from_anyhow_error() {
219        let anyhow_err = anyhow::anyhow!("network failure");
220        let err: BetfairHttpError = anyhow_err.into();
221        assert!(matches!(err, BetfairHttpError::NetworkError(_)));
222    }
223
224    #[rstest]
225    #[case(BetfairHttpError::NetworkError("connection reset".to_string()), true)]
226    #[case(BetfairHttpError::Timeout("read".to_string()), true)]
227    #[case(BetfairHttpError::UnexpectedStatus { status: 502, body: "error code: 502".to_string() }, true)]
228    #[case(BetfairHttpError::UnexpectedStatus { status: 500, body: String::new() }, true)]
229    #[case(BetfairHttpError::UnexpectedStatus { status: 429, body: String::new() }, false)]
230    #[case(BetfairHttpError::UnexpectedStatus { status: 403, body: String::new() }, false)]
231    #[case(BetfairHttpError::BetfairError { code: -32600, message: "Invalid".to_string() }, false)]
232    #[case(BetfairHttpError::JsonError("bad".to_string()), false)]
233    #[case(BetfairHttpError::MissingCredentials, false)]
234    #[case(BetfairHttpError::Canceled("shutdown".to_string()), false)]
235    fn test_is_order_placement_ambiguous(#[case] error: BetfairHttpError, #[case] expected: bool) {
236        assert_eq!(error.is_order_placement_ambiguous(), expected);
237    }
238
239    #[rstest]
240    #[case(BetfairHttpError::BetfairError { code: -32099, message: "server error".to_string() }, true)]
241    #[case(BetfairHttpError::BetfairError { code: -1, message: "NO_SESSION".to_string() }, true)]
242    #[case(BetfairHttpError::BetfairError { code: -1, message: "INVALID_SESSION_INFORMATION".to_string() }, true)]
243    #[case(BetfairHttpError::BetfairError { code: -32600, message: "Invalid request".to_string() }, false)]
244    #[case(BetfairHttpError::NetworkError("timeout".to_string()), false)]
245    #[case(BetfairHttpError::UnexpectedStatus { status: 429, body: String::new() }, false)]
246    fn test_is_session_error(#[case] error: BetfairHttpError, #[case] expected: bool) {
247        assert_eq!(error.is_session_error(), expected);
248    }
249
250    #[rstest]
251    #[case(BetfairHttpError::LoginFailed { status: "NO_SESSION".to_string() }, true)]
252    #[case(BetfairHttpError::LoginFailed { status: "CERT_AUTH_REQUIRED".to_string() }, true)]
253    #[case(BetfairHttpError::NetworkError("timeout".to_string()), false)]
254    #[case(BetfairHttpError::Timeout("read".to_string()), false)]
255    #[case(BetfairHttpError::BetfairError { code: -32099, message: "server error".to_string() }, false)]
256    #[case(BetfairHttpError::JsonError("bad".to_string()), false)]
257    #[case(BetfairHttpError::MissingCredentials, false)]
258    fn test_is_login_failed(#[case] error: BetfairHttpError, #[case] expected: bool) {
259        assert_eq!(error.is_login_failed(), expected);
260    }
261
262    #[rstest]
263    #[case(BetfairHttpError::BetfairError { code: -1, message: "TOO_MANY_REQUESTS".to_string() }, true)]
264    #[case(BetfairHttpError::UnexpectedStatus { status: 429, body: String::new() }, true)]
265    #[case(BetfairHttpError::BetfairError { code: -1, message: "NO_SESSION".to_string() }, false)]
266    #[case(BetfairHttpError::UnexpectedStatus { status: 500, body: String::new() }, false)]
267    #[case(BetfairHttpError::NetworkError("timeout".to_string()), false)]
268    fn test_is_rate_limit_error(#[case] error: BetfairHttpError, #[case] expected: bool) {
269        assert_eq!(error.is_rate_limit_error(), expected);
270    }
271}