1use std::fmt::Display;
19
20#[derive(Debug, Clone)]
22pub enum BetfairHttpError {
23 MissingCredentials,
25 LoginFailed { status: String },
27 BetfairError { code: i64, message: String },
29 JsonError(String),
31 NetworkError(String),
33 InvalidConfiguration(String),
35 Timeout(String),
37 Canceled(String),
39 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 #[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 #[must_use]
94 pub fn is_login_failed(&self) -> bool {
95 matches!(self, Self::LoginFailed { .. })
96 }
97
98 #[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 #[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 #[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
141fn is_retryable_error_code(code: i64) -> bool {
146 matches!(
147 code,
148 -32099 | -32700 )
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}