Skip to main content

nautilus_betfair/http/
client.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 implementation.
17
18use 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
50// Keep the final dispatch 15 seconds inside Betfair's 60-second customerRef window
51const ORDER_RETRY_MAX_ELAPSED_MS: u64 = 45_000;
52
53/// Betfair JSON-RPC request envelope.
54#[derive(Debug, Serialize)]
55struct JsonRpcRequest<P: Serialize> {
56    jsonrpc: &'static str,
57    method: String,
58    params: P,
59    id: u64,
60}
61
62/// Betfair JSON-RPC response envelope.
63#[derive(Debug, Deserialize)]
64struct JsonRpcResponse<T> {
65    result: Option<T>,
66    error: Option<JsonRpcError>,
67}
68
69/// JSON-RPC error object.
70#[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/// Betfair HTTP client for raw API operations.
93///
94/// Handles session-token authentication, JSON-RPC protocol, form-encoded
95/// identity requests, REST navigation, rate limiting, and retry logic.
96#[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    /// Creates a new [`BetfairHttpClient`].
115    ///
116    /// # Errors
117    ///
118    /// Returns an error if the HTTP client cannot be created.
119    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    /// Overrides the API base URLs (for testing with mock servers).
172    ///
173    /// The keep-alive URL is derived from `identity_login` by replacing the
174    /// path with `/keepAlive`.
175    #[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        // Derive keep-alive from same host as login
184        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    /// Returns a clone of the current cancellation token for this client.
195    ///
196    /// `disconnect()` cancels and replaces the token, so callers should fetch
197    /// a fresh clone for each operation rather than holding one long-term.
198    pub fn cancellation_token(&self) -> CancellationToken {
199        self.cancellation_token.lock().clone()
200    }
201
202    /// Returns the current session token, if authenticated.
203    pub async fn session_token(&self) -> Option<SecretString> {
204        self.session_token.read().await.clone()
205    }
206
207    /// Runs a synchronous token publication while session mutation is serialized.
208    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    /// Returns whether the client has an active session.
217    pub async fn is_connected(&self) -> bool {
218        self.session_token.read().await.is_some()
219    }
220
221    /// Returns the application key.
222    #[must_use]
223    pub fn app_key(&self) -> &str {
224        self.credential.app_key()
225    }
226
227    /// Authenticates with Betfair using interactive (non-cert) login.
228    ///
229    /// Sends credentials to the Identity API and stores the returned
230    /// session token for subsequent requests.
231    ///
232    /// # Errors
233    ///
234    /// Returns an error if the login request fails or authentication
235    /// is rejected.
236    pub async fn connect(&self) -> Result<(), BetfairHttpError> {
237        // Serialize session mutations so an older keep-alive response cannot overwrite a newer
238        // full login. This matches Python's per-instance asyncio.Lock around the same path.
239        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    /// Resets the session and re-authenticates.
252    ///
253    /// # Errors
254    ///
255    /// Returns an error if re-authentication fails.
256    pub async fn reconnect(&self) -> Result<(), BetfairHttpError> {
257        self.reconnect_with_token().await.map(drop)
258    }
259
260    /// Resets the session, re-authenticates, and returns the replacement token.
261    ///
262    /// # Errors
263    ///
264    /// Returns an error if re-authentication fails.
265    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    /// Clears the session token, cancels any in-flight retries, and primes a
302    /// fresh cancellation token for the next session.
303    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    /// Sends a keep-alive request to renew the session.
315    ///
316    /// # Errors
317    ///
318    /// Returns an error if the keep-alive request fails.
319    pub async fn keep_alive(&self) -> Result<(), BetfairHttpError> {
320        self.keep_alive_with_token().await.map(drop)
321    }
322
323    /// Renews the session and returns the current token.
324    ///
325    /// # Errors
326    ///
327    /// Returns an error if the keep-alive request fails.
328    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    /// Sends a JSON-RPC request to the Betting API.
351    ///
352    /// # Errors
353    ///
354    /// Returns an error if the request fails, authentication is missing,
355    /// or the response contains a JSON-RPC error.
356    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    /// Sends a JSON-RPC request to the Betting API with order rate limiting.
366    /// Ambiguous failures are retried only when the params include a request-level `customerRef`.
367    ///
368    /// # Errors
369    ///
370    /// Returns an error if the request fails, authentication is missing,
371    /// or the response contains a JSON-RPC error.
372    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    /// Sends a JSON-RPC request to the Accounts API.
386    ///
387    /// # Errors
388    ///
389    /// Returns an error if the request fails, authentication is missing,
390    /// or the response contains a JSON-RPC error.
391    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    /// Sends a GET request to the Navigation API.
401    ///
402    /// # Errors
403    ///
404    /// Returns an error if the request fails or the response cannot be parsed.
405    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        // Add session token if we have one (for keep-alive)
512        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(&params)?;
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        // Snapshot the current token; `disconnect()` may swap it for a fresh
594        // one mid-flight, but the in-flight retry loop should observe the
595        // pre-disconnect token so a cancel actually unblocks it.
596        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}