1use std::fmt::Display;
19
20#[derive(Debug, Clone)]
22pub enum BetfairHttpError {
23 MissingCredentials,
25 LoginFailed { status: String },
27 BetfairError {
29 code: i64,
30 message: String,
31 api_error_code: Option<String>,
32 api_error_details: Option<String>,
33 },
34 JsonError(String),
36 ResponseError(String),
38 NetworkError(String),
40 InvalidConfiguration(String),
42 Timeout(String),
44 Canceled(String),
46 UnexpectedStatus { status: u16, body: String },
48 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 #[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 #[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 #[must_use]
144 pub fn is_login_failed(&self) -> bool {
145 matches!(self, Self::LoginFailed { .. })
146 }
147
148 #[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 #[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 #[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 #[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
241fn is_retryable_error_code(code: i64) -> bool {
246 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}