Skip to main content

projectx_client/
client.rs

1// SPDX-FileCopyrightText: 2026 Kevin Monaghan
2// SPDX-License-Identifier: MIT-0
3
4//! Authenticated REST client.
5
6use std::{
7    fmt,
8    sync::Arc,
9    time::{Duration, SystemTime},
10};
11
12use futures_util::StreamExt as _;
13use parking_lot::Mutex;
14use reqwest::{Method, Proxy, header, redirect::Policy};
15use serde::{Deserialize, Serialize, de::DeserializeOwned};
16use tokio::{task::JoinHandle, time::MissedTickBehavior};
17use tokio_util::sync::CancellationToken;
18
19use crate::{
20    Account, AccountId, AmbiguityOrigin, ApplicationCredentials, Bar, CancelOrder, CloseContract,
21    Contract, Credentials, Endpoints, Error, HistoryRequest, Hub, ModifyOrder, OperationResponse,
22    Order, OrderId, OrderPage, OrderQuery, OrderResponse, OrderSearch, PartialCloseContract,
23    PlaceOrder, Position, ProviderError, ProviderList, RateLimitConfig, RateLimitKind,
24    RealtimeClient, SearchContracts, Trade, TradeQuery, TradeSearch,
25    credentials::AuthenticationCredentials,
26    error_codes::ErrorCodeTable,
27    models::{
28        AccountsBody, BarsBody, ContractBody, ContractsBody, EmptyBody, Envelope, OrderBody,
29        OrdersBody, PlaceOrderBody, PositionsBody, TradesBody,
30    },
31    rate_limit::{MAX_COOLDOWN, RateLimits},
32    token::{TokenRevision, TokenSnapshot, TokenStore, UpdateOutcome},
33};
34
35const DEFAULT_TIMEOUT: Duration = Duration::from_mins(1);
36const DEFAULT_RESPONSE_LIMIT: usize = 16 * 1024 * 1024;
37const DEFAULT_MAX_RETRIES: u32 = 3;
38const DEFAULT_RETRY_INITIAL: Duration = Duration::from_secs(1);
39const DEFAULT_RETRY_MAX: Duration = Duration::from_secs(10);
40const USER_AGENT: &str = concat!("projectx-client/", env!("CARGO_PKG_VERSION"));
41
42/// Authenticated `ProjectX` REST client.
43///
44/// The client uses the caller's Tokio runtime and keeps its bearer token
45/// private. Call [`Client::authenticate`] before calling authenticated methods.
46/// Safe authenticated queries wait asynchronously for shared rate-limit
47/// capacity before their HTTP timeout begins. Money-moving mutations instead
48/// return [`Error::LocallyRateLimited`] without sending when capacity is full.
49pub struct Client {
50    credentials: Arc<AuthenticationCredentials>,
51    endpoints: Endpoints,
52    http: reqwest::Client,
53    realtime_http: reqwest::Client,
54    token: Arc<TokenStore>,
55    rate_limits: Arc<RateLimits>,
56    response_limit: usize,
57    realtime_config: crate::realtime::RealtimeConfig,
58    max_retries: u32,
59    retry_initial: Duration,
60    retry_max: Duration,
61}
62
63/// Invalidates the exact session admitted to a logout request unless the
64/// provider definitively rejected the request before admission.
65struct LogoutAttempt {
66    store: Arc<TokenStore>,
67    basis: TokenSnapshot,
68    armed: bool,
69}
70
71impl LogoutAttempt {
72    fn new(store: Arc<TokenStore>, basis: TokenSnapshot) -> Self {
73        Self {
74            store,
75            basis,
76            armed: true,
77        }
78    }
79
80    fn basis(&self) -> &TokenSnapshot {
81        &self.basis
82    }
83
84    fn retain_session(mut self) {
85        self.armed = false;
86    }
87}
88
89impl Drop for LogoutAttempt {
90    fn drop(&mut self) {
91        if self.armed {
92            self.store.invalidate_if_current(&self.basis);
93        }
94    }
95}
96
97/// Invalidates one exact session revision unless validation reaches a
98/// trustworthy terminal result.
99struct ValidationAttempt {
100    store: Arc<TokenStore>,
101    basis: TokenSnapshot,
102    tracker: Option<Arc<ValidationTracker>>,
103    armed: bool,
104}
105
106impl ValidationAttempt {
107    fn new(
108        store: Arc<TokenStore>,
109        basis: TokenSnapshot,
110        tracker: Option<&Arc<ValidationTracker>>,
111    ) -> Option<Self> {
112        if tracker.is_some_and(|tracker| !tracker.register(basis.revision())) {
113            return None;
114        }
115        Some(Self {
116            store,
117            basis,
118            tracker: tracker.cloned(),
119            armed: true,
120        })
121    }
122
123    fn basis(&self) -> &TokenSnapshot {
124        &self.basis
125    }
126
127    fn disarm(mut self) {
128        self.armed = false;
129        self.release_tracker();
130    }
131
132    fn claim_trustworthy_completion(&mut self) -> bool {
133        let Some(tracker) = self.tracker.take() else {
134            return true;
135        };
136        tracker.claim_completion(self.basis.revision())
137    }
138
139    fn release_tracker(&mut self) {
140        if let Some(tracker) = self.tracker.take() {
141            let _claimed = tracker.claim_completion(self.basis.revision());
142        }
143    }
144}
145
146impl Drop for ValidationAttempt {
147    fn drop(&mut self) {
148        if self.armed {
149            self.store.invalidate_if_current(&self.basis);
150        }
151        self.release_tracker();
152    }
153}
154
155/// Coordinates cancellation with the periodic validator's admitted request.
156#[derive(Default)]
157struct ValidationTracker {
158    state: Mutex<ValidationTrackerState>,
159}
160
161#[derive(Default)]
162struct ValidationTrackerState {
163    closed: bool,
164    active: Option<TokenRevision>,
165}
166
167impl ValidationTracker {
168    fn register(&self, basis: TokenRevision) -> bool {
169        let mut state = self.state.lock();
170        if state.closed || state.active.is_some() {
171            return false;
172        }
173        state.active = Some(basis);
174        true
175    }
176
177    fn claim_completion(&self, basis: TokenRevision) -> bool {
178        let mut state = self.state.lock();
179        if state.active == Some(basis) {
180            state.active = None;
181            return true;
182        }
183        false
184    }
185
186    fn close(&self) -> Option<TokenRevision> {
187        let mut state = self.state.lock();
188        state.closed = true;
189        state.active.take()
190    }
191}
192
193impl Client {
194    /// Starts configuring a client with explicit API-key credentials.
195    pub fn builder(credentials: Credentials) -> ClientBuilder {
196        ClientBuilder::new(AuthenticationCredentials::ApiKey(credentials))
197    }
198
199    /// Starts configuring a client with authorized-application credentials.
200    pub fn application_builder(credentials: ApplicationCredentials) -> ClientBuilder {
201        ClientBuilder::new(AuthenticationCredentials::Application(credentials))
202    }
203
204    /// Creates a real-time client sharing this client's rotating bearer token.
205    ///
206    /// The returned hub snapshots the current token immediately before every
207    /// initial connection and reconnect. Call [`Self::authenticate`] first.
208    #[must_use]
209    pub fn realtime(&self, hub: Hub) -> RealtimeClient {
210        RealtimeClient::new(
211            hub,
212            self.endpoints.clone(),
213            self.realtime_http.clone(),
214            Arc::clone(&self.token),
215            self.realtime_config,
216        )
217    }
218
219    /// Authenticates with the endpoint selected by the configured credential type.
220    ///
221    /// API-key credentials send exactly `userName` and `apiKey` to
222    /// `/api/Auth/loginKey`. Authorized-application credentials send exactly
223    /// `userName`, `password`, `deviceId`, `appId`, and `verifyKey` to
224    /// `/api/Auth/loginApp`.
225    ///
226    /// # Errors
227    ///
228    /// Returns an error when transport fails, credentials are rejected, or the
229    /// provider omits a usable token.
230    pub async fn authenticate(&self) -> Result<(), Error> {
231        let attempt = self.token.begin_authentication();
232        let response: LoginResponse = match self.credentials.as_ref() {
233            AuthenticationCredentials::ApiKey(credentials) => {
234                let body = LoginApiKeyRequest {
235                    user_name: credentials.expose_user_name(),
236                    api_key: credentials.expose_api_key(),
237                };
238                self.post_unauthenticated("api/Auth/loginKey", &body)
239                    .await?
240            }
241            AuthenticationCredentials::Application(credentials) => {
242                let body = LoginAppRequest {
243                    user_name: credentials.expose_user_name(),
244                    password: credentials.expose_password(),
245                    device_id: credentials.expose_device_id(),
246                    app_id: credentials.expose_app_id(),
247                    verify_key: credentials.expose_verify_key(),
248                };
249                self.post_unauthenticated("api/Auth/loginApp", &body)
250                    .await?
251            }
252        };
253        validate_response_status(response.success, response.error_code)?;
254        if !response.success {
255            return Err(Error::CredentialsRejected {
256                code: response.error_code,
257                name: ErrorCodeTable::Login.name(response.error_code),
258            });
259        }
260        let token = validate_token(response.token.as_deref())?;
261        if !attempt.commit(token) {
262            tracing::debug!(
263                "discarded a delayed authentication response after another authentication completed"
264            );
265        }
266        Ok(())
267    }
268
269    /// Authenticates and starts periodic token validation.
270    ///
271    /// The validator is cancelled when the returned guard is dropped. Prefer
272    /// [`SessionValidator::shutdown`] when graceful task completion matters.
273    ///
274    /// # Errors
275    ///
276    /// Returns an error when the validation period is zero or cannot be
277    /// represented by Tokio's clock, or when authentication fails.
278    pub async fn authenticate_with_validation(
279        &self,
280        period: Duration,
281    ) -> Result<SessionValidator, Error> {
282        if period.is_zero() {
283            return Err(Error::Configuration(
284                "validation period must be non-zero".to_owned(),
285            ));
286        }
287        let first_tick = tokio::time::Instant::now()
288            .checked_add(period)
289            .ok_or_else(|| {
290                Error::Configuration(
291                    "validation period cannot be represented by the Tokio clock".to_owned(),
292                )
293            })?;
294        self.authenticate().await?;
295        let cancellation = CancellationToken::new();
296        let task_cancellation = cancellation.clone();
297        let validation_tracker = Arc::new(ValidationTracker::default());
298        let task_validation_tracker = Arc::clone(&validation_tracker);
299        let client = self.clone();
300        let task = tokio::spawn(async move {
301            let mut ticker = tokio::time::interval_at(first_tick, period);
302            ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
303            loop {
304                tokio::select! {
305                    () = task_cancellation.cancelled() => break Ok(()),
306                    _ = ticker.tick() => {
307                        tokio::select! {
308                            biased;
309                            result = client.validate_session_tracked(Some(Arc::clone(
310                                &task_validation_tracker,
311                            ))) => {
312                                match result {
313                                    Ok(()) => {}
314                                    Err(error)
315                                        if is_terminal_session_error(&error)
316                                            && !client
317                                                .token
318                                                .has_viable_session_or_authentication() =>
319                                    {
320                                        break Err(error);
321                                    }
322                                    Err(error) => {
323                                        tracing::warn!(%error, "ProjectX token validation failed");
324                                    }
325                                }
326                            }
327                            () = task_cancellation.cancelled() => {
328                                if client.token.is_authenticated() {
329                                    break Ok(());
330                                }
331                                break Err(Error::AmbiguousSessionValidation);
332                            }
333                        }
334                    }
335                }
336            }
337        });
338        Ok(SessionValidator {
339            cancellation,
340            task: Some(task),
341            token: Arc::clone(&self.token),
342            validation_tracker,
343        })
344    }
345
346    /// Validates the current bearer token and applies provider token rotation.
347    ///
348    /// # Errors
349    ///
350    /// Returns an error when unauthenticated, transport fails, or validation is
351    /// rejected. Once the validation request may have reached the provider,
352    /// cancellation or an untrustworthy response invalidates that exact token
353    /// revision and requires authentication before it can be used again.
354    pub async fn validate_session(&self) -> Result<(), Error> {
355        self.validate_session_tracked(None).await
356    }
357
358    /// Logs out the current provider session exactly once.
359    ///
360    /// A request that may have reached the provider invalidates only the exact
361    /// bearer-token revision it used, including when the future is cancelled or
362    /// the response is ambiguous. A definitive connection failure or HTTP 429
363    /// retains that revision because the provider did not admit the logout.
364    ///
365    /// # Errors
366    ///
367    /// Returns an error when unauthenticated, locally or remotely rate limited,
368    /// rejected by the provider, or when no trustworthy response is available.
369    pub async fn logout(&self) -> Result<OperationResponse, Error> {
370        self.require_authentication()?;
371        let url = self.endpoints.api_url("api/Auth/logout")?;
372        self.rate_limits
373            .try_acquire(RateLimitKind::General)
374            .map_err(|retry_after| Error::LocallyRateLimited {
375                kind: RateLimitKind::General,
376                retry_after,
377            })?;
378        let basis = self
379            .token
380            .versioned_snapshot()
381            .ok_or(Error::NotAuthenticated)?;
382        let attempt = LogoutAttempt::new(Arc::clone(&self.token), basis);
383        let response = match self
384            .http
385            .request(Method::POST, url)
386            .bearer_auth(attempt.basis().expose())
387            .send()
388            .await
389        {
390            Ok(response) => response,
391            Err(error) if error.is_connect() || error.is_builder() => {
392                attempt.retain_session();
393                return Err(Error::Transport(error));
394            }
395            Err(error) => return Err(Error::Transport(error)),
396        };
397        if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
398            let retry_after =
399                self.apply_provider_cooldown(RateLimitKind::General, response.headers());
400            attempt.retain_session();
401            return Err(Error::ProviderRateLimited {
402                kind: RateLimitKind::General,
403                retry_after,
404            });
405        }
406        let response: Envelope<EmptyBody> = self.decode(response).await?;
407        accepted(response, ErrorCodeTable::Logout)?;
408        Ok(OperationResponse)
409    }
410
411    /// Checks whether the provider REST service is responsive.
412    ///
413    /// This unauthenticated operation expects the provider's exact `pong`
414    /// response and applies the configured response-size bound.
415    ///
416    /// # Errors
417    ///
418    /// Returns an error for URL, transport, HTTP status, response-size, or
419    /// unexpected-response failures.
420    pub async fn ping(&self) -> Result<(), Error> {
421        let url = self.endpoints.api_url("api/Status/ping")?;
422        let response = self
423            .http
424            .request(Method::GET, url)
425            .send()
426            .await
427            .map_err(Error::Transport)?;
428        let bytes = self.read_bounded_response(response).await?;
429        if bytes == b"pong" {
430            Ok(())
431        } else {
432            Err(Error::UnexpectedStatusResponse)
433        }
434    }
435
436    async fn validate_session_tracked(
437        &self,
438        tracker: Option<Arc<ValidationTracker>>,
439    ) -> Result<(), Error> {
440        let (response, attempt) = self.post_validation(tracker).await?;
441        let response: ValidateResponse = self.decode(response).await?;
442        validate_response_status(response.success, response.error_code)?;
443        if !response.success {
444            let error = Error::SessionValidationRejected {
445                code: response.error_code,
446                name: ErrorCodeTable::Validate.name(response.error_code),
447            };
448            if matches!(response.error_code, 1..=3) {
449                return Err(error);
450            }
451            return Err(Error::AmbiguousSessionValidation);
452        }
453        let new_token = response
454            .new_token
455            .as_deref()
456            .map(|token| validate_token(Some(token)))
457            .transpose()?;
458        if let Some(outcome) = finish_trustworthy_validation(&self.token, attempt, new_token)? {
459            match outcome {
460                UpdateOutcome::Applied => {}
461                UpdateOutcome::Deferred => {
462                    tracing::debug!(
463                        "deferred token rotation until concurrent authentication completes"
464                    );
465                }
466                UpdateOutcome::Stale => {
467                    tracing::debug!(
468                        "discarded token rotation from a stale session-validation response"
469                    );
470                }
471            }
472        }
473        Ok(())
474    }
475
476    /// Retrieves active accounts for the authenticated user.
477    ///
478    /// Returns a [`ProviderList`]; see [`Self::search_accounts`] for the
479    /// presence distinction.
480    ///
481    /// # Errors
482    ///
483    /// Returns an error for authentication, transport, provider, or decode failures.
484    pub async fn search_active_accounts(&self) -> Result<ProviderList<Account>, Error> {
485        self.search_accounts(true).await
486    }
487
488    /// Searches accounts for the authenticated user.
489    ///
490    /// Set `only_active_accounts` to `false` to include inactive accounts. Use
491    /// [`Self::search_active_accounts`] when only active accounts are required.
492    ///
493    /// Returns a [`ProviderList`]: an explicit array, including an
494    /// authoritative empty one, is [`ProviderList::Listed`], while a success
495    /// envelope that omits the field or serializes it as JSON `null` is
496    /// [`ProviderList::Absent`].
497    ///
498    /// # Errors
499    ///
500    /// Returns an error for authentication, transport, provider, or decode failures.
501    pub async fn search_accounts(
502        &self,
503        only_active_accounts: bool,
504    ) -> Result<ProviderList<Account>, Error> {
505        let response: Envelope<AccountsBody> = self
506            .post_authenticated(
507                RateLimitKind::General,
508                "api/Account/search",
509                &AccountSearchRequest {
510                    only_active_accounts,
511                },
512            )
513            .await?;
514        Ok(accepted(response, ErrorCodeTable::SuccessOnly)?.accounts)
515    }
516
517    /// Lists contracts available to the selected live or simulated data feed.
518    ///
519    /// Returns a [`ProviderList`] preserving whether the provider carried the
520    /// array explicitly; see [`Self::search_accounts`] for the distinction.
521    ///
522    /// # Errors
523    ///
524    /// Returns an error for authentication, transport, provider, or decode failures.
525    pub async fn available_contracts(&self, live: bool) -> Result<ProviderList<Contract>, Error> {
526        let response: Envelope<ContractsBody> = self
527            .post_authenticated(
528                RateLimitKind::General,
529                "api/Contract/available",
530                &AvailableContractsRequest { live },
531            )
532            .await?;
533        Ok(accepted(response, ErrorCodeTable::SuccessOnly)?.contracts)
534    }
535
536    /// Searches contracts using provider-native search text.
537    ///
538    /// The provider returns at most 20 matching contracts per request.
539    ///
540    /// Returns a [`ProviderList`] preserving whether the provider carried the
541    /// array explicitly; see [`Self::search_accounts`] for the distinction.
542    ///
543    /// # Errors
544    ///
545    /// Returns an error for authentication, transport, provider, or decode failures.
546    pub async fn search_contracts(
547        &self,
548        request: &SearchContracts,
549    ) -> Result<ProviderList<Contract>, Error> {
550        let response: Envelope<ContractsBody> = self
551            .post_authenticated(RateLimitKind::General, "api/Contract/search", request)
552            .await?;
553        Ok(accepted(response, ErrorCodeTable::SuccessOnly)?.contracts)
554    }
555
556    /// Retrieves one contract by its explicit provider identifier.
557    ///
558    /// # Errors
559    ///
560    /// Returns an error for authentication, transport, provider, or decode failures.
561    pub async fn contract_by_id(&self, contract_id: &crate::ContractId) -> Result<Contract, Error> {
562        let response: Envelope<ContractBody> = self
563            .post_authenticated(
564                RateLimitKind::General,
565                "api/Contract/searchById",
566                &ContractRequest { contract_id },
567            )
568            .await?;
569        Ok(accepted(response, ErrorCodeTable::ContractSearchById)?.contract)
570    }
571
572    /// Retrieves historical bars for an explicit provider contract.
573    ///
574    /// Returns a [`ProviderList`] preserving whether the provider carried the
575    /// array explicitly; see [`Self::search_accounts`] for the distinction.
576    ///
577    /// # Errors
578    ///
579    /// Returns an error for authentication, transport, provider, or decode failures.
580    pub async fn retrieve_bars(
581        &self,
582        request: &HistoryRequest,
583    ) -> Result<ProviderList<Bar>, Error> {
584        let response: Envelope<BarsBody> = self
585            .post_authenticated(RateLimitKind::History, "api/History/retrieveBars", request)
586            .await?;
587        Ok(accepted(response, ErrorCodeTable::Bars)?.bars)
588    }
589
590    /// Searches historical orders for an account and time range.
591    ///
592    /// Returns a [`ProviderList`] preserving whether the provider carried the
593    /// array explicitly; see [`Self::search_accounts`] for the distinction.
594    ///
595    /// # Errors
596    ///
597    /// Returns an error for authentication, transport, provider, or decode failures.
598    pub async fn search_orders(&self, request: &OrderSearch) -> Result<ProviderList<Order>, Error> {
599        let response: Envelope<OrdersBody> = self
600            .post_authenticated(RateLimitKind::General, "api/Order/search", request)
601            .await?;
602        Ok(accepted(response, ErrorCodeTable::OrderSearch)?.orders)
603    }
604
605    /// Retrieves one order by its provider account and order identifiers.
606    ///
607    /// # Errors
608    ///
609    /// Returns an error for authentication, transport, provider, or decode failures.
610    pub async fn order_by_id(
611        &self,
612        account_id: AccountId,
613        order_id: OrderId,
614    ) -> Result<Order, Error> {
615        let response: Envelope<OrderBody> = self
616            .post_authenticated(
617                RateLimitKind::General,
618                "api/Order/searchById",
619                &AccountOrderRequest {
620                    account_id,
621                    order_id,
622                },
623            )
624            .await?;
625        Ok(accepted(response, ErrorCodeTable::OrderSearchById)?.order)
626    }
627
628    /// Searches currently open orders for an account.
629    ///
630    /// The provider excludes `Suspended` orders from this legacy endpoint,
631    /// including inactive bracket children. Use [`Self::query_orders`] and
632    /// explicitly select every non-terminal status needed by the application
633    /// when building a complete working-order reconciliation view.
634    ///
635    /// Returns a [`ProviderList`] preserving whether the provider carried the
636    /// array explicitly; see [`Self::search_accounts`] for the distinction.
637    ///
638    /// # Errors
639    ///
640    /// Returns an error for authentication, transport, provider, or decode failures.
641    pub async fn search_open_orders(
642        &self,
643        account_id: AccountId,
644    ) -> Result<ProviderList<Order>, Error> {
645        let response: Envelope<OrdersBody> = self
646            .post_authenticated(
647                RateLimitKind::General,
648                "api/Order/searchOpen",
649                &AccountRequest { account_id },
650            )
651            .await?;
652        Ok(accepted(response, ErrorCodeTable::OrderSearch)?.orders)
653    }
654
655    /// Queries filtered orders through `/api/Order/v2/query`.
656    ///
657    /// For a complete non-terminal working-order reconciliation, filter on
658    /// [`OrderStatus::Open`](crate::OrderStatus::Open),
659    /// [`OrderStatus::Pending`](crate::OrderStatus::Pending),
660    /// [`OrderStatus::PendingCancellation`](crate::OrderStatus::PendingCancellation), and
661    /// [`OrderStatus::Suspended`](crate::OrderStatus::Suspended). The last of
662    /// these includes inactive bracket children omitted by [`Self::search_open_orders`].
663    ///
664    /// [`OrderPage::orders`] preserves whether the provider carried the array
665    /// explicitly; see [`Self::search_accounts`] for the distinction. Continue
666    /// through every listed page until one is exhausted; request a total
667    /// count when an explicit completion check is useful. A page whose
668    /// orders are [`ProviderList::Absent`] carries no rows and ends the
669    /// pagination loop exactly like an explicit short or empty page: treat
670    /// `Absent` as page exhaustion, not as a retryable gap, and drive loop
671    /// control with [`ProviderList::is_listed`] or the listed row count —
672    /// [`ProviderList::is_explicitly_empty`] is `false` for `Absent`.
673    ///
674    /// # Errors
675    ///
676    /// Returns an error for authentication, transport, provider, or decode failures.
677    pub async fn query_orders(&self, request: &OrderQuery) -> Result<OrderPage, Error> {
678        let response: Envelope<OrderPage> = self
679            .post_authenticated(RateLimitKind::General, "api/Order/v2/query", request)
680            .await?;
681        accepted(response, ErrorCodeTable::OrderSearch)
682    }
683
684    /// Places an order exactly once.
685    ///
686    /// This method never retries. An untrustworthy response is returned as
687    /// [`Error::AmbiguousMutation`]; callers must reconcile open and recent
688    /// orders before deciding whether another submission is safe.
689    /// A returned order ID alone does not imply acceptance: provider code `2`
690    /// can accompany a rejected order record and remains [`Error::Provider`].
691    ///
692    /// # Errors
693    ///
694    /// Returns an error for authentication, provider, decode, or ambiguous
695    /// transport outcomes.
696    pub async fn place_order(&self, request: &PlaceOrder) -> Result<OrderResponse, Error> {
697        let kind = MutationKind::OrderPlacement;
698        let response: Envelope<PlaceOrderBody> = self
699            .post_authenticated_no_retry(RateLimitKind::General, kind.path(), request)
700            .await
701            .map_err(|error| ambiguous_mutation(kind, error))?;
702        let body = accepted(response, kind.error_code_table())
703            .map_err(|error| ambiguous_mutation(kind, error))?;
704        let order_id = body.order_id.ok_or(Error::AmbiguousMutation {
705            operation: kind.operation(),
706            code: None,
707            name: None,
708            origin: Some(AmbiguityOrigin::MissingResult),
709        })?;
710        Ok(OrderResponse { order_id })
711    }
712
713    /// Cancels an order exactly once.
714    ///
715    /// The provider supports direct cancellation only for simulated accounts
716    /// that are not copy-trading followers. Unsupported accounts return
717    /// provider code `6`; the client leaves account eligibility to the provider.
718    /// See the [cancellation reference](https://gateway.docs.projectx.com/docs/api-reference/order/order-cancel/).
719    ///
720    /// This method never retries. If the provider may have admitted the
721    /// request but no trustworthy result is available, it returns
722    /// [`Error::AmbiguousMutation`]. Reconcile provider state before retrying.
723    ///
724    /// # Errors
725    ///
726    /// Returns an error for authentication, provider rejection, local rate
727    /// limiting, or an ambiguous post-admission outcome.
728    pub async fn cancel_order(&self, request: &CancelOrder) -> Result<OperationResponse, Error> {
729        self.mutation(MutationKind::OrderCancellation, request)
730            .await
731    }
732
733    /// Modifies an open order exactly once.
734    ///
735    /// This method never retries. If the provider may have admitted the
736    /// request but no trustworthy result is available, it returns
737    /// [`Error::AmbiguousMutation`]. Reconcile provider state before retrying.
738    ///
739    /// # Errors
740    ///
741    /// Returns an error for authentication, provider rejection, local rate
742    /// limiting, or an ambiguous post-admission outcome.
743    pub async fn modify_order(&self, request: &ModifyOrder) -> Result<OperationResponse, Error> {
744        self.mutation(MutationKind::OrderModification, request)
745            .await
746    }
747
748    /// Searches currently open positions for an account.
749    ///
750    /// Returns a [`ProviderList`]: an explicit array, including an
751    /// authoritative empty one that may establish flatness, is
752    /// [`ProviderList::Listed`], while a success envelope that omits the field
753    /// or serializes it as JSON `null` is [`ProviderList::Absent`] and is not
754    /// evidence of an empty position book.
755    ///
756    /// # Errors
757    ///
758    /// Returns an error for authentication, transport, provider, or decode failures.
759    pub async fn search_open_positions(
760        &self,
761        account_id: AccountId,
762    ) -> Result<ProviderList<Position>, Error> {
763        let response: Envelope<PositionsBody> = self
764            .post_authenticated(
765                RateLimitKind::General,
766                "api/Position/searchOpen",
767                &AccountRequest { account_id },
768            )
769            .await?;
770        Ok(accepted(response, ErrorCodeTable::PositionSearch)?.positions)
771    }
772
773    /// Closes the open position for an explicit account and contract exactly once.
774    ///
775    /// The provider closes with a market order. A rejection carries the code
776    /// text `/api/Position/closeContract` publishes: code `5` is
777    /// `OrderRejected`, so the close did not execute, and code `8` is
778    /// `AccountRejected`, which the provider returns for live accounts.
779    ///
780    /// This method never retries. If the provider may have admitted the
781    /// request but no trustworthy result is available, it returns
782    /// [`Error::AmbiguousMutation`]. Reconcile provider state before retrying.
783    ///
784    /// # Errors
785    ///
786    /// Returns an error for authentication, provider rejection, local rate
787    /// limiting, or an ambiguous post-admission outcome.
788    pub async fn close_contract(
789        &self,
790        request: &CloseContract,
791    ) -> Result<OperationResponse, Error> {
792        self.mutation(MutationKind::PositionClose, request).await
793    }
794
795    /// Partially closes an open position for an account and contract exactly once.
796    ///
797    /// The provider closes with a market order. A rejection carries the code
798    /// text `/api/Position/partialCloseContract` publishes: code `5` is
799    /// `InvalidCloseSize`, which the provider also returns when `size` exceeds
800    /// the open position, code `6` is `OrderRejected`, and code `9` is
801    /// `AccountRejected`, which the provider returns for live accounts.
802    /// The [partial-close reference](https://gateway.docs.projectx.com/docs/api-reference/positions/close-positions-partial/)
803    /// documents that code `6` covers both a symbol that is not tradable at the
804    /// moment and a contract without a current price: the provider records the
805    /// closing order as rejected and returns a null `errorMessage` for both, so
806    /// the response cannot say which cause applied. Retry once the market is
807    /// open and quoting.
808    ///
809    /// This method never retries. If the provider may have admitted the
810    /// request but no trustworthy result is available, it returns
811    /// [`Error::AmbiguousMutation`]. Reconcile provider state before retrying.
812    ///
813    /// # Errors
814    ///
815    /// Returns an error for authentication, provider rejection, local rate
816    /// limiting, or an ambiguous post-admission outcome.
817    pub async fn partial_close_contract(
818        &self,
819        request: &PartialCloseContract,
820    ) -> Result<OperationResponse, Error> {
821        self.mutation(MutationKind::PartialPositionClose, request)
822            .await
823    }
824
825    /// Searches executions for an account and time range.
826    ///
827    /// Returns a [`ProviderList`] preserving whether the provider carried the
828    /// array explicitly; see [`Self::search_accounts`] for the distinction.
829    ///
830    /// # Errors
831    ///
832    /// Returns an error for authentication, transport, provider, or decode failures.
833    pub async fn search_trades(&self, request: &TradeSearch) -> Result<ProviderList<Trade>, Error> {
834        let response: Envelope<TradesBody> = self
835            .post_authenticated(RateLimitKind::General, "api/Trade/search", request)
836            .await?;
837        Ok(accepted(response, ErrorCodeTable::TradeSearch)?.trades)
838    }
839
840    /// Searches trades with optional start and end timestamp bounds.
841    ///
842    /// Unlike [`Self::search_trades`], a [`TradeQuery`] can omit either or both
843    /// timestamp bounds to express the provider's complete request schema.
844    ///
845    /// Returns a [`ProviderList`] preserving whether the provider carried the
846    /// array explicitly; see [`Self::search_accounts`] for the distinction.
847    ///
848    /// # Errors
849    ///
850    /// Returns an error for authentication, transport, provider, or decode failures.
851    pub async fn query_trades(&self, request: &TradeQuery) -> Result<ProviderList<Trade>, Error> {
852        let response: Envelope<TradesBody> = self
853            .post_authenticated(RateLimitKind::General, "api/Trade/search", request)
854            .await?;
855        Ok(accepted(response, ErrorCodeTable::TradeSearch)?.trades)
856    }
857
858    async fn mutation<T>(&self, kind: MutationKind, request: &T) -> Result<OperationResponse, Error>
859    where
860        T: Serialize + ?Sized,
861    {
862        let response: Envelope<EmptyBody> = self
863            .post_authenticated_no_retry(RateLimitKind::General, kind.path(), request)
864            .await
865            .map_err(|error| ambiguous_mutation(kind, error))?;
866        accepted(response, kind.error_code_table())
867            .map_err(|error| ambiguous_mutation(kind, error))?;
868        Ok(OperationResponse)
869    }
870
871    async fn post_unauthenticated<T, R>(&self, path: &str, body: &T) -> Result<R, Error>
872    where
873        T: Serialize + ?Sized,
874        R: DeserializeOwned,
875    {
876        let url = self.endpoints.api_url(path)?;
877        let response = self
878            .http
879            .request(Method::POST, url)
880            .json(body)
881            .send()
882            .await
883            .map_err(Error::Transport)?;
884        self.decode(response).await
885    }
886
887    async fn post_authenticated<T, R>(
888        &self,
889        kind: RateLimitKind,
890        path: &str,
891        body: &T,
892    ) -> Result<R, Error>
893    where
894        T: Serialize + ?Sized,
895        R: DeserializeOwned,
896    {
897        self.post_authenticated_tracked(kind, path, body)
898            .await
899            .map(|(response, _basis)| response)
900    }
901
902    async fn post_authenticated_tracked<T, R>(
903        &self,
904        kind: RateLimitKind,
905        path: &str,
906        body: &T,
907    ) -> Result<(R, TokenSnapshot), Error>
908    where
909        T: Serialize + ?Sized,
910        R: DeserializeOwned,
911    {
912        let encoded = serde_json::to_vec(body).map_err(Error::Encode)?;
913        self.post_authenticated_encoded(kind, path, &encoded).await
914    }
915
916    async fn post_authenticated_encoded<R>(
917        &self,
918        kind: RateLimitKind,
919        path: &str,
920        body: &[u8],
921    ) -> Result<(R, TokenSnapshot), Error>
922    where
923        R: DeserializeOwned,
924    {
925        self.require_authentication()?;
926        let mut attempt = 0;
927        let mut delay = self.retry_initial;
928        loop {
929            self.rate_limits.wait(kind).await;
930            match self.post_authenticated_once(kind, path, body).await {
931                Ok(response) => return Ok(response),
932                Err(error) if attempt < self.max_retries && should_retry(&error) => {
933                    attempt += 1;
934                    tokio::time::sleep(retry_delay(&error, delay)).await;
935                    delay = delay.saturating_mul(2).min(self.retry_max);
936                }
937                Err(error) => return Err(error),
938            }
939        }
940    }
941
942    async fn post_authenticated_no_retry<T, R>(
943        &self,
944        kind: RateLimitKind,
945        path: &str,
946        body: &T,
947    ) -> Result<R, Error>
948    where
949        T: Serialize + ?Sized,
950        R: DeserializeOwned,
951    {
952        let encoded = serde_json::to_vec(body).map_err(Error::Encode)?;
953        self.require_authentication()?;
954        self.rate_limits
955            .try_acquire(kind)
956            .map_err(|retry_after| Error::LocallyRateLimited { kind, retry_after })?;
957        self.post_authenticated_once(kind, path, &encoded)
958            .await
959            .map(|(response, _basis)| response)
960    }
961
962    async fn post_authenticated_once<R>(
963        &self,
964        kind: RateLimitKind,
965        path: &str,
966        body: &[u8],
967    ) -> Result<(R, TokenSnapshot), Error>
968    where
969        R: DeserializeOwned,
970    {
971        let token = self
972            .token
973            .versioned_snapshot()
974            .ok_or(Error::NotAuthenticated)?;
975        let url = self.endpoints.api_url(path)?;
976        let request = self
977            .http
978            .request(Method::POST, url)
979            .bearer_auth(token.expose())
980            .body(body.to_vec());
981        let response = request.send().await.map_err(Error::Transport)?;
982        if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
983            let retry_after = self.apply_provider_cooldown(kind, response.headers());
984            return Err(Error::ProviderRateLimited { kind, retry_after });
985        }
986        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
987            self.token.invalidate_if_current(&token);
988            return Err(Error::UnexpectedStatus { status: 401 });
989        }
990        self.decode(response)
991            .await
992            .map(|response| (response, token))
993    }
994
995    async fn post_validation(
996        &self,
997        tracker: Option<Arc<ValidationTracker>>,
998    ) -> Result<(reqwest::Response, ValidationAttempt), Error> {
999        self.require_authentication()?;
1000        let mut attempt = 0;
1001        let mut delay = self.retry_initial;
1002        loop {
1003            self.rate_limits.wait(RateLimitKind::General).await;
1004            match self.post_validation_once(tracker.as_ref()).await {
1005                Ok(response) => return Ok(response),
1006                Err(error) if attempt < self.max_retries && should_retry(&error) => {
1007                    attempt += 1;
1008                    tokio::time::sleep(retry_delay(&error, delay)).await;
1009                    delay = delay.saturating_mul(2).min(self.retry_max);
1010                }
1011                Err(error) => return Err(error),
1012            }
1013        }
1014    }
1015
1016    async fn post_validation_once(
1017        &self,
1018        tracker: Option<&Arc<ValidationTracker>>,
1019    ) -> Result<(reqwest::Response, ValidationAttempt), Error> {
1020        let url = self.endpoints.api_url("api/Auth/validate")?;
1021        let basis = self
1022            .token
1023            .versioned_snapshot()
1024            .ok_or(Error::NotAuthenticated)?;
1025        let attempt = ValidationAttempt::new(Arc::clone(&self.token), basis, tracker)
1026            .ok_or(Error::NotAuthenticated)?;
1027        let response = match self
1028            .http
1029            .request(Method::POST, url)
1030            .bearer_auth(attempt.basis().expose())
1031            .send()
1032            .await
1033        {
1034            Ok(response) => response,
1035            Err(error) if error.is_connect() || error.is_builder() => {
1036                attempt.disarm();
1037                return Err(Error::Transport(error));
1038            }
1039            Err(_error) => return Err(Error::AmbiguousSessionValidation),
1040        };
1041        if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
1042            let retry_after =
1043                self.apply_provider_cooldown(RateLimitKind::General, response.headers());
1044            attempt.disarm();
1045            return Err(Error::ProviderRateLimited {
1046                kind: RateLimitKind::General,
1047                retry_after,
1048            });
1049        }
1050        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
1051            return Err(Error::UnexpectedStatus { status: 401 });
1052        }
1053        if !response.status().is_success() {
1054            return Err(Error::AmbiguousSessionValidation);
1055        }
1056        Ok((response, attempt))
1057    }
1058
1059    fn apply_provider_cooldown(
1060        &self,
1061        kind: RateLimitKind,
1062        headers: &header::HeaderMap,
1063    ) -> Duration {
1064        let retry_after = parse_retry_after(headers, SystemTime::now())
1065            .unwrap_or_else(|| self.rate_limits.limit(kind).window())
1066            .min(MAX_COOLDOWN);
1067        self.rate_limits.cool_down(kind, retry_after);
1068        retry_after
1069    }
1070
1071    fn require_authentication(&self) -> Result<(), Error> {
1072        if self.token.is_authenticated() {
1073            Ok(())
1074        } else {
1075            Err(Error::NotAuthenticated)
1076        }
1077    }
1078
1079    async fn decode<R>(&self, response: reqwest::Response) -> Result<R, Error>
1080    where
1081        R: DeserializeOwned,
1082    {
1083        let bytes = self.read_bounded_response(response).await?;
1084        serde_json::from_slice(&bytes).map_err(Error::Decode)
1085    }
1086
1087    async fn read_bounded_response(&self, response: reqwest::Response) -> Result<Vec<u8>, Error> {
1088        let status = response.status();
1089        if !status.is_success() {
1090            return Err(Error::UnexpectedStatus {
1091                status: status.as_u16(),
1092            });
1093        }
1094        let content_length = response.content_length();
1095        if content_length.is_some_and(|length| length > self.response_limit as u64) {
1096            return Err(Error::ResponseTooLarge {
1097                limit_bytes: self.response_limit,
1098            });
1099        }
1100
1101        let capacity = content_length
1102            .and_then(|length| usize::try_from(length).ok())
1103            .unwrap_or(0);
1104        let mut bytes = Vec::with_capacity(capacity);
1105        let mut stream = response.bytes_stream();
1106        while let Some(chunk) = stream.next().await {
1107            let chunk = chunk.map_err(Error::Transport)?;
1108            if bytes.len().saturating_add(chunk.len()) > self.response_limit {
1109                return Err(Error::ResponseTooLarge {
1110                    limit_bytes: self.response_limit,
1111                });
1112            }
1113            bytes.extend_from_slice(&chunk);
1114        }
1115        Ok(bytes)
1116    }
1117}
1118
1119impl fmt::Debug for Client {
1120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1121        f.debug_struct("Client")
1122            .field("credentials", &self.credentials)
1123            .field("endpoints", &self.endpoints)
1124            .field("response_limit", &self.response_limit)
1125            .field("max_retries", &self.max_retries)
1126            .field("rate_limits", &self.rate_limits.config())
1127            .finish_non_exhaustive()
1128    }
1129}
1130
1131impl Clone for Client {
1132    fn clone(&self) -> Self {
1133        Self {
1134            credentials: Arc::clone(&self.credentials),
1135            endpoints: self.endpoints.clone(),
1136            http: self.http.clone(),
1137            realtime_http: self.realtime_http.clone(),
1138            token: Arc::clone(&self.token),
1139            rate_limits: Arc::clone(&self.rate_limits),
1140            response_limit: self.response_limit,
1141            realtime_config: self.realtime_config,
1142            max_retries: self.max_retries,
1143            retry_initial: self.retry_initial,
1144            retry_max: self.retry_max,
1145        }
1146    }
1147}
1148
1149/// Guard for a periodic token-validation task.
1150#[must_use = "dropping the validator cancels periodic token validation"]
1151pub struct SessionValidator {
1152    cancellation: CancellationToken,
1153    task: Option<JoinHandle<Result<(), Error>>>,
1154    token: Arc<TokenStore>,
1155    validation_tracker: Arc<ValidationTracker>,
1156}
1157
1158impl SessionValidator {
1159    fn close_validation(&self) -> bool {
1160        let Some(revision) = self.validation_tracker.close() else {
1161            return false;
1162        };
1163        self.token.invalidate_revision_if_current(revision);
1164        true
1165    }
1166
1167    /// Cancels validation and waits for its task to finish.
1168    ///
1169    /// # Errors
1170    ///
1171    /// Returns the terminal validation error if the provider invalidated the
1172    /// session, [`Error::AmbiguousSessionValidation`] when shutdown cancels an
1173    /// admitted validation request, or [`Error::BackgroundTaskFailed`] if the
1174    /// library-owned task panicked or was aborted unexpectedly.
1175    pub async fn shutdown(mut self) -> Result<(), Error> {
1176        let validation_was_active = self.close_validation();
1177        self.cancellation.cancel();
1178        let task_result = if let Some(task) = self.task.take() {
1179            task.await
1180                .map_err(|_join_error| Error::BackgroundTaskFailed {
1181                    task: "session validator",
1182                })?
1183        } else {
1184            Ok(())
1185        };
1186        if validation_was_active {
1187            return Err(Error::AmbiguousSessionValidation);
1188        }
1189        task_result
1190    }
1191
1192    /// Returns whether the validation task has stopped.
1193    #[must_use]
1194    pub fn is_finished(&self) -> bool {
1195        self.task.as_ref().is_none_or(JoinHandle::is_finished)
1196    }
1197}
1198
1199impl fmt::Debug for SessionValidator {
1200    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1201        formatter
1202            .debug_struct("SessionValidator")
1203            .field("cancelled", &self.cancellation.is_cancelled())
1204            .finish_non_exhaustive()
1205    }
1206}
1207
1208impl Drop for SessionValidator {
1209    fn drop(&mut self) {
1210        let _validation_was_active = self.close_validation();
1211        self.cancellation.cancel();
1212        if let Some(task) = self.task.take() {
1213            task.abort();
1214        }
1215    }
1216}
1217
1218/// Builder for [`Client`].
1219#[must_use]
1220pub struct ClientBuilder {
1221    credentials: AuthenticationCredentials,
1222    endpoints: Endpoints,
1223    timeout: Duration,
1224    response_limit: usize,
1225    realtime_config: crate::realtime::RealtimeConfig,
1226    proxy: Option<String>,
1227    max_retries: u32,
1228    retry_initial: Duration,
1229    retry_max: Duration,
1230    rate_limits: Option<RateLimitConfig>,
1231}
1232
1233impl ClientBuilder {
1234    fn new(credentials: AuthenticationCredentials) -> Self {
1235        Self {
1236            credentials,
1237            endpoints: Endpoints::default(),
1238            timeout: DEFAULT_TIMEOUT,
1239            response_limit: DEFAULT_RESPONSE_LIMIT,
1240            realtime_config: crate::realtime::RealtimeConfig::default(),
1241            proxy: None,
1242            max_retries: DEFAULT_MAX_RETRIES,
1243            retry_initial: DEFAULT_RETRY_INITIAL,
1244            retry_max: DEFAULT_RETRY_MAX,
1245            rate_limits: Some(RateLimitConfig::default()),
1246        }
1247    }
1248
1249    /// Uses custom provider endpoints.
1250    pub fn endpoints(mut self, endpoints: Endpoints) -> Self {
1251        self.endpoints = endpoints;
1252        self
1253    }
1254
1255    /// Sets the per-request timeout.
1256    pub fn timeout(mut self, timeout: Duration) -> Self {
1257        self.timeout = timeout;
1258        self
1259    }
1260
1261    /// Sets the maximum decoded HTTP response size.
1262    pub fn response_limit(mut self, bytes: usize) -> Self {
1263        self.response_limit = bytes;
1264        self
1265    }
1266
1267    /// Sets queued outbound real-time messages per hub (default 4,096).
1268    /// A full queue refuses new admission with `SendQueueFull` without sending.
1269    /// Build rejects zero or values outside Tokio's representable permit range.
1270    pub fn realtime_writer_capacity(mut self, capacity: usize) -> Self {
1271        self.realtime_config.writer_capacity = capacity;
1272        self
1273    }
1274
1275    /// Sets simultaneous incomplete real-time invocations per hub (default 4,096).
1276    /// Completed subscriptions consume no slots and have no total-count limit.
1277    /// A full set refuses admission with `PendingInvocationCapacity` before sending.
1278    /// Build rejects zero or values outside Tokio's representable permit range.
1279    pub fn realtime_pending_invocation_capacity(mut self, capacity: usize) -> Self {
1280        self.realtime_config.pending_capacity = capacity;
1281        self
1282    }
1283
1284    /// Sets the real-time invocation completion wait after enqueue (default 15s).
1285    /// A deadline includes writer-queue time, preserves an unknown outcome and
1286    /// never closes the socket or resends the request. Build rejects zero or
1287    /// durations outside Tokio's representable clock range.
1288    pub fn realtime_invocation_timeout(mut self, timeout: Duration) -> Self {
1289        self.realtime_config.invocation_timeout = timeout;
1290        self
1291    }
1292
1293    /// Routes HTTP requests through the supplied proxy URL.
1294    ///
1295    /// Ambient process proxy variables are deliberately ignored. This method
1296    /// is the only way to enable proxying for REST and real-time traffic.
1297    pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
1298        self.proxy = Some(proxy.into());
1299        self
1300    }
1301
1302    /// Sets the number of retries for read/query requests.
1303    ///
1304    /// Money-moving mutations are never retried.
1305    pub fn max_retries(mut self, max_retries: u32) -> Self {
1306        self.max_retries = max_retries;
1307        self
1308    }
1309
1310    /// Sets the initial and maximum exponential retry delays.
1311    pub fn retry_delays(mut self, initial: Duration, maximum: Duration) -> Self {
1312        self.retry_initial = initial;
1313        self.retry_max = maximum;
1314        self
1315    }
1316
1317    /// Replaces the default `ProjectX` REST rate limits.
1318    ///
1319    /// The configured budgets are shared by the built [`Client`] and all of
1320    /// its clones.
1321    pub fn rate_limits(mut self, rate_limits: RateLimitConfig) -> Self {
1322        self.rate_limits = Some(rate_limits);
1323        self
1324    }
1325
1326    /// Disables local REST rate limiting.
1327    ///
1328    /// Use this only when an external coordinator enforces the provider limits
1329    /// across every client and process using the same credentials. Provider
1330    /// HTTP 429 responses still participate in query retry delays.
1331    pub fn disable_rate_limits(mut self) -> Self {
1332        self.rate_limits = None;
1333        self
1334    }
1335
1336    /// Builds the client without performing network I/O.
1337    ///
1338    /// # Errors
1339    ///
1340    /// Returns an error for zero timeout/response limits, an invalid or unsafe
1341    /// proxy combination, or a transport configuration failure.
1342    pub fn build(self) -> Result<Client, Error> {
1343        self.realtime_config.validate()?;
1344        if self.timeout.is_zero()
1345            || self.response_limit == 0
1346            || self.retry_initial.is_zero()
1347            || self.retry_max < self.retry_initial
1348        {
1349            return Err(Error::Configuration(
1350                "timeout, response limit, and retry delays must be valid and non-zero".to_owned(),
1351            ));
1352        }
1353        let now = tokio::time::Instant::now();
1354        if [self.timeout, self.retry_initial, self.retry_max]
1355            .into_iter()
1356            .any(|duration| now.checked_add(duration).is_none())
1357        {
1358            return Err(Error::Configuration(
1359                "timeout and retry delays must be representable by the Tokio clock".to_owned(),
1360            ));
1361        }
1362        if self.proxy.is_some() && self.endpoints.uses_plaintext_transport() {
1363            return Err(Error::Configuration(
1364                "a proxy cannot be combined with plain-HTTP loopback endpoints".to_owned(),
1365            ));
1366        }
1367        let mut headers = header::HeaderMap::new();
1368        headers.insert(
1369            header::USER_AGENT,
1370            header::HeaderValue::from_static(USER_AGENT),
1371        );
1372        headers.insert(
1373            header::ACCEPT,
1374            header::HeaderValue::from_static("text/plain"),
1375        );
1376        headers.insert(
1377            header::CONTENT_TYPE,
1378            header::HeaderValue::from_static("application/json"),
1379        );
1380
1381        let mut builder = reqwest::Client::builder()
1382            .no_proxy()
1383            .retry(reqwest::retry::never())
1384            .default_headers(headers)
1385            .timeout(self.timeout)
1386            .redirect(Policy::none());
1387        let mut realtime_builder = reqwest::Client::builder()
1388            .no_proxy()
1389            .retry(reqwest::retry::never())
1390            .http1_only()
1391            .timeout(self.timeout)
1392            .redirect(Policy::none());
1393        if let Some(proxy) = self.proxy {
1394            let proxy = Proxy::all(proxy).map_err(Error::Transport)?;
1395            builder = builder.proxy(proxy.clone());
1396            realtime_builder = realtime_builder.proxy(proxy);
1397        }
1398        let http = builder.build().map_err(Error::Transport)?;
1399        let realtime_http = realtime_builder.build().map_err(Error::Transport)?;
1400        Ok(Client {
1401            credentials: Arc::new(self.credentials),
1402            endpoints: self.endpoints,
1403            http,
1404            realtime_http,
1405            token: Arc::new(TokenStore::default()),
1406            rate_limits: Arc::new(RateLimits::new(self.rate_limits)),
1407            response_limit: self.response_limit,
1408            realtime_config: self.realtime_config,
1409            max_retries: self.max_retries,
1410            retry_initial: self.retry_initial,
1411            retry_max: self.retry_max,
1412        })
1413    }
1414}
1415
1416/// Accepts a decoded provider envelope, naming any rejection with the code
1417/// text the provider publishes for the responding endpoint.
1418fn accepted<T>(response: Envelope<T>, codes: ErrorCodeTable) -> Result<T, Error> {
1419    match response {
1420        Envelope::Accepted(body) => Ok(body),
1421        Envelope::Rejected { error_code } => Err(ProviderError {
1422            code: error_code,
1423            name: codes.name(error_code),
1424        }
1425        .into()),
1426        Envelope::InconsistentStatus {
1427            success,
1428            error_code,
1429        } => Err(Error::InconsistentResponseStatus {
1430            success,
1431            code: error_code,
1432        }),
1433    }
1434}
1435
1436fn validate_token(raw: Option<&str>) -> Result<String, Error> {
1437    let token = raw
1438        .filter(|value| !value.is_empty())
1439        .ok_or(Error::MissingAuthenticationToken)?;
1440    if !token.bytes().all(|byte| byte.is_ascii_graphic()) {
1441        return Err(Error::InvalidAuthenticationToken);
1442    }
1443    header::HeaderValue::from_str(token).map_err(|_| Error::InvalidAuthenticationToken)?;
1444    Ok(token.to_owned())
1445}
1446
1447fn finish_trustworthy_validation(
1448    store: &TokenStore,
1449    mut attempt: ValidationAttempt,
1450    new_token: Option<String>,
1451) -> Result<Option<UpdateOutcome>, Error> {
1452    if !attempt.claim_trustworthy_completion() {
1453        return Err(Error::AmbiguousSessionValidation);
1454    }
1455    let outcome = new_token.map(|token| store.rotate_if_current(attempt.basis(), token));
1456    attempt.disarm();
1457    Ok(outcome)
1458}
1459
1460fn should_retry(error: &Error) -> bool {
1461    match error {
1462        Error::Transport(error) => error.is_timeout() || error.is_connect(),
1463        Error::ProviderRateLimited { .. } => true,
1464        Error::UnexpectedStatus { status } => *status == 429 || *status >= 500,
1465        _ => false,
1466    }
1467}
1468
1469fn is_terminal_session_error(error: &Error) -> bool {
1470    matches!(
1471        error,
1472        Error::NotAuthenticated
1473            | Error::MissingAuthenticationToken
1474            | Error::InvalidAuthenticationToken
1475            | Error::AmbiguousSessionValidation
1476            | Error::ResponseTooLarge { .. }
1477            | Error::Decode(_)
1478            | Error::InconsistentResponseStatus { .. }
1479            | Error::UnexpectedStatus { status: 401 }
1480            | Error::SessionValidationRejected { code: 1..=3, .. }
1481    )
1482}
1483
1484fn retry_delay(error: &Error, backoff: Duration) -> Duration {
1485    match error {
1486        Error::ProviderRateLimited { retry_after, .. } => backoff.max(*retry_after),
1487        _ => backoff,
1488    }
1489}
1490
1491fn parse_retry_after(headers: &header::HeaderMap, now: SystemTime) -> Option<Duration> {
1492    let raw = headers.get(header::RETRY_AFTER)?.to_str().ok()?.trim();
1493    if let Ok(seconds) = raw.parse::<u64>() {
1494        return Some(Duration::from_secs(seconds).min(MAX_COOLDOWN));
1495    }
1496    let deadline = httpdate::parse_http_date(raw).ok()?;
1497    Some(
1498        deadline
1499            .duration_since(now)
1500            .unwrap_or(Duration::ZERO)
1501            .min(MAX_COOLDOWN),
1502    )
1503}
1504
1505#[derive(Clone, Copy, Debug)]
1506enum MutationKind {
1507    OrderPlacement,
1508    OrderCancellation,
1509    OrderModification,
1510    PositionClose,
1511    PartialPositionClose,
1512}
1513
1514impl MutationKind {
1515    const fn operation(self) -> &'static str {
1516        match self {
1517            Self::OrderPlacement => "order placement",
1518            Self::OrderCancellation => "order cancellation",
1519            Self::OrderModification => "order modification",
1520            Self::PositionClose => "position close",
1521            Self::PartialPositionClose => "partial position close",
1522        }
1523    }
1524
1525    const fn path(self) -> &'static str {
1526        match self {
1527            Self::OrderPlacement => "api/Order/place",
1528            Self::OrderCancellation => "api/Order/cancel",
1529            Self::OrderModification => "api/Order/modify",
1530            Self::PositionClose => "api/Position/closeContract",
1531            Self::PartialPositionClose => "api/Position/partialCloseContract",
1532        }
1533    }
1534
1535    const fn error_code_table(self) -> ErrorCodeTable {
1536        match self {
1537            Self::OrderPlacement => ErrorCodeTable::OrderPlacement,
1538            Self::OrderCancellation => ErrorCodeTable::OrderCancellation,
1539            Self::OrderModification => ErrorCodeTable::OrderModification,
1540            Self::PositionClose => ErrorCodeTable::PositionClose,
1541            Self::PartialPositionClose => ErrorCodeTable::PartialPositionClose,
1542        }
1543    }
1544
1545    const fn is_definitive_rejection(self, code: i32) -> bool {
1546        // These are the endpoint-specific, documented rejection-only codes.
1547        // Pending, unknown, zero-in-a-rejection, and future codes deliberately
1548        // remain ambiguous because the provider may already have acted.
1549        match self {
1550            Self::OrderPlacement => matches!(code, 1..=5 | 8..=10),
1551            Self::OrderCancellation => matches!(code, 1..=3 | 6),
1552            Self::OrderModification => matches!(code, 1..=3 | 6 | 7),
1553            Self::PositionClose => matches!(code, 1..=5 | 8),
1554            Self::PartialPositionClose => matches!(code, 1..=6 | 9),
1555        }
1556    }
1557}
1558
1559/// Maps an untrustworthy mutation outcome to its transport-level origin.
1560///
1561/// The mapping is total: outcomes with a decoded provider rejection are the
1562/// evidence themselves (their callers never consult this function), and every
1563/// other outcome reports a classified origin or
1564/// [`AmbiguityOrigin::Unclassified`], so an ambiguous mutation this crate
1565/// constructs never carries both no code and no origin.
1566fn ambiguity_origin(error: &Error) -> AmbiguityOrigin {
1567    match error {
1568        Error::Transport(failure) => {
1569            if failure.is_timeout() {
1570                AmbiguityOrigin::TransportTimeout
1571            } else if failure.is_connect() {
1572                AmbiguityOrigin::TransportConnect
1573            } else {
1574                AmbiguityOrigin::Transport
1575            }
1576        }
1577        Error::ProviderRateLimited { .. } => AmbiguityOrigin::RateLimited,
1578        Error::UnexpectedStatus { status } => AmbiguityOrigin::HttpStatus(*status),
1579        Error::Decode(_) => AmbiguityOrigin::Decode,
1580        Error::ResponseTooLarge { .. } => AmbiguityOrigin::ResponseTooLarge,
1581        Error::InconsistentResponseStatus { .. } => AmbiguityOrigin::InconsistentStatus,
1582        _ => AmbiguityOrigin::Unclassified,
1583    }
1584}
1585
1586fn ambiguous_mutation(kind: MutationKind, error: Error) -> Error {
1587    match error {
1588        Error::Provider(provider) if kind.is_definitive_rejection(provider.code) => {
1589            Error::Provider(provider)
1590        }
1591        Error::Provider(provider) => Error::AmbiguousMutation {
1592            operation: kind.operation(),
1593            code: Some(provider.code),
1594            name: provider.name,
1595            origin: None,
1596        },
1597        // Every pass-through failure is a definitive pre-send outcome: no
1598        // request left the process, so nothing is ambiguous. `Url` belongs
1599        // here because endpoint construction precedes the network request.
1600        Error::NotAuthenticated
1601        | Error::UnexpectedStatus { status: 401 }
1602        | Error::Configuration(_)
1603        | Error::Encode(_)
1604        | Error::Url(_)
1605        | Error::LocallyRateLimited { .. } => error,
1606        error => Error::AmbiguousMutation {
1607            operation: kind.operation(),
1608            code: None,
1609            name: None,
1610            origin: Some(ambiguity_origin(&error)),
1611        },
1612    }
1613}
1614
1615fn validate_response_status(success: bool, code: i32) -> Result<(), Error> {
1616    if success == (code == 0) {
1617        Ok(())
1618    } else {
1619        Err(Error::InconsistentResponseStatus { success, code })
1620    }
1621}
1622
1623#[derive(Serialize)]
1624#[serde(rename_all = "camelCase")]
1625struct LoginApiKeyRequest<'a> {
1626    user_name: &'a str,
1627    api_key: &'a str,
1628}
1629
1630#[derive(Serialize)]
1631#[serde(rename_all = "camelCase")]
1632struct LoginAppRequest<'a> {
1633    user_name: &'a str,
1634    password: &'a str,
1635    device_id: &'a str,
1636    app_id: &'a str,
1637    verify_key: &'a str,
1638}
1639
1640#[derive(Deserialize)]
1641#[serde(rename_all = "camelCase")]
1642struct LoginResponse {
1643    success: bool,
1644    error_code: i32,
1645    token: Option<String>,
1646}
1647
1648#[derive(Deserialize)]
1649#[serde(rename_all = "camelCase")]
1650struct ValidateResponse {
1651    success: bool,
1652    error_code: i32,
1653    new_token: Option<String>,
1654}
1655
1656#[derive(Serialize)]
1657#[serde(rename_all = "camelCase")]
1658struct AccountSearchRequest {
1659    only_active_accounts: bool,
1660}
1661
1662#[derive(Serialize)]
1663struct AvailableContractsRequest {
1664    live: bool,
1665}
1666
1667#[derive(Serialize)]
1668#[serde(rename_all = "camelCase")]
1669struct AccountRequest {
1670    account_id: AccountId,
1671}
1672
1673#[derive(Serialize)]
1674#[serde(rename_all = "camelCase")]
1675struct AccountOrderRequest {
1676    account_id: AccountId,
1677    order_id: OrderId,
1678}
1679
1680#[derive(Serialize)]
1681#[serde(rename_all = "camelCase")]
1682struct ContractRequest<'a> {
1683    contract_id: &'a crate::ContractId,
1684}
1685
1686#[cfg(test)]
1687mod tests {
1688    use std::time::UNIX_EPOCH;
1689
1690    use super::*;
1691
1692    fn fixture_credentials() -> Credentials {
1693        Credentials::new("synthetic-user", "synthetic-key")
1694            .unwrap_or_else(|error| panic!("fixture credentials must be valid: {error}"))
1695    }
1696
1697    #[test]
1698    fn builder_rejects_durations_outside_the_tokio_clock_range() {
1699        let timeout = Client::builder(fixture_credentials())
1700            .timeout(Duration::MAX)
1701            .build();
1702        assert!(matches!(timeout, Err(Error::Configuration(_))));
1703
1704        let retry = Client::builder(fixture_credentials())
1705            .retry_delays(Duration::from_secs(1), Duration::MAX)
1706            .build();
1707        assert!(matches!(retry, Err(Error::Configuration(_))));
1708    }
1709
1710    #[test]
1711    fn builder_rejects_a_proxy_for_plaintext_loopback_endpoints() {
1712        let endpoints = Endpoints::custom("http://127.0.0.1:8080", "http://[::1]:8080")
1713            .unwrap_or_else(|error| panic!("fixture endpoints must be valid: {error}"));
1714        let client = Client::builder(fixture_credentials())
1715            .endpoints(endpoints)
1716            .proxy("http://127.0.0.1:8888")
1717            .build();
1718
1719        assert!(matches!(client, Err(Error::Configuration(_))));
1720    }
1721
1722    #[test]
1723    fn bearer_token_validation_rejects_whitespace_and_control_bytes() {
1724        assert!(matches!(
1725            validate_token(Some("token with spaces")),
1726            Err(Error::InvalidAuthenticationToken)
1727        ));
1728        assert!(matches!(
1729            validate_token(Some("token\n")),
1730            Err(Error::InvalidAuthenticationToken)
1731        ));
1732        assert!(matches!(
1733            validate_token(Some("")),
1734            Err(Error::MissingAuthenticationToken)
1735        ));
1736        assert_eq!(
1737            validate_token(Some("synthetic.jwt-token_123"))
1738                .unwrap_or_else(|error| panic!("fixture token must be valid: {error}")),
1739            "synthetic.jwt-token_123"
1740        );
1741    }
1742
1743    #[test]
1744    fn validator_shutdown_and_trustworthy_completion_have_one_atomic_winner() {
1745        let losing_store = Arc::new(TokenStore::default());
1746        assert!(
1747            losing_store
1748                .begin_authentication()
1749                .commit("initial-token".to_owned())
1750        );
1751        let losing_tracker = Arc::new(ValidationTracker::default());
1752        let losing_basis = losing_store
1753            .versioned_snapshot()
1754            .unwrap_or_else(|| panic!("fixture token must be installed"));
1755        let losing_attempt = ValidationAttempt::new(
1756            Arc::clone(&losing_store),
1757            losing_basis,
1758            Some(&losing_tracker),
1759        )
1760        .unwrap_or_else(|| panic!("fixture validation must be admitted"));
1761
1762        let closing_revision = losing_tracker
1763            .close()
1764            .unwrap_or_else(|| panic!("shutdown must claim the admitted validation"));
1765        let losing_completion = finish_trustworthy_validation(
1766            &losing_store,
1767            losing_attempt,
1768            Some("rotated-token".to_owned()),
1769        );
1770        losing_store.invalidate_revision_if_current(closing_revision);
1771
1772        assert!(matches!(
1773            losing_completion,
1774            Err(Error::AmbiguousSessionValidation)
1775        ));
1776        assert!(!losing_store.is_authenticated());
1777
1778        let winning_store = Arc::new(TokenStore::default());
1779        assert!(
1780            winning_store
1781                .begin_authentication()
1782                .commit("initial-token".to_owned())
1783        );
1784        let winning_tracker = Arc::new(ValidationTracker::default());
1785        let winning_basis = winning_store
1786            .versioned_snapshot()
1787            .unwrap_or_else(|| panic!("fixture token must be installed"));
1788        let winning_attempt = ValidationAttempt::new(
1789            Arc::clone(&winning_store),
1790            winning_basis,
1791            Some(&winning_tracker),
1792        )
1793        .unwrap_or_else(|| panic!("fixture validation must be admitted"));
1794
1795        let winning_completion = finish_trustworthy_validation(
1796            &winning_store,
1797            winning_attempt,
1798            Some("rotated-token".to_owned()),
1799        )
1800        .unwrap_or_else(|error| panic!("trustworthy completion must win: {error}"));
1801
1802        assert_eq!(winning_completion, Some(UpdateOutcome::Applied));
1803        assert_eq!(winning_tracker.close(), None);
1804        assert_eq!(winning_store.snapshot().as_deref(), Some("rotated-token"));
1805    }
1806
1807    #[test]
1808    fn retry_after_parses_delta_seconds_and_bounds_hostile_values() {
1809        let mut headers = header::HeaderMap::new();
1810        headers.insert(header::RETRY_AFTER, header::HeaderValue::from_static("45"));
1811        assert_eq!(
1812            parse_retry_after(&headers, UNIX_EPOCH),
1813            Some(Duration::from_secs(45))
1814        );
1815
1816        headers.insert(
1817            header::RETRY_AFTER,
1818            header::HeaderValue::from_static("18446744073709551615"),
1819        );
1820        assert_eq!(parse_retry_after(&headers, UNIX_EPOCH), Some(MAX_COOLDOWN));
1821    }
1822
1823    #[test]
1824    fn retry_after_parses_http_dates_without_waiting_for_past_dates() {
1825        let now = UNIX_EPOCH + Duration::from_secs(1_000_000);
1826        let future = now + Duration::from_secs(75);
1827        let mut headers = header::HeaderMap::new();
1828        let future_header = header::HeaderValue::from_str(&httpdate::fmt_http_date(future))
1829            .unwrap_or_else(|error| panic!("fixture header must be valid: {error}"));
1830        headers.insert(header::RETRY_AFTER, future_header);
1831        assert_eq!(
1832            parse_retry_after(&headers, now),
1833            Some(Duration::from_secs(75))
1834        );
1835
1836        let past_header = header::HeaderValue::from_str(&httpdate::fmt_http_date(UNIX_EPOCH))
1837            .unwrap_or_else(|error| panic!("fixture header must be valid: {error}"));
1838        headers.insert(header::RETRY_AFTER, past_header);
1839        assert_eq!(parse_retry_after(&headers, now), Some(Duration::ZERO));
1840    }
1841
1842    #[test]
1843    fn retry_after_rejects_malformed_headers() {
1844        let mut headers = header::HeaderMap::new();
1845        headers.insert(
1846            header::RETRY_AFTER,
1847            header::HeaderValue::from_static("not-a-delay"),
1848        );
1849        assert_eq!(parse_retry_after(&headers, UNIX_EPOCH), None);
1850    }
1851
1852    #[test]
1853    fn provider_retry_delay_uses_the_longer_value_without_adding_delays() {
1854        let provider_delay = Error::ProviderRateLimited {
1855            kind: RateLimitKind::General,
1856            retry_after: Duration::from_secs(20),
1857        };
1858        assert_eq!(
1859            retry_delay(&provider_delay, Duration::from_secs(5)),
1860            Duration::from_secs(20)
1861        );
1862        assert_eq!(
1863            retry_delay(&provider_delay, Duration::from_secs(30)),
1864            Duration::from_secs(30)
1865        );
1866    }
1867
1868    fn untrustworthy_mutation_errors() -> Vec<(Error, AmbiguityOrigin)> {
1869        let decode_error = serde_json::from_str::<()>("not json")
1870            .err()
1871            .unwrap_or_else(|| panic!("fixture JSON must fail to decode"));
1872        vec![
1873            (
1874                Error::UnexpectedStatus { status: 502 },
1875                AmbiguityOrigin::HttpStatus(502),
1876            ),
1877            (
1878                Error::UnexpectedStatus { status: 504 },
1879                AmbiguityOrigin::HttpStatus(504),
1880            ),
1881            (Error::Decode(decode_error), AmbiguityOrigin::Decode),
1882            (
1883                Error::ResponseTooLarge { limit_bytes: 16 },
1884                AmbiguityOrigin::ResponseTooLarge,
1885            ),
1886            (
1887                Error::InconsistentResponseStatus {
1888                    success: true,
1889                    code: 7,
1890                },
1891                AmbiguityOrigin::InconsistentStatus,
1892            ),
1893            (
1894                Error::ProviderRateLimited {
1895                    kind: RateLimitKind::General,
1896                    retry_after: Duration::from_secs(1),
1897                },
1898                AmbiguityOrigin::RateLimited,
1899            ),
1900            (
1901                Error::BackgroundTaskFailed {
1902                    task: "fixture task",
1903                },
1904                AmbiguityOrigin::Unclassified,
1905            ),
1906        ]
1907    }
1908
1909    #[test]
1910    fn ambiguous_mutations_carry_their_failure_origin_and_stay_distinguishable() {
1911        let mut rendered = Vec::new();
1912        for (error, expected_origin) in untrustworthy_mutation_errors() {
1913            let ambiguous = ambiguous_mutation(MutationKind::OrderPlacement, error);
1914            let text = ambiguous.to_string();
1915            let Error::AmbiguousMutation {
1916                operation,
1917                code,
1918                name,
1919                origin,
1920            } = ambiguous
1921            else {
1922                panic!("untrustworthy outcome must stay an ambiguous mutation");
1923            };
1924            assert_eq!(operation, "order placement");
1925            assert_eq!(code, None);
1926            assert_eq!(name, None);
1927            assert_eq!(origin, Some(expected_origin));
1928            rendered.push(text);
1929        }
1930        let unique: std::collections::BTreeSet<_> = rendered.iter().map(String::as_str).collect();
1931        assert_eq!(
1932            unique.len(),
1933            rendered.len(),
1934            "distinct origins must render distinct evidence: {rendered:?}"
1935        );
1936        assert!(rendered[0].contains("HTTP status 502"));
1937        assert!(rendered[1].contains("HTTP status 504"));
1938
1939        // A decoded non-definitive provider rejection is itself the evidence:
1940        // the code is carried and no transport origin is fabricated beside it.
1941        let Error::AmbiguousMutation { code, origin, .. } = ambiguous_mutation(
1942            MutationKind::OrderPlacement,
1943            Error::Provider(ProviderError {
1944                code: 6,
1945                name: Some("OrderPending"),
1946            }),
1947        ) else {
1948            panic!("pending provider rejection must stay ambiguous");
1949        };
1950        assert_eq!(code, Some(6));
1951        assert_eq!(origin, None);
1952    }
1953
1954    #[test]
1955    fn every_constructible_non_passthrough_outcome_carries_exactly_one_evidence_field() {
1956        // `Transport` is absent because `reqwest::Error` has no public
1957        // constructor; every other variant of `Error` that the ambiguity
1958        // mapping can receive is represented here or in the pass-through
1959        // test below.
1960        let errors = || {
1961            vec![
1962                Error::InvalidIdentifier {
1963                    kind: "fixture",
1964                    reason: "fixture",
1965                },
1966                Error::CredentialsRejected {
1967                    code: 1,
1968                    name: None,
1969                },
1970                Error::SessionValidationRejected {
1971                    code: 1,
1972                    name: None,
1973                },
1974                Error::InconsistentResponseStatus {
1975                    success: true,
1976                    code: 7,
1977                },
1978                Error::MissingAuthenticationToken,
1979                Error::InvalidAuthenticationToken,
1980                Error::AmbiguousSessionValidation,
1981                Error::UnexpectedStatusResponse,
1982                Error::BackgroundTaskFailed {
1983                    task: "fixture task",
1984                },
1985                Error::UnexpectedStatus { status: 503 },
1986                Error::ResponseTooLarge { limit_bytes: 16 },
1987                Error::ProviderRateLimited {
1988                    kind: RateLimitKind::General,
1989                    retry_after: Duration::from_secs(1),
1990                },
1991                Error::Provider(ProviderError {
1992                    code: 0,
1993                    name: None,
1994                }),
1995            ]
1996        };
1997        for error in errors() {
1998            let Error::AmbiguousMutation { code, origin, .. } =
1999                ambiguous_mutation(MutationKind::PositionClose, error)
2000            else {
2001                panic!("a non-passthrough outcome must stay an ambiguous mutation");
2002            };
2003            assert!(
2004                code.is_some() != origin.is_some(),
2005                "exactly one of code and origin must be set, got code {code:?} origin {origin:?}"
2006            );
2007        }
2008    }
2009
2010    #[test]
2011    fn pre_send_failures_are_not_ambiguous_mutations() {
2012        let errors = || {
2013            vec![
2014                Error::NotAuthenticated,
2015                Error::Configuration("synthetic".to_owned()),
2016                Error::LocallyRateLimited {
2017                    kind: RateLimitKind::General,
2018                    retry_after: Duration::from_secs(1),
2019                },
2020                Error::Url(
2021                    "::".parse::<url::Url>()
2022                        .err()
2023                        .unwrap_or_else(|| panic!("fixture URL must fail to parse")),
2024                ),
2025            ]
2026        };
2027        for error in errors() {
2028            let expected = error.to_string();
2029            let mapped = ambiguous_mutation(MutationKind::OrderCancellation, error);
2030            assert_eq!(mapped.to_string(), expected);
2031        }
2032    }
2033
2034    #[test]
2035    fn mutation_policies_whitelist_only_documented_definitive_rejections() {
2036        let cases: &[(MutationKind, &[i32], &[i32])] = &[
2037            (
2038                MutationKind::OrderPlacement,
2039                &[1, 2, 3, 4, 5, 8, 9, 10],
2040                &[0, 6, 7, 11, 99],
2041            ),
2042            (
2043                MutationKind::OrderCancellation,
2044                &[1, 2, 3, 6],
2045                &[0, 4, 5, 7, 99],
2046            ),
2047            (
2048                MutationKind::OrderModification,
2049                &[1, 2, 3, 6, 7],
2050                &[0, 4, 5, 8, 99],
2051            ),
2052            (
2053                MutationKind::PositionClose,
2054                &[1, 2, 3, 4, 5, 8],
2055                &[0, 6, 7, 9, 99],
2056            ),
2057            (
2058                MutationKind::PartialPositionClose,
2059                &[1, 2, 3, 4, 5, 6, 9],
2060                &[0, 7, 8, 10, 99],
2061            ),
2062        ];
2063
2064        for &(kind, definitive, ambiguous) in cases {
2065            for &code in definitive {
2066                assert!(
2067                    kind.is_definitive_rejection(code),
2068                    "{kind:?} code {code} must be definitive"
2069                );
2070            }
2071            for &code in ambiguous {
2072                assert!(
2073                    !kind.is_definitive_rejection(code),
2074                    "{kind:?} code {code} must be ambiguous"
2075                );
2076            }
2077        }
2078    }
2079
2080    #[test]
2081    fn published_mutation_code_names_agree_with_the_rejection_policy() {
2082        // The provider documents pending and unknown outcomes as the only
2083        // ambiguous rejections. Every other published code must therefore be
2084        // definitive, and a definitive rejection must always have published
2085        // code text to report.
2086        const AMBIGUOUS_NAMES: &[&str] = &["Pending", "OrderPending", "UnknownError"];
2087
2088        for kind in [
2089            MutationKind::OrderPlacement,
2090            MutationKind::OrderCancellation,
2091            MutationKind::OrderModification,
2092            MutationKind::PositionClose,
2093            MutationKind::PartialPositionClose,
2094        ] {
2095            let table = kind.error_code_table();
2096            for code in 1..=16 {
2097                assert_eq!(
2098                    kind.is_definitive_rejection(code),
2099                    table
2100                        .name(code)
2101                        .is_some_and(|name| !AMBIGUOUS_NAMES.contains(&name)),
2102                    "{kind:?} code {code} must be definitive exactly when its published name is neither pending nor unknown"
2103                );
2104            }
2105        }
2106    }
2107}