1use std::{
19 collections::HashMap,
20 num::NonZeroU32,
21 sync::{
22 Arc,
23 atomic::{AtomicBool, AtomicU64, Ordering},
24 },
25};
26
27use nautilus_core::string::{secret::SecretString, urlencoding};
28use nautilus_network::{
29 http::{HttpClient, HttpRedirectPolicy, Method, create_standard_nautilus_headers},
30 ratelimiter::quota::Quota,
31 retry::{RetryConfig, RetryError, RetryManager},
32};
33use serde::{Deserialize, Serialize, de::DeserializeOwned};
34use tokio_util::sync::CancellationToken;
35use zeroize::Zeroizing;
36
37use super::{
38 error::BetfairHttpError,
39 models::{LoginResponse, LoginStatus},
40};
41use crate::common::{
42 consts::{
43 BETFAIR_ACCOUNTS_URL, BETFAIR_BETTING_URL, BETFAIR_IDENTITY_LOGIN_URL,
44 BETFAIR_KEEP_ALIVE_URL, BETFAIR_NAVIGATION_URL, BETFAIR_RATE_LIMIT_DEFAULT,
45 BETFAIR_RATE_LIMIT_ORDERS, HEADER_X_APPLICATION, HEADER_X_AUTHENTICATION,
46 },
47 credential::BetfairCredential,
48};
49
50const ORDER_RETRY_MAX_ELAPSED_MS: u64 = 45_000;
52
53#[derive(Debug, Serialize)]
55struct JsonRpcRequest<P: Serialize> {
56 jsonrpc: &'static str,
57 method: String,
58 params: P,
59 id: u64,
60}
61
62#[derive(Debug, Deserialize)]
64struct JsonRpcResponse<T> {
65 result: Option<T>,
66 error: Option<JsonRpcError>,
67}
68
69#[derive(Debug, Deserialize)]
71struct JsonRpcError {
72 code: i64,
73 message: String,
74 data: Option<serde_json::Value>,
75}
76
77#[derive(Debug, Deserialize)]
78#[serde(rename_all = "camelCase")]
79struct JsonRpcApiException {
80 error_code: String,
81 error_details: Option<String>,
82}
83
84impl JsonRpcError {
85 fn api_exception(&self) -> Option<JsonRpcApiException> {
86 let data = self.data.as_ref()?;
87 let exception_name = data.get("exceptionname")?.as_str()?;
88 serde_json::from_value(data.get(exception_name)?.clone()).ok()
89 }
90}
91
92#[derive(Debug)]
97pub struct BetfairHttpClient {
98 client: HttpClient,
99 credential: BetfairCredential,
100 session_token: Arc<tokio::sync::RwLock<Option<SecretString>>>,
101 retry_manager: RetryManager<BetfairHttpError>,
102 order_retry_manager: RetryManager<BetfairHttpError>,
103 cancellation_token: parking_lot::Mutex<CancellationToken>,
104 connect_lock: tokio::sync::Mutex<()>,
105 request_id: AtomicU64,
106 url_identity_login: String,
107 url_keep_alive: String,
108 url_betting: String,
109 url_accounts: String,
110 url_navigation: String,
111}
112
113impl BetfairHttpClient {
114 pub fn new(
120 credential: BetfairCredential,
121 timeout_secs: Option<u64>,
122 max_retries: Option<u32>,
123 retry_delay_ms: Option<u64>,
124 proxy_url: Option<String>,
125 request_rate_per_second: Option<u32>,
126 order_request_rate_per_second: Option<u32>,
127 ) -> Result<Self, BetfairHttpError> {
128 let retry_config = RetryConfig {
129 max_retries: max_retries.unwrap_or(3),
130 initial_delay_ms: retry_delay_ms.unwrap_or(1000),
131 max_delay_ms: 10_000,
132 backoff_factor: 2.0,
133 jitter_ms: 500,
134 operation_timeout_ms: Some(30_000),
135 immediate_first: false,
136 max_elapsed_ms: Some(120_000),
137 };
138 let order_retry_config = RetryConfig {
139 max_elapsed_ms: Some(ORDER_RETRY_MAX_ELAPSED_MS),
140 ..retry_config
141 };
142
143 Ok(Self {
144 client: HttpClient::builder()
145 .redirect_policy(HttpRedirectPolicy::Reject)
146 .headers(create_standard_nautilus_headers().into_iter().collect())
147 .keyed_quotas(Self::rate_limiter_quotas(
148 request_rate_per_second.unwrap_or(5),
149 order_request_rate_per_second.unwrap_or(20),
150 )?)
151 .maybe_default_quota(Self::default_quota(request_rate_per_second.unwrap_or(5))?)
152 .maybe_timeout_secs(timeout_secs)
153 .maybe_proxy_url(proxy_url)
154 .build()
155 .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {e}"))?,
156 credential,
157 session_token: Arc::new(tokio::sync::RwLock::new(None)),
158 retry_manager: RetryManager::new(retry_config),
159 order_retry_manager: RetryManager::new(order_retry_config),
160 cancellation_token: parking_lot::Mutex::new(CancellationToken::new()),
161 connect_lock: tokio::sync::Mutex::new(()),
162 request_id: AtomicU64::new(1),
163 url_identity_login: BETFAIR_IDENTITY_LOGIN_URL.to_string(),
164 url_keep_alive: BETFAIR_KEEP_ALIVE_URL.to_string(),
165 url_betting: BETFAIR_BETTING_URL.to_string(),
166 url_accounts: BETFAIR_ACCOUNTS_URL.to_string(),
167 url_navigation: BETFAIR_NAVIGATION_URL.to_string(),
168 })
169 }
170
171 #[must_use]
176 pub fn with_urls(
177 mut self,
178 identity_login: String,
179 betting: String,
180 accounts: String,
181 navigation: String,
182 ) -> Self {
183 if let Some(base) = identity_login.rfind('/') {
185 self.url_keep_alive = format!("{}/keepAlive", &identity_login[..base]);
186 }
187 self.url_identity_login = identity_login;
188 self.url_betting = betting;
189 self.url_accounts = accounts;
190 self.url_navigation = navigation;
191 self
192 }
193
194 pub fn cancellation_token(&self) -> CancellationToken {
199 self.cancellation_token.lock().clone()
200 }
201
202 pub async fn session_token(&self) -> Option<SecretString> {
204 self.session_token.read().await.clone()
205 }
206
207 pub(crate) async fn with_session_token<T>(
209 &self,
210 publish: impl FnOnce(&SecretString) -> T,
211 ) -> Option<T> {
212 let _guard = self.connect_lock.lock().await;
213 self.session_token.read().await.as_ref().map(publish)
214 }
215
216 pub async fn is_connected(&self) -> bool {
218 self.session_token.read().await.is_some()
219 }
220
221 #[must_use]
223 pub fn app_key(&self) -> &str {
224 self.credential.app_key()
225 }
226
227 pub async fn connect(&self) -> Result<(), BetfairHttpError> {
237 let _guard = self.connect_lock.lock().await;
240
241 if self.session_token.read().await.is_some() {
242 log::debug!("Session token exists (already connected), skipping");
243 return Ok(());
244 }
245
246 let token = self.login().await?;
247 *self.session_token.write().await = Some(token);
248 Ok(())
249 }
250
251 pub async fn reconnect(&self) -> Result<(), BetfairHttpError> {
257 self.reconnect_with_token().await.map(drop)
258 }
259
260 pub(crate) async fn reconnect_with_token(&self) -> Result<SecretString, BetfairHttpError> {
266 let _guard = self.connect_lock.lock().await;
267 log::info!("Betfair reconnecting...");
268 *self.session_token.write().await = None;
269 let token = self.login().await?;
270 *self.session_token.write().await = Some(token.clone());
271 Ok(token)
272 }
273
274 async fn login(&self) -> Result<SecretString, BetfairHttpError> {
275 let password = Zeroizing::new(urlencoding::encode(self.credential.password()).into_owned());
276 let form_body = SecretString::from(format!(
277 "username={}&password={}",
278 urlencoding::encode(self.credential.username()),
279 password.as_str(),
280 ));
281
282 let resp_bytes = self
283 .send_identity(&self.url_identity_login, form_body)
284 .await?;
285
286 let mut resp: LoginResponse = serde_json::from_slice(&resp_bytes)?;
287
288 if resp.status == LoginStatus::Success {
289 log::debug!("Betfair login successful");
290 Ok(std::mem::take(&mut resp.token))
291 } else {
292 Err(BetfairHttpError::LoginFailed {
293 status: resp
294 .error
295 .take()
296 .unwrap_or_else(|| format!("{:?}", resp.status)),
297 })
298 }
299 }
300
301 pub async fn disconnect(&self) {
304 log::info!("Betfair disconnecting...");
305 let _guard = self.connect_lock.lock().await;
306 {
307 let mut guard = self.cancellation_token.lock();
308 guard.cancel();
309 *guard = CancellationToken::new();
310 }
311 *self.session_token.write().await = None;
312 }
313
314 pub async fn keep_alive(&self) -> Result<(), BetfairHttpError> {
320 self.keep_alive_with_token().await.map(drop)
321 }
322
323 pub(crate) async fn keep_alive_with_token(&self) -> Result<SecretString, BetfairHttpError> {
329 let _guard = self.connect_lock.lock().await;
330 let resp_bytes = self
331 .send_identity(&self.url_keep_alive, SecretString::default())
332 .await?;
333
334 let mut resp: LoginResponse = serde_json::from_slice(&resp_bytes)?;
335
336 if resp.status == LoginStatus::Success {
337 let token = std::mem::take(&mut resp.token);
338 *self.session_token.write().await = Some(token.clone());
339 Ok(token)
340 } else {
341 Err(BetfairHttpError::LoginFailed {
342 status: resp
343 .error
344 .take()
345 .unwrap_or_else(|| format!("{:?}", resp.status)),
346 })
347 }
348 }
349
350 pub async fn send_betting<T, P>(&self, method: &str, params: P) -> Result<T, BetfairHttpError>
357 where
358 T: DeserializeOwned,
359 P: Serialize,
360 {
361 self.send_jsonrpc(&self.url_betting, method, params, false)
362 .await
363 }
364
365 pub async fn send_betting_order<T, P>(
373 &self,
374 method: &str,
375 params: P,
376 ) -> Result<T, BetfairHttpError>
377 where
378 T: DeserializeOwned,
379 P: Serialize,
380 {
381 self.send_jsonrpc(&self.url_betting, method, params, true)
382 .await
383 }
384
385 pub async fn send_accounts<T, P>(&self, method: &str, params: P) -> Result<T, BetfairHttpError>
392 where
393 T: DeserializeOwned,
394 P: Serialize,
395 {
396 self.send_jsonrpc(&self.url_accounts, method, params, false)
397 .await
398 }
399
400 pub async fn send_navigation<T>(&self) -> Result<T, BetfairHttpError>
406 where
407 T: DeserializeOwned,
408 {
409 let headers = self.build_headers("application/json").await?;
410
411 let resp = self
412 .client
413 .request(
414 Method::GET,
415 self.url_navigation.clone(),
416 None,
417 Some(headers),
418 None,
419 None,
420 Some(vec![BETFAIR_RATE_LIMIT_DEFAULT.to_string()]),
421 )
422 .await
423 .map_err(|e| BetfairHttpError::NetworkError(e.to_string()))?;
424
425 if resp.status.as_u16() != 200 {
426 let body = String::from_utf8_lossy(&resp.body);
427 return Err(BetfairHttpError::UnexpectedStatus {
428 status: resp.status.as_u16(),
429 body: body.to_string(),
430 });
431 }
432
433 serde_json::from_slice(&resp.body).map_err(BetfairHttpError::from)
434 }
435
436 fn make_quota(requests_per_second: u32, label: &str) -> Result<Quota, BetfairHttpError> {
437 let rate = NonZeroU32::new(requests_per_second).ok_or_else(|| {
438 BetfairHttpError::InvalidConfiguration(format!("{label} must be greater than zero"))
439 })?;
440
441 Quota::per_second(rate).ok_or_else(|| {
442 BetfairHttpError::InvalidConfiguration(format!("Invalid {label} quota configuration"))
443 })
444 }
445
446 fn rate_limiter_quotas(
447 request_rate_per_second: u32,
448 order_request_rate_per_second: u32,
449 ) -> Result<Vec<(String, Quota)>, BetfairHttpError> {
450 Ok(vec![
451 (
452 BETFAIR_RATE_LIMIT_DEFAULT.to_string(),
453 Self::make_quota(request_rate_per_second, "request_rate_per_second")?,
454 ),
455 (
456 BETFAIR_RATE_LIMIT_ORDERS.to_string(),
457 Self::make_quota(
458 order_request_rate_per_second,
459 "order_request_rate_per_second",
460 )?,
461 ),
462 ])
463 }
464
465 fn default_quota(request_rate_per_second: u32) -> Result<Option<Quota>, BetfairHttpError> {
466 Ok(Some(Self::make_quota(
467 request_rate_per_second,
468 "request_rate_per_second",
469 )?))
470 }
471
472 async fn build_headers(
473 &self,
474 content_type: &str,
475 ) -> Result<HashMap<String, String>, BetfairHttpError> {
476 let token = self
477 .session_token
478 .read()
479 .await
480 .as_ref()
481 .map(|token| token.expose_secret().to_owned())
482 .ok_or(BetfairHttpError::MissingCredentials)?;
483
484 let mut headers = HashMap::new();
485 headers.insert(HEADER_X_AUTHENTICATION.to_string(), token);
486 headers.insert(
487 HEADER_X_APPLICATION.to_string(),
488 self.credential.app_key().to_string(),
489 );
490 headers.insert("Accept".to_string(), "application/json".to_string());
491 headers.insert("Content-Type".to_string(), content_type.to_string());
492 Ok(headers)
493 }
494
495 async fn send_identity(
496 &self,
497 url: &str,
498 body: SecretString,
499 ) -> Result<Vec<u8>, BetfairHttpError> {
500 let mut headers = HashMap::new();
501 headers.insert("Accept".to_string(), "application/json".to_string());
502 headers.insert(
503 "Content-Type".to_string(),
504 "application/x-www-form-urlencoded".to_string(),
505 );
506 headers.insert(
507 HEADER_X_APPLICATION.to_string(),
508 self.credential.app_key().to_string(),
509 );
510
511 if let Some(token) = self.session_token.read().await.as_ref() {
513 headers.insert(
514 HEADER_X_AUTHENTICATION.to_string(),
515 token.expose_secret().to_owned(),
516 );
517 }
518
519 let resp = self
520 .client
521 .request_with_secret_body(
522 Method::POST,
523 url.to_string(),
524 None,
525 Some(headers),
526 body,
527 None,
528 Some(vec![BETFAIR_RATE_LIMIT_DEFAULT.to_string()]),
529 )
530 .await
531 .map_err(|e| BetfairHttpError::NetworkError(e.to_string()))?;
532
533 if resp.status.as_u16() != 200 {
534 let body = String::from_utf8_lossy(&resp.body);
535 return Err(BetfairHttpError::UnexpectedStatus {
536 status: resp.status.as_u16(),
537 body: body.to_string(),
538 });
539 }
540
541 Ok(resp.body.to_vec())
542 }
543
544 async fn send_jsonrpc<T, P>(
545 &self,
546 base_url: &str,
547 method: &str,
548 params: P,
549 is_order: bool,
550 ) -> Result<T, BetfairHttpError>
551 where
552 T: DeserializeOwned,
553 P: Serialize,
554 {
555 let operation_id = format!("{base_url}#{method}");
556 let params_value = serde_json::to_value(¶ms)?;
557 let has_order_customer_ref = params_value
558 .get("customerRef")
559 .and_then(serde_json::Value::as_str)
560 .is_some_and(|customer_ref| !customer_ref.is_empty());
561 let had_ambiguous_attempt = AtomicBool::new(false);
562
563 let operation = || {
564 let params_value = params_value.clone();
565 let had_ambiguous_attempt = &had_ambiguous_attempt;
566
567 async move {
568 let result = self
569 .send_jsonrpc_once(base_url, method, params_value, is_order)
570 .await;
571
572 if is_order && result.as_ref().is_err_and(|e| e.is_order_ambiguous()) {
573 had_ambiguous_attempt.store(true, Ordering::Relaxed);
574 }
575
576 result
577 }
578 };
579
580 let should_retry = |error: &BetfairHttpError| -> bool {
581 if is_order {
582 error.is_order_retryable()
583 && (has_order_customer_ref || !error.is_order_ambiguous())
584 } else {
585 error.is_retryable()
586 }
587 };
588
589 let create_error = |error: RetryError| -> BetfairHttpError {
590 map_retry_error(error, is_order, &had_ambiguous_attempt)
591 };
592
593 let token = self.cancellation_token.lock().clone();
597
598 let retry_manager = if is_order {
599 &self.order_retry_manager
600 } else {
601 &self.retry_manager
602 };
603 let result = retry_manager
604 .invocation(&operation_id, operation, should_retry, create_error)
605 .cancellation_token(&token)
606 .execute()
607 .await;
608
609 let result = match result {
610 Err(e)
611 if is_order
612 && had_ambiguous_attempt.load(Ordering::Relaxed)
613 && !e.is_order_ambiguous() =>
614 {
615 Err(BetfairHttpError::OrderRequestAmbiguous(format!(
616 "an earlier attempt had an unknown outcome; final error: {e}",
617 )))
618 }
619 result => result,
620 };
621
622 if let Err(ref e) = result
623 && should_retry(e)
624 {
625 log::error!("Request exhausted retries: method={method}, error={e}");
626 }
627
628 result
629 }
630
631 async fn send_jsonrpc_once<T>(
632 &self,
633 base_url: &str,
634 method: &str,
635 params: serde_json::Value,
636 is_order: bool,
637 ) -> Result<T, BetfairHttpError>
638 where
639 T: DeserializeOwned,
640 {
641 let id = self.request_id.fetch_add(1, Ordering::SeqCst);
642 let request = JsonRpcRequest {
643 jsonrpc: "2.0",
644 method: method.to_string(),
645 params,
646 id,
647 };
648
649 let body = serde_json::to_vec(&request)?;
650 let headers = self.build_headers("application/json").await?;
651 let rate_keys = if is_order {
652 vec![BETFAIR_RATE_LIMIT_ORDERS.to_string()]
653 } else {
654 vec![BETFAIR_RATE_LIMIT_DEFAULT.to_string()]
655 };
656
657 let resp = self
658 .client
659 .request(
660 Method::POST,
661 base_url.to_string(),
662 None,
663 Some(headers),
664 Some(body),
665 None,
666 Some(rate_keys),
667 )
668 .await
669 .map_err(|e| BetfairHttpError::NetworkError(e.to_string()))?;
670
671 if !resp.status.is_success() {
672 let error_body = String::from_utf8_lossy(&resp.body);
673 let preview: String = error_body.chars().take(500).collect();
674 log::warn!(
675 "HTTP error response: method={method}, status={}, body={}",
676 resp.status.as_u16(),
677 preview,
678 );
679 return Err(BetfairHttpError::UnexpectedStatus {
680 status: resp.status.as_u16(),
681 body: error_body.to_string(),
682 });
683 }
684
685 let json_value: serde_json::Value = serde_json::from_slice(&resp.body).map_err(|e| {
686 let preview: String = String::from_utf8_lossy(&resp.body)
687 .chars()
688 .take(500)
689 .collect();
690 log::warn!(
691 "Non-JSON response: method={method}, status={}, body={preview}",
692 resp.status.as_u16(),
693 );
694 BetfairHttpError::ResponseError(e.to_string())
695 })?;
696
697 let rpc_resp: JsonRpcResponse<T> = serde_json::from_value(json_value).map_err(|e| {
698 log::warn!("Failed to deserialize JSON-RPC response: method={method}, error={e}",);
699 BetfairHttpError::ResponseError(e.to_string())
700 })?;
701
702 if let Some(result) = rpc_resp.result {
703 Ok(result)
704 } else if let Some(error) = rpc_resp.error {
705 let api_exception = error.api_exception();
706 Err(BetfairHttpError::BetfairError {
707 code: error.code,
708 message: error.message,
709 api_error_code: api_exception
710 .as_ref()
711 .map(|exception| exception.error_code.clone()),
712 api_error_details: api_exception.and_then(|exception| exception.error_details),
713 })
714 } else {
715 Err(BetfairHttpError::ResponseError(
716 "Response contains neither result nor error".to_string(),
717 ))
718 }
719 }
720}
721
722fn map_retry_error(
723 error: RetryError,
724 is_order: bool,
725 had_ambiguous_attempt: &AtomicBool,
726) -> BetfairHttpError {
727 if is_order && matches!(&error, RetryError::OperationTimeout { .. }) {
728 had_ambiguous_attempt.store(true, Ordering::Relaxed);
729 }
730
731 match error {
732 RetryError::Canceled => {
733 BetfairHttpError::Canceled("Adapter disconnecting or shutting down".to_string())
734 }
735 error => BetfairHttpError::NetworkError(error.to_string()),
736 }
737}
738
739#[cfg(test)]
740mod tests {
741 use std::time::Duration;
742
743 use nautilus_testkit::http::assert_http_redirect_rejected;
744 use parking_lot::Mutex;
745 use proptest::prelude::*;
746 use rstest::rstest;
747
748 use super::*;
749 use crate::common::consts::{
750 BETFAIR_RATE_LIMIT_DEFAULT, BETFAIR_RATE_LIMIT_ORDERS, METHOD_LIST_MARKET_CATALOGUE,
751 };
752
753 fn json_value_strategy() -> impl Strategy<Value = serde_json::Value> {
754 let leaf = prop_oneof![
755 Just(serde_json::Value::Null),
756 any::<bool>().prop_map(serde_json::Value::Bool),
757 any::<i64>().prop_map(|value| serde_json::Value::Number(value.into())),
758 "[ -~]{0,32}".prop_map(serde_json::Value::String),
759 ];
760
761 leaf.prop_recursive(3, 64, 8, |inner| {
762 prop_oneof![
763 prop::collection::vec(inner.clone(), 0..8).prop_map(serde_json::Value::Array),
764 prop::collection::vec(("[A-Za-z0-9_]{0,16}", inner), 0..8)
765 .prop_map(|entries| serde_json::Value::Object(entries.into_iter().collect())),
766 ]
767 })
768 }
769
770 #[tokio::test]
771 async fn test_authenticated_client_rejects_redirects() {
772 let client = BetfairHttpClient::new(
773 BetfairCredential::new("user".into(), "password".into(), "app".into()),
774 Some(3),
775 Some(0),
776 None,
777 None,
778 None,
779 None,
780 )
781 .unwrap()
782 .client;
783 assert_http_redirect_rejected(|url| async move {
784 client
785 .get(url, None, None, Some(3), None)
786 .await
787 .unwrap()
788 .status
789 .as_u16()
790 })
791 .await;
792 }
793
794 #[rstest]
795 fn test_rate_limiter_quotas_has_expected_keys() {
796 let quotas = BetfairHttpClient::rate_limiter_quotas(5, 20).unwrap();
797 let keys: Vec<&str> = quotas.iter().map(|(k, _)| k.as_str()).collect();
798 assert!(keys.contains(&BETFAIR_RATE_LIMIT_DEFAULT));
799 assert!(keys.contains(&BETFAIR_RATE_LIMIT_ORDERS));
800 }
801
802 #[rstest]
803 fn test_default_quota_is_some() {
804 assert!(BetfairHttpClient::default_quota(5).unwrap().is_some());
805 }
806
807 #[rstest]
808 fn test_rate_limiter_quotas_reject_zero_rate_limit() {
809 let result = BetfairHttpClient::rate_limiter_quotas(0, 20);
810
811 assert!(result.is_err());
812 assert!(
813 result
814 .err()
815 .unwrap()
816 .to_string()
817 .contains("request_rate_per_second")
818 );
819 }
820
821 #[rstest]
822 fn test_debug_redacts_session_token() {
823 let client = BetfairHttpClient::new(
824 BetfairCredential::new(
825 "username".to_string(),
826 "betfair-password-sentinel".to_string(),
827 "application-key".to_string(),
828 ),
829 None,
830 None,
831 None,
832 None,
833 None,
834 None,
835 )
836 .unwrap();
837 *client.session_token.try_write().unwrap() =
838 Some(SecretString::from("betfair-session-token-sentinel"));
839
840 let debug = format!("{client:?}");
841
842 assert!(debug.contains("session_token"));
843 assert!(!debug.contains("betfair-session-token-sentinel"));
844 assert!(!debug.contains("betfair-password-sentinel"));
845 }
846
847 #[rstest]
848 fn test_json_rpc_request_serialization() {
849 let request = JsonRpcRequest {
850 jsonrpc: "2.0",
851 method: METHOD_LIST_MARKET_CATALOGUE.to_string(),
852 params: serde_json::json!({"filter": {}, "maxResults": 100}),
853 id: 1,
854 };
855
856 let json = serde_json::to_value(&request).unwrap();
857 assert_eq!(json["jsonrpc"], "2.0");
858 assert_eq!(json["method"], "SportsAPING/v1.0/listMarketCatalogue");
859 assert_eq!(json["params"]["maxResults"], 100);
860 assert_eq!(json["id"], 1);
861 }
862
863 #[rstest]
864 fn test_json_rpc_response_success() {
865 let json = include_str!(concat!(
866 env!("CARGO_MANIFEST_DIR"),
867 "/test_data/rest/betting_place_order_success.json"
868 ));
869 let resp: JsonRpcResponse<serde_json::Value> = serde_json::from_str(json).unwrap();
870 assert_eq!(
871 resp.result
872 .as_ref()
873 .and_then(|result| result["status"].as_str()),
874 Some("SUCCESS")
875 );
876 assert!(resp.error.is_none());
877 }
878
879 #[rstest]
880 fn test_json_rpc_response_error() {
881 let json = include_str!(concat!(
882 env!("CARGO_MANIFEST_DIR"),
883 "/test_data/rest/betting_jsonrpc_error_too_much_data_live.json"
884 ));
885 let resp: JsonRpcResponse<serde_json::Value> = serde_json::from_str(json).unwrap();
886 assert!(resp.result.is_none());
887 let error = resp.error.unwrap();
888 assert_eq!(error.code, -32099);
889 assert_eq!(error.message, "ANGX-0001");
890 let api_exception = error.api_exception().unwrap();
891 assert_eq!(api_exception.error_code, "TOO_MUCH_DATA");
892 assert_eq!(
893 api_exception.error_details.as_deref(),
894 Some("MaxResults must be less than or equal to 1000")
895 );
896 }
897
898 #[rstest]
899 fn test_order_operation_timeout_records_ambiguous_attempt() {
900 let had_ambiguous_attempt = AtomicBool::new(false);
901
902 let error = map_retry_error(
903 RetryError::OperationTimeout { timeout_ms: 30_000 },
904 true,
905 &had_ambiguous_attempt,
906 );
907
908 assert!(error.is_order_ambiguous());
909 assert!(had_ambiguous_attempt.load(Ordering::Relaxed));
910 }
911
912 proptest! {
913 #![proptest_config(ProptestConfig::with_cases(256))]
914
915 #[rstest]
916 fn arbitrary_json_rpc_error_data_preserves_outer_error(
917 data in json_value_strategy(),
918 ) {
919 let response: JsonRpcResponse<serde_json::Value> = serde_json::from_value(
920 serde_json::json!({
921 "jsonrpc": "2.0",
922 "id": 73,
923 "error": {
924 "code": -32099,
925 "message": "ANGX-PROPERTY",
926 "data": data,
927 },
928 }),
929 )
930 .unwrap();
931 let error = response.error.unwrap();
932 let api_exception = error.api_exception();
933 let surfaced = BetfairHttpError::BetfairError {
934 code: error.code,
935 message: error.message.clone(),
936 api_error_code: api_exception
937 .as_ref()
938 .map(|exception| exception.error_code.clone()),
939 api_error_details: api_exception
940 .and_then(|exception| exception.error_details),
941 };
942
943 prop_assert_eq!(error.code, -32099);
944 prop_assert_eq!(error.message, "ANGX-PROPERTY");
945
946 if matches!(
947 &surfaced,
948 BetfairHttpError::BetfairError {
949 api_error_code: None,
950 ..
951 }
952 ) {
953 prop_assert!(surfaced.is_order_ambiguous());
954 prop_assert!(!surfaced.is_order_retryable());
955 }
956 }
957
958 #[rstest]
959 fn unknown_api_error_code_is_ambiguous_and_not_retried(
960 api_error_code in "FUTURE_[A-Z0-9_]{1,24}",
961 ) {
962 let exception_name = "FutureAPINGException";
963 let response: JsonRpcResponse<serde_json::Value> = serde_json::from_value(
964 serde_json::json!({
965 "jsonrpc": "2.0",
966 "id": 74,
967 "error": {
968 "code": -32099,
969 "message": "ANGX-FUTURE",
970 "data": {
971 "exceptionname": exception_name,
972 (exception_name): {
973 "errorCode": api_error_code,
974 "errorDetails": "future wire shape",
975 },
976 },
977 },
978 }),
979 )
980 .unwrap();
981 let error = response.error.unwrap();
982 let api_exception = error.api_exception().unwrap();
983 let surfaced = BetfairHttpError::BetfairError {
984 code: error.code,
985 message: error.message.clone(),
986 api_error_code: Some(api_exception.error_code.clone()),
987 api_error_details: api_exception.error_details.clone(),
988 };
989
990 prop_assert_eq!(error.code, -32099);
991 prop_assert_eq!(error.message, "ANGX-FUTURE");
992 prop_assert_eq!(api_exception.error_code, api_error_code);
993 prop_assert_eq!(
994 api_exception.error_details.as_deref(),
995 Some("future wire shape"),
996 );
997 prop_assert!(surfaced.is_order_ambiguous());
998 prop_assert!(!surfaced.is_order_retryable());
999 }
1000 }
1001
1002 #[tokio::test(start_paused = true)]
1003 async fn test_order_retry_budget_prevents_dispatch_at_45_seconds() {
1004 let client = BetfairHttpClient::new(
1005 BetfairCredential::new(
1006 "username".to_string(),
1007 "password".to_string(),
1008 "app-key".to_string(),
1009 ),
1010 None,
1011 Some(100),
1012 Some(10_000),
1013 None,
1014 Some(5),
1015 Some(20),
1016 )
1017 .unwrap();
1018 let started_at = tokio::time::Instant::now();
1019 let attempt_times = Arc::new(Mutex::new(Vec::new()));
1020 let attempt_times_for_operation = Arc::clone(&attempt_times);
1021
1022 let result = client
1023 .order_retry_manager
1024 .invocation(
1025 "placeOrders",
1026 move || {
1027 let attempt_times = Arc::clone(&attempt_times_for_operation);
1028 async move {
1029 attempt_times.lock().push(started_at.elapsed());
1030 Err::<(), _>(BetfairHttpError::UnexpectedStatus {
1031 status: 502,
1032 body: "Bad Gateway".to_string(),
1033 })
1034 }
1035 },
1036 BetfairHttpError::is_order_retryable,
1037 |e| BetfairHttpError::NetworkError(e.to_string()),
1038 )
1039 .execute()
1040 .await;
1041
1042 assert!(matches!(result, Err(BetfairHttpError::NetworkError(_))));
1043 assert_eq!(started_at.elapsed(), Duration::from_secs(45));
1044 let attempt_times = attempt_times.lock();
1045 assert!(attempt_times.len() > 1);
1046 assert_eq!(attempt_times[0], Duration::ZERO);
1047 assert!(
1048 attempt_times
1049 .iter()
1050 .all(|elapsed| *elapsed < Duration::from_secs(45)),
1051 );
1052 }
1053}