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