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    /// whose [`ProviderError::order_id`] carries that record's ID. Every
692    /// definitive rejection also carries the provider's free-form message
693    /// through [`ProviderError::untrusted_message`].
694    ///
695    /// # Errors
696    ///
697    /// Returns an error for authentication, provider, decode, or ambiguous
698    /// transport outcomes.
699    pub async fn place_order(&self, request: &PlaceOrder) -> Result<OrderResponse, Error> {
700        let kind = MutationKind::OrderPlacement;
701        let response: Envelope<PlaceOrderBody, PlaceOrderBody> = self
702            .post_authenticated_no_retry(RateLimitKind::General, kind.path(), request)
703            .await
704            .map_err(|error| ambiguous_mutation(kind, error))?;
705        let body =
706            accepted_with_detail(response, kind.error_code_table(), |detail| detail.order_id)
707                .map_err(|error| ambiguous_mutation(kind, error))?;
708        let order_id = body.order_id.ok_or(Error::AmbiguousMutation {
709            operation: kind.operation(),
710            code: None,
711            name: None,
712            origin: Some(AmbiguityOrigin::MissingResult),
713        })?;
714        Ok(OrderResponse { order_id })
715    }
716
717    /// Cancels an order exactly once.
718    ///
719    /// The provider supports direct cancellation only for simulated accounts
720    /// that are not copy-trading followers. Unsupported accounts return
721    /// provider code `6`; the client leaves account eligibility to the provider.
722    /// See the [cancellation reference](https://gateway.docs.projectx.com/docs/api-reference/order/order-cancel/).
723    ///
724    /// This method never retries. If the provider may have admitted the
725    /// request but no trustworthy result is available, it returns
726    /// [`Error::AmbiguousMutation`]. Reconcile provider state before retrying.
727    ///
728    /// # Errors
729    ///
730    /// Returns an error for authentication, provider rejection, local rate
731    /// limiting, or an ambiguous post-admission outcome.
732    pub async fn cancel_order(&self, request: &CancelOrder) -> Result<OperationResponse, Error> {
733        self.mutation(MutationKind::OrderCancellation, request)
734            .await
735    }
736
737    /// Modifies an open order exactly once.
738    ///
739    /// This method never retries. If the provider may have admitted the
740    /// request but no trustworthy result is available, it returns
741    /// [`Error::AmbiguousMutation`]. Reconcile provider state before retrying.
742    ///
743    /// # Errors
744    ///
745    /// Returns an error for authentication, provider rejection, local rate
746    /// limiting, or an ambiguous post-admission outcome.
747    pub async fn modify_order(&self, request: &ModifyOrder) -> Result<OperationResponse, Error> {
748        self.mutation(MutationKind::OrderModification, request)
749            .await
750    }
751
752    /// Searches currently open positions for an account.
753    ///
754    /// Returns a [`ProviderList`]: an explicit array, including an
755    /// authoritative empty one that may establish flatness, is
756    /// [`ProviderList::Listed`], while a success envelope that omits the field
757    /// or serializes it as JSON `null` is [`ProviderList::Absent`] and is not
758    /// evidence of an empty position book.
759    ///
760    /// # Errors
761    ///
762    /// Returns an error for authentication, transport, provider, or decode failures.
763    pub async fn search_open_positions(
764        &self,
765        account_id: AccountId,
766    ) -> Result<ProviderList<Position>, Error> {
767        let response: Envelope<PositionsBody> = self
768            .post_authenticated(
769                RateLimitKind::General,
770                "api/Position/searchOpen",
771                &AccountRequest { account_id },
772            )
773            .await?;
774        Ok(accepted(response, ErrorCodeTable::PositionSearch)?.positions)
775    }
776
777    /// Closes the open position for an explicit account and contract exactly once.
778    ///
779    /// The provider closes with a market order. A rejection carries the code
780    /// text `/api/Position/closeContract` publishes: code `5` is
781    /// `OrderRejected`, so the close did not execute, and code `8` is
782    /// `AccountRejected`, which the provider returns for live accounts.
783    ///
784    /// This method never retries. If the provider may have admitted the
785    /// request but no trustworthy result is available, it returns
786    /// [`Error::AmbiguousMutation`]. Reconcile provider state before retrying.
787    ///
788    /// # Errors
789    ///
790    /// Returns an error for authentication, provider rejection, local rate
791    /// limiting, or an ambiguous post-admission outcome.
792    pub async fn close_contract(
793        &self,
794        request: &CloseContract,
795    ) -> Result<OperationResponse, Error> {
796        self.mutation(MutationKind::PositionClose, request).await
797    }
798
799    /// Partially closes an open position for an account and contract exactly once.
800    ///
801    /// The provider closes with a market order. A rejection carries the code
802    /// text `/api/Position/partialCloseContract` publishes: code `5` is
803    /// `InvalidCloseSize`, which the provider also returns when `size` exceeds
804    /// the open position, code `6` is `OrderRejected`, and code `9` is
805    /// `AccountRejected`, which the provider returns for live accounts.
806    /// The [partial-close reference](https://gateway.docs.projectx.com/docs/api-reference/positions/close-positions-partial/)
807    /// documents that code `6` covers both a symbol that is not tradable at the
808    /// moment and a contract without a current price: the provider records the
809    /// closing order as rejected and returns a null `errorMessage` for both, so
810    /// the response cannot say which cause applied. Retry once the market is
811    /// open and quoting.
812    ///
813    /// This method never retries. If the provider may have admitted the
814    /// request but no trustworthy result is available, it returns
815    /// [`Error::AmbiguousMutation`]. Reconcile provider state before retrying.
816    ///
817    /// # Errors
818    ///
819    /// Returns an error for authentication, provider rejection, local rate
820    /// limiting, or an ambiguous post-admission outcome.
821    pub async fn partial_close_contract(
822        &self,
823        request: &PartialCloseContract,
824    ) -> Result<OperationResponse, Error> {
825        self.mutation(MutationKind::PartialPositionClose, request)
826            .await
827    }
828
829    /// Searches executions for an account and time range.
830    ///
831    /// Returns a [`ProviderList`] preserving whether the provider carried the
832    /// array explicitly; see [`Self::search_accounts`] for the distinction.
833    ///
834    /// # Errors
835    ///
836    /// Returns an error for authentication, transport, provider, or decode failures.
837    pub async fn search_trades(&self, request: &TradeSearch) -> Result<ProviderList<Trade>, Error> {
838        let response: Envelope<TradesBody> = self
839            .post_authenticated(RateLimitKind::General, "api/Trade/search", request)
840            .await?;
841        Ok(accepted(response, ErrorCodeTable::TradeSearch)?.trades)
842    }
843
844    /// Searches trades with optional start and end timestamp bounds.
845    ///
846    /// Unlike [`Self::search_trades`], a [`TradeQuery`] can omit either or both
847    /// timestamp bounds to express the provider's complete request schema.
848    ///
849    /// Returns a [`ProviderList`] preserving whether the provider carried the
850    /// array explicitly; see [`Self::search_accounts`] for the distinction.
851    ///
852    /// # Errors
853    ///
854    /// Returns an error for authentication, transport, provider, or decode failures.
855    pub async fn query_trades(&self, request: &TradeQuery) -> Result<ProviderList<Trade>, Error> {
856        let response: Envelope<TradesBody> = self
857            .post_authenticated(RateLimitKind::General, "api/Trade/search", request)
858            .await?;
859        Ok(accepted(response, ErrorCodeTable::TradeSearch)?.trades)
860    }
861
862    async fn mutation<T>(&self, kind: MutationKind, request: &T) -> Result<OperationResponse, Error>
863    where
864        T: Serialize + ?Sized,
865    {
866        let response: Envelope<EmptyBody> = self
867            .post_authenticated_no_retry(RateLimitKind::General, kind.path(), request)
868            .await
869            .map_err(|error| ambiguous_mutation(kind, error))?;
870        accepted(response, kind.error_code_table())
871            .map_err(|error| ambiguous_mutation(kind, error))?;
872        Ok(OperationResponse)
873    }
874
875    async fn post_unauthenticated<T, R>(&self, path: &str, body: &T) -> Result<R, Error>
876    where
877        T: Serialize + ?Sized,
878        R: DeserializeOwned,
879    {
880        let url = self.endpoints.api_url(path)?;
881        let response = self
882            .http
883            .request(Method::POST, url)
884            .json(body)
885            .send()
886            .await
887            .map_err(Error::Transport)?;
888        self.decode(response).await
889    }
890
891    async fn post_authenticated<T, R>(
892        &self,
893        kind: RateLimitKind,
894        path: &str,
895        body: &T,
896    ) -> Result<R, Error>
897    where
898        T: Serialize + ?Sized,
899        R: DeserializeOwned,
900    {
901        self.post_authenticated_tracked(kind, path, body)
902            .await
903            .map(|(response, _basis)| response)
904    }
905
906    async fn post_authenticated_tracked<T, R>(
907        &self,
908        kind: RateLimitKind,
909        path: &str,
910        body: &T,
911    ) -> Result<(R, TokenSnapshot), Error>
912    where
913        T: Serialize + ?Sized,
914        R: DeserializeOwned,
915    {
916        let encoded = serde_json::to_vec(body).map_err(Error::Encode)?;
917        self.post_authenticated_encoded(kind, path, &encoded).await
918    }
919
920    async fn post_authenticated_encoded<R>(
921        &self,
922        kind: RateLimitKind,
923        path: &str,
924        body: &[u8],
925    ) -> Result<(R, TokenSnapshot), Error>
926    where
927        R: DeserializeOwned,
928    {
929        self.require_authentication()?;
930        let mut attempt = 0;
931        let mut delay = self.retry_initial;
932        loop {
933            self.rate_limits.wait(kind).await;
934            match self.post_authenticated_once(kind, path, body).await {
935                Ok(response) => return Ok(response),
936                Err(error) if attempt < self.max_retries && should_retry(&error) => {
937                    attempt += 1;
938                    tokio::time::sleep(retry_delay(&error, delay)).await;
939                    delay = delay.saturating_mul(2).min(self.retry_max);
940                }
941                Err(error) => return Err(error),
942            }
943        }
944    }
945
946    async fn post_authenticated_no_retry<T, R>(
947        &self,
948        kind: RateLimitKind,
949        path: &str,
950        body: &T,
951    ) -> Result<R, Error>
952    where
953        T: Serialize + ?Sized,
954        R: DeserializeOwned,
955    {
956        let encoded = serde_json::to_vec(body).map_err(Error::Encode)?;
957        self.require_authentication()?;
958        self.rate_limits
959            .try_acquire(kind)
960            .map_err(|retry_after| Error::LocallyRateLimited { kind, retry_after })?;
961        self.post_authenticated_once(kind, path, &encoded)
962            .await
963            .map(|(response, _basis)| response)
964    }
965
966    async fn post_authenticated_once<R>(
967        &self,
968        kind: RateLimitKind,
969        path: &str,
970        body: &[u8],
971    ) -> Result<(R, TokenSnapshot), Error>
972    where
973        R: DeserializeOwned,
974    {
975        let token = self
976            .token
977            .versioned_snapshot()
978            .ok_or(Error::NotAuthenticated)?;
979        let url = self.endpoints.api_url(path)?;
980        let request = self
981            .http
982            .request(Method::POST, url)
983            .bearer_auth(token.expose())
984            .body(body.to_vec());
985        let response = request.send().await.map_err(Error::Transport)?;
986        if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
987            let retry_after = self.apply_provider_cooldown(kind, response.headers());
988            return Err(Error::ProviderRateLimited { kind, retry_after });
989        }
990        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
991            self.token.invalidate_if_current(&token);
992            return Err(Error::UnexpectedStatus { status: 401 });
993        }
994        self.decode(response)
995            .await
996            .map(|response| (response, token))
997    }
998
999    async fn post_validation(
1000        &self,
1001        tracker: Option<Arc<ValidationTracker>>,
1002    ) -> Result<(reqwest::Response, ValidationAttempt), Error> {
1003        self.require_authentication()?;
1004        let mut attempt = 0;
1005        let mut delay = self.retry_initial;
1006        loop {
1007            self.rate_limits.wait(RateLimitKind::General).await;
1008            match self.post_validation_once(tracker.as_ref()).await {
1009                Ok(response) => return Ok(response),
1010                Err(error) if attempt < self.max_retries && should_retry(&error) => {
1011                    attempt += 1;
1012                    tokio::time::sleep(retry_delay(&error, delay)).await;
1013                    delay = delay.saturating_mul(2).min(self.retry_max);
1014                }
1015                Err(error) => return Err(error),
1016            }
1017        }
1018    }
1019
1020    async fn post_validation_once(
1021        &self,
1022        tracker: Option<&Arc<ValidationTracker>>,
1023    ) -> Result<(reqwest::Response, ValidationAttempt), Error> {
1024        let url = self.endpoints.api_url("api/Auth/validate")?;
1025        let basis = self
1026            .token
1027            .versioned_snapshot()
1028            .ok_or(Error::NotAuthenticated)?;
1029        let attempt = ValidationAttempt::new(Arc::clone(&self.token), basis, tracker)
1030            .ok_or(Error::NotAuthenticated)?;
1031        let response = match self
1032            .http
1033            .request(Method::POST, url)
1034            .bearer_auth(attempt.basis().expose())
1035            .send()
1036            .await
1037        {
1038            Ok(response) => response,
1039            Err(error) if error.is_connect() || error.is_builder() => {
1040                attempt.disarm();
1041                return Err(Error::Transport(error));
1042            }
1043            Err(_error) => return Err(Error::AmbiguousSessionValidation),
1044        };
1045        if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
1046            let retry_after =
1047                self.apply_provider_cooldown(RateLimitKind::General, response.headers());
1048            attempt.disarm();
1049            return Err(Error::ProviderRateLimited {
1050                kind: RateLimitKind::General,
1051                retry_after,
1052            });
1053        }
1054        if response.status() == reqwest::StatusCode::UNAUTHORIZED {
1055            return Err(Error::UnexpectedStatus { status: 401 });
1056        }
1057        if !response.status().is_success() {
1058            return Err(Error::AmbiguousSessionValidation);
1059        }
1060        Ok((response, attempt))
1061    }
1062
1063    fn apply_provider_cooldown(
1064        &self,
1065        kind: RateLimitKind,
1066        headers: &header::HeaderMap,
1067    ) -> Duration {
1068        let retry_after = parse_retry_after(headers, SystemTime::now())
1069            .unwrap_or_else(|| self.rate_limits.limit(kind).window())
1070            .min(MAX_COOLDOWN);
1071        self.rate_limits.cool_down(kind, retry_after);
1072        retry_after
1073    }
1074
1075    fn require_authentication(&self) -> Result<(), Error> {
1076        if self.token.is_authenticated() {
1077            Ok(())
1078        } else {
1079            Err(Error::NotAuthenticated)
1080        }
1081    }
1082
1083    async fn decode<R>(&self, response: reqwest::Response) -> Result<R, Error>
1084    where
1085        R: DeserializeOwned,
1086    {
1087        let bytes = self.read_bounded_response(response).await?;
1088        serde_json::from_slice(&bytes).map_err(Error::Decode)
1089    }
1090
1091    async fn read_bounded_response(&self, response: reqwest::Response) -> Result<Vec<u8>, Error> {
1092        let status = response.status();
1093        if !status.is_success() {
1094            return Err(Error::UnexpectedStatus {
1095                status: status.as_u16(),
1096            });
1097        }
1098        let content_length = response.content_length();
1099        if content_length.is_some_and(|length| length > self.response_limit as u64) {
1100            return Err(Error::ResponseTooLarge {
1101                limit_bytes: self.response_limit,
1102            });
1103        }
1104
1105        let capacity = content_length
1106            .and_then(|length| usize::try_from(length).ok())
1107            .unwrap_or(0);
1108        let mut bytes = Vec::with_capacity(capacity);
1109        let mut stream = response.bytes_stream();
1110        while let Some(chunk) = stream.next().await {
1111            let chunk = chunk.map_err(Error::Transport)?;
1112            if bytes.len().saturating_add(chunk.len()) > self.response_limit {
1113                return Err(Error::ResponseTooLarge {
1114                    limit_bytes: self.response_limit,
1115                });
1116            }
1117            bytes.extend_from_slice(&chunk);
1118        }
1119        Ok(bytes)
1120    }
1121}
1122
1123impl fmt::Debug for Client {
1124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1125        f.debug_struct("Client")
1126            .field("credentials", &self.credentials)
1127            .field("endpoints", &self.endpoints)
1128            .field("response_limit", &self.response_limit)
1129            .field("max_retries", &self.max_retries)
1130            .field("rate_limits", &self.rate_limits.config())
1131            .finish_non_exhaustive()
1132    }
1133}
1134
1135impl Clone for Client {
1136    fn clone(&self) -> Self {
1137        Self {
1138            credentials: Arc::clone(&self.credentials),
1139            endpoints: self.endpoints.clone(),
1140            http: self.http.clone(),
1141            realtime_http: self.realtime_http.clone(),
1142            token: Arc::clone(&self.token),
1143            rate_limits: Arc::clone(&self.rate_limits),
1144            response_limit: self.response_limit,
1145            realtime_config: self.realtime_config,
1146            max_retries: self.max_retries,
1147            retry_initial: self.retry_initial,
1148            retry_max: self.retry_max,
1149        }
1150    }
1151}
1152
1153/// Guard for a periodic token-validation task.
1154#[must_use = "dropping the validator cancels periodic token validation"]
1155pub struct SessionValidator {
1156    cancellation: CancellationToken,
1157    task: Option<JoinHandle<Result<(), Error>>>,
1158    token: Arc<TokenStore>,
1159    validation_tracker: Arc<ValidationTracker>,
1160}
1161
1162impl SessionValidator {
1163    fn close_validation(&self) -> bool {
1164        let Some(revision) = self.validation_tracker.close() else {
1165            return false;
1166        };
1167        self.token.invalidate_revision_if_current(revision);
1168        true
1169    }
1170
1171    /// Cancels validation and waits for its task to finish.
1172    ///
1173    /// # Errors
1174    ///
1175    /// Returns the terminal validation error if the provider invalidated the
1176    /// session, [`Error::AmbiguousSessionValidation`] when shutdown cancels an
1177    /// admitted validation request, or [`Error::BackgroundTaskFailed`] if the
1178    /// library-owned task panicked or was aborted unexpectedly.
1179    pub async fn shutdown(mut self) -> Result<(), Error> {
1180        let validation_was_active = self.close_validation();
1181        self.cancellation.cancel();
1182        let task_result = if let Some(task) = self.task.take() {
1183            task.await
1184                .map_err(|_join_error| Error::BackgroundTaskFailed {
1185                    task: "session validator",
1186                })?
1187        } else {
1188            Ok(())
1189        };
1190        if validation_was_active {
1191            return Err(Error::AmbiguousSessionValidation);
1192        }
1193        task_result
1194    }
1195
1196    /// Returns whether the validation task has stopped.
1197    #[must_use]
1198    pub fn is_finished(&self) -> bool {
1199        self.task.as_ref().is_none_or(JoinHandle::is_finished)
1200    }
1201}
1202
1203impl fmt::Debug for SessionValidator {
1204    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1205        formatter
1206            .debug_struct("SessionValidator")
1207            .field("cancelled", &self.cancellation.is_cancelled())
1208            .finish_non_exhaustive()
1209    }
1210}
1211
1212impl Drop for SessionValidator {
1213    fn drop(&mut self) {
1214        let _validation_was_active = self.close_validation();
1215        self.cancellation.cancel();
1216        if let Some(task) = self.task.take() {
1217            task.abort();
1218        }
1219    }
1220}
1221
1222/// Builder for [`Client`].
1223#[must_use]
1224pub struct ClientBuilder {
1225    credentials: AuthenticationCredentials,
1226    endpoints: Endpoints,
1227    timeout: Duration,
1228    response_limit: usize,
1229    realtime_config: crate::realtime::RealtimeConfig,
1230    proxy: Option<String>,
1231    max_retries: u32,
1232    retry_initial: Duration,
1233    retry_max: Duration,
1234    rate_limits: Option<RateLimitConfig>,
1235}
1236
1237impl ClientBuilder {
1238    fn new(credentials: AuthenticationCredentials) -> Self {
1239        Self {
1240            credentials,
1241            endpoints: Endpoints::default(),
1242            timeout: DEFAULT_TIMEOUT,
1243            response_limit: DEFAULT_RESPONSE_LIMIT,
1244            realtime_config: crate::realtime::RealtimeConfig::default(),
1245            proxy: None,
1246            max_retries: DEFAULT_MAX_RETRIES,
1247            retry_initial: DEFAULT_RETRY_INITIAL,
1248            retry_max: DEFAULT_RETRY_MAX,
1249            rate_limits: Some(RateLimitConfig::default()),
1250        }
1251    }
1252
1253    /// Uses custom provider endpoints.
1254    pub fn endpoints(mut self, endpoints: Endpoints) -> Self {
1255        self.endpoints = endpoints;
1256        self
1257    }
1258
1259    /// Sets the per-request timeout.
1260    pub fn timeout(mut self, timeout: Duration) -> Self {
1261        self.timeout = timeout;
1262        self
1263    }
1264
1265    /// Sets the maximum decoded HTTP response size.
1266    pub fn response_limit(mut self, bytes: usize) -> Self {
1267        self.response_limit = bytes;
1268        self
1269    }
1270
1271    /// Sets queued outbound real-time messages per hub (default 4,096).
1272    /// A full queue refuses new admission with `SendQueueFull` without sending.
1273    /// Build rejects zero or values outside Tokio's representable permit range.
1274    pub fn realtime_writer_capacity(mut self, capacity: usize) -> Self {
1275        self.realtime_config.writer_capacity = capacity;
1276        self
1277    }
1278
1279    /// Sets simultaneous incomplete real-time invocations per hub (default 4,096).
1280    /// Completed subscriptions consume no slots and have no total-count limit.
1281    /// A full set refuses admission with `PendingInvocationCapacity` before sending.
1282    /// Build rejects zero or values outside Tokio's representable permit range.
1283    pub fn realtime_pending_invocation_capacity(mut self, capacity: usize) -> Self {
1284        self.realtime_config.pending_capacity = capacity;
1285        self
1286    }
1287
1288    /// Sets the real-time invocation completion wait after enqueue (default 15s).
1289    /// A deadline includes writer-queue time, preserves an unknown outcome and
1290    /// never closes the socket or resends the request. Build rejects zero or
1291    /// durations outside Tokio's representable clock range.
1292    pub fn realtime_invocation_timeout(mut self, timeout: Duration) -> Self {
1293        self.realtime_config.invocation_timeout = timeout;
1294        self
1295    }
1296
1297    /// Routes HTTP requests through the supplied proxy URL.
1298    ///
1299    /// Ambient process proxy variables are deliberately ignored. This method
1300    /// is the only way to enable proxying for REST and real-time traffic.
1301    pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
1302        self.proxy = Some(proxy.into());
1303        self
1304    }
1305
1306    /// Sets the number of retries for read/query requests.
1307    ///
1308    /// Money-moving mutations are never retried.
1309    pub fn max_retries(mut self, max_retries: u32) -> Self {
1310        self.max_retries = max_retries;
1311        self
1312    }
1313
1314    /// Sets the initial and maximum exponential retry delays.
1315    pub fn retry_delays(mut self, initial: Duration, maximum: Duration) -> Self {
1316        self.retry_initial = initial;
1317        self.retry_max = maximum;
1318        self
1319    }
1320
1321    /// Replaces the default `ProjectX` REST rate limits.
1322    ///
1323    /// The configured budgets are shared by the built [`Client`] and all of
1324    /// its clones.
1325    pub fn rate_limits(mut self, rate_limits: RateLimitConfig) -> Self {
1326        self.rate_limits = Some(rate_limits);
1327        self
1328    }
1329
1330    /// Disables local REST rate limiting.
1331    ///
1332    /// Use this only when an external coordinator enforces the provider limits
1333    /// across every client and process using the same credentials. Provider
1334    /// HTTP 429 responses still participate in query retry delays.
1335    pub fn disable_rate_limits(mut self) -> Self {
1336        self.rate_limits = None;
1337        self
1338    }
1339
1340    /// Builds the client without performing network I/O.
1341    ///
1342    /// # Errors
1343    ///
1344    /// Returns an error for zero timeout/response limits, an invalid or unsafe
1345    /// proxy combination, or a transport configuration failure.
1346    pub fn build(self) -> Result<Client, Error> {
1347        self.realtime_config.validate()?;
1348        if self.timeout.is_zero()
1349            || self.response_limit == 0
1350            || self.retry_initial.is_zero()
1351            || self.retry_max < self.retry_initial
1352        {
1353            return Err(Error::Configuration(
1354                "timeout, response limit, and retry delays must be valid and non-zero".to_owned(),
1355            ));
1356        }
1357        let now = tokio::time::Instant::now();
1358        if [self.timeout, self.retry_initial, self.retry_max]
1359            .into_iter()
1360            .any(|duration| now.checked_add(duration).is_none())
1361        {
1362            return Err(Error::Configuration(
1363                "timeout and retry delays must be representable by the Tokio clock".to_owned(),
1364            ));
1365        }
1366        if self.proxy.is_some() && self.endpoints.uses_plaintext_transport() {
1367            return Err(Error::Configuration(
1368                "a proxy cannot be combined with plain-HTTP loopback endpoints".to_owned(),
1369            ));
1370        }
1371        let mut headers = header::HeaderMap::new();
1372        headers.insert(
1373            header::USER_AGENT,
1374            header::HeaderValue::from_static(USER_AGENT),
1375        );
1376        headers.insert(
1377            header::ACCEPT,
1378            header::HeaderValue::from_static("text/plain"),
1379        );
1380        headers.insert(
1381            header::CONTENT_TYPE,
1382            header::HeaderValue::from_static("application/json"),
1383        );
1384
1385        let mut builder = reqwest::Client::builder()
1386            .no_proxy()
1387            .retry(reqwest::retry::never())
1388            .default_headers(headers)
1389            .timeout(self.timeout)
1390            .redirect(Policy::none());
1391        let mut realtime_builder = reqwest::Client::builder()
1392            .no_proxy()
1393            .retry(reqwest::retry::never())
1394            .http1_only()
1395            .timeout(self.timeout)
1396            .redirect(Policy::none());
1397        if let Some(proxy) = self.proxy {
1398            let proxy = Proxy::all(proxy).map_err(Error::Transport)?;
1399            builder = builder.proxy(proxy.clone());
1400            realtime_builder = realtime_builder.proxy(proxy);
1401        }
1402        let http = builder.build().map_err(Error::Transport)?;
1403        let realtime_http = realtime_builder.build().map_err(Error::Transport)?;
1404        Ok(Client {
1405            credentials: Arc::new(self.credentials),
1406            endpoints: self.endpoints,
1407            http,
1408            realtime_http,
1409            token: Arc::new(TokenStore::default()),
1410            rate_limits: Arc::new(RateLimits::new(self.rate_limits)),
1411            response_limit: self.response_limit,
1412            realtime_config: self.realtime_config,
1413            max_retries: self.max_retries,
1414            retry_initial: self.retry_initial,
1415            retry_max: self.retry_max,
1416        })
1417    }
1418}
1419
1420/// Accepts a decoded provider envelope, naming any rejection with the code
1421/// text the provider publishes for the responding endpoint.
1422fn accepted<T>(response: Envelope<T>, codes: ErrorCodeTable) -> Result<T, Error> {
1423    accepted_with_detail(response, codes, |_| None)
1424}
1425
1426/// Accepts a decoded provider envelope like [`accepted`], also carrying the
1427/// order record an endpoint returns beside its rejection.
1428fn accepted_with_detail<T, R>(
1429    response: Envelope<T, R>,
1430    codes: ErrorCodeTable,
1431    rejected_order: fn(R) -> Option<OrderId>,
1432) -> Result<T, Error> {
1433    match response {
1434        Envelope::Accepted(body) => Ok(body),
1435        Envelope::Rejected {
1436            error_code,
1437            error_message,
1438            detail,
1439        } => Err(ProviderError::new(
1440            error_code,
1441            codes.name(error_code),
1442            rejected_order(detail),
1443            error_message,
1444        )
1445        .into()),
1446        Envelope::InconsistentStatus {
1447            success,
1448            error_code,
1449        } => Err(Error::InconsistentResponseStatus {
1450            success,
1451            code: error_code,
1452        }),
1453    }
1454}
1455
1456fn validate_token(raw: Option<&str>) -> Result<String, Error> {
1457    let token = raw
1458        .filter(|value| !value.is_empty())
1459        .ok_or(Error::MissingAuthenticationToken)?;
1460    if !token.bytes().all(|byte| byte.is_ascii_graphic()) {
1461        return Err(Error::InvalidAuthenticationToken);
1462    }
1463    header::HeaderValue::from_str(token).map_err(|_| Error::InvalidAuthenticationToken)?;
1464    Ok(token.to_owned())
1465}
1466
1467fn finish_trustworthy_validation(
1468    store: &TokenStore,
1469    mut attempt: ValidationAttempt,
1470    new_token: Option<String>,
1471) -> Result<Option<UpdateOutcome>, Error> {
1472    if !attempt.claim_trustworthy_completion() {
1473        return Err(Error::AmbiguousSessionValidation);
1474    }
1475    let outcome = new_token.map(|token| store.rotate_if_current(attempt.basis(), token));
1476    attempt.disarm();
1477    Ok(outcome)
1478}
1479
1480fn should_retry(error: &Error) -> bool {
1481    match error {
1482        Error::Transport(error) => error.is_timeout() || error.is_connect(),
1483        Error::ProviderRateLimited { .. } => true,
1484        Error::UnexpectedStatus { status } => *status == 429 || *status >= 500,
1485        _ => false,
1486    }
1487}
1488
1489fn is_terminal_session_error(error: &Error) -> bool {
1490    matches!(
1491        error,
1492        Error::NotAuthenticated
1493            | Error::MissingAuthenticationToken
1494            | Error::InvalidAuthenticationToken
1495            | Error::AmbiguousSessionValidation
1496            | Error::ResponseTooLarge { .. }
1497            | Error::Decode(_)
1498            | Error::InconsistentResponseStatus { .. }
1499            | Error::UnexpectedStatus { status: 401 }
1500            | Error::SessionValidationRejected { code: 1..=3, .. }
1501    )
1502}
1503
1504fn retry_delay(error: &Error, backoff: Duration) -> Duration {
1505    match error {
1506        Error::ProviderRateLimited { retry_after, .. } => backoff.max(*retry_after),
1507        _ => backoff,
1508    }
1509}
1510
1511fn parse_retry_after(headers: &header::HeaderMap, now: SystemTime) -> Option<Duration> {
1512    let raw = headers.get(header::RETRY_AFTER)?.to_str().ok()?.trim();
1513    if let Ok(seconds) = raw.parse::<u64>() {
1514        return Some(Duration::from_secs(seconds).min(MAX_COOLDOWN));
1515    }
1516    let deadline = httpdate::parse_http_date(raw).ok()?;
1517    Some(
1518        deadline
1519            .duration_since(now)
1520            .unwrap_or(Duration::ZERO)
1521            .min(MAX_COOLDOWN),
1522    )
1523}
1524
1525#[derive(Clone, Copy, Debug)]
1526enum MutationKind {
1527    OrderPlacement,
1528    OrderCancellation,
1529    OrderModification,
1530    PositionClose,
1531    PartialPositionClose,
1532}
1533
1534impl MutationKind {
1535    const fn operation(self) -> &'static str {
1536        match self {
1537            Self::OrderPlacement => "order placement",
1538            Self::OrderCancellation => "order cancellation",
1539            Self::OrderModification => "order modification",
1540            Self::PositionClose => "position close",
1541            Self::PartialPositionClose => "partial position close",
1542        }
1543    }
1544
1545    const fn path(self) -> &'static str {
1546        match self {
1547            Self::OrderPlacement => "api/Order/place",
1548            Self::OrderCancellation => "api/Order/cancel",
1549            Self::OrderModification => "api/Order/modify",
1550            Self::PositionClose => "api/Position/closeContract",
1551            Self::PartialPositionClose => "api/Position/partialCloseContract",
1552        }
1553    }
1554
1555    const fn error_code_table(self) -> ErrorCodeTable {
1556        match self {
1557            Self::OrderPlacement => ErrorCodeTable::OrderPlacement,
1558            Self::OrderCancellation => ErrorCodeTable::OrderCancellation,
1559            Self::OrderModification => ErrorCodeTable::OrderModification,
1560            Self::PositionClose => ErrorCodeTable::PositionClose,
1561            Self::PartialPositionClose => ErrorCodeTable::PartialPositionClose,
1562        }
1563    }
1564
1565    const fn is_definitive_rejection(self, code: i32) -> bool {
1566        // These are the endpoint-specific, documented rejection-only codes.
1567        // Pending, unknown, zero-in-a-rejection, and future codes deliberately
1568        // remain ambiguous because the provider may already have acted.
1569        match self {
1570            Self::OrderPlacement => matches!(code, 1..=5 | 8..=10),
1571            Self::OrderCancellation => matches!(code, 1..=3 | 6),
1572            Self::OrderModification => matches!(code, 1..=3 | 6 | 7),
1573            Self::PositionClose => matches!(code, 1..=5 | 8),
1574            Self::PartialPositionClose => matches!(code, 1..=6 | 9),
1575        }
1576    }
1577}
1578
1579/// Maps an untrustworthy mutation outcome to its transport-level origin.
1580///
1581/// The mapping is total: outcomes with a decoded provider rejection are the
1582/// evidence themselves (their callers never consult this function), and every
1583/// other outcome reports a classified origin or
1584/// [`AmbiguityOrigin::Unclassified`], so an ambiguous mutation this crate
1585/// constructs never carries both no code and no origin.
1586fn ambiguity_origin(error: &Error) -> AmbiguityOrigin {
1587    match error {
1588        Error::Transport(failure) => {
1589            if failure.is_timeout() {
1590                AmbiguityOrigin::TransportTimeout
1591            } else if failure.is_connect() {
1592                AmbiguityOrigin::TransportConnect
1593            } else {
1594                AmbiguityOrigin::Transport
1595            }
1596        }
1597        Error::ProviderRateLimited { .. } => AmbiguityOrigin::RateLimited,
1598        Error::UnexpectedStatus { status } => AmbiguityOrigin::HttpStatus(*status),
1599        Error::Decode(_) => AmbiguityOrigin::Decode,
1600        Error::ResponseTooLarge { .. } => AmbiguityOrigin::ResponseTooLarge,
1601        Error::InconsistentResponseStatus { .. } => AmbiguityOrigin::InconsistentStatus,
1602        _ => AmbiguityOrigin::Unclassified,
1603    }
1604}
1605
1606fn ambiguous_mutation(kind: MutationKind, error: Error) -> Error {
1607    match error {
1608        Error::Provider(provider) if kind.is_definitive_rejection(provider.code) => {
1609            Error::Provider(provider)
1610        }
1611        Error::Provider(provider) => Error::AmbiguousMutation {
1612            operation: kind.operation(),
1613            code: Some(provider.code),
1614            name: provider.name,
1615            origin: None,
1616        },
1617        // Every pass-through failure is a definitive pre-send outcome: no
1618        // request left the process, so nothing is ambiguous. `Url` belongs
1619        // here because endpoint construction precedes the network request.
1620        Error::NotAuthenticated
1621        | Error::UnexpectedStatus { status: 401 }
1622        | Error::Configuration(_)
1623        | Error::Encode(_)
1624        | Error::Url(_)
1625        | Error::LocallyRateLimited { .. } => error,
1626        error => Error::AmbiguousMutation {
1627            operation: kind.operation(),
1628            code: None,
1629            name: None,
1630            origin: Some(ambiguity_origin(&error)),
1631        },
1632    }
1633}
1634
1635fn validate_response_status(success: bool, code: i32) -> Result<(), Error> {
1636    if success == (code == 0) {
1637        Ok(())
1638    } else {
1639        Err(Error::InconsistentResponseStatus { success, code })
1640    }
1641}
1642
1643#[derive(Serialize)]
1644#[serde(rename_all = "camelCase")]
1645struct LoginApiKeyRequest<'a> {
1646    user_name: &'a str,
1647    api_key: &'a str,
1648}
1649
1650#[derive(Serialize)]
1651#[serde(rename_all = "camelCase")]
1652struct LoginAppRequest<'a> {
1653    user_name: &'a str,
1654    password: &'a str,
1655    device_id: &'a str,
1656    app_id: &'a str,
1657    verify_key: &'a str,
1658}
1659
1660#[derive(Deserialize)]
1661#[serde(rename_all = "camelCase")]
1662struct LoginResponse {
1663    success: bool,
1664    error_code: i32,
1665    token: Option<String>,
1666}
1667
1668#[derive(Deserialize)]
1669#[serde(rename_all = "camelCase")]
1670struct ValidateResponse {
1671    success: bool,
1672    error_code: i32,
1673    new_token: Option<String>,
1674}
1675
1676#[derive(Serialize)]
1677#[serde(rename_all = "camelCase")]
1678struct AccountSearchRequest {
1679    only_active_accounts: bool,
1680}
1681
1682#[derive(Serialize)]
1683struct AvailableContractsRequest {
1684    live: bool,
1685}
1686
1687#[derive(Serialize)]
1688#[serde(rename_all = "camelCase")]
1689struct AccountRequest {
1690    account_id: AccountId,
1691}
1692
1693#[derive(Serialize)]
1694#[serde(rename_all = "camelCase")]
1695struct AccountOrderRequest {
1696    account_id: AccountId,
1697    order_id: OrderId,
1698}
1699
1700#[derive(Serialize)]
1701#[serde(rename_all = "camelCase")]
1702struct ContractRequest<'a> {
1703    contract_id: &'a crate::ContractId,
1704}
1705
1706#[cfg(test)]
1707mod tests {
1708    use std::time::UNIX_EPOCH;
1709
1710    use super::*;
1711
1712    fn fixture_credentials() -> Credentials {
1713        Credentials::new("synthetic-user", "synthetic-key")
1714            .unwrap_or_else(|error| panic!("fixture credentials must be valid: {error}"))
1715    }
1716
1717    #[test]
1718    fn builder_rejects_durations_outside_the_tokio_clock_range() {
1719        let timeout = Client::builder(fixture_credentials())
1720            .timeout(Duration::MAX)
1721            .build();
1722        assert!(matches!(timeout, Err(Error::Configuration(_))));
1723
1724        let retry = Client::builder(fixture_credentials())
1725            .retry_delays(Duration::from_secs(1), Duration::MAX)
1726            .build();
1727        assert!(matches!(retry, Err(Error::Configuration(_))));
1728    }
1729
1730    #[test]
1731    fn builder_rejects_a_proxy_for_plaintext_loopback_endpoints() {
1732        let endpoints = Endpoints::custom("http://127.0.0.1:8080", "http://[::1]:8080")
1733            .unwrap_or_else(|error| panic!("fixture endpoints must be valid: {error}"));
1734        let client = Client::builder(fixture_credentials())
1735            .endpoints(endpoints)
1736            .proxy("http://127.0.0.1:8888")
1737            .build();
1738
1739        assert!(matches!(client, Err(Error::Configuration(_))));
1740    }
1741
1742    #[test]
1743    fn bearer_token_validation_rejects_whitespace_and_control_bytes() {
1744        assert!(matches!(
1745            validate_token(Some("token with spaces")),
1746            Err(Error::InvalidAuthenticationToken)
1747        ));
1748        assert!(matches!(
1749            validate_token(Some("token\n")),
1750            Err(Error::InvalidAuthenticationToken)
1751        ));
1752        assert!(matches!(
1753            validate_token(Some("")),
1754            Err(Error::MissingAuthenticationToken)
1755        ));
1756        assert_eq!(
1757            validate_token(Some("synthetic.jwt-token_123"))
1758                .unwrap_or_else(|error| panic!("fixture token must be valid: {error}")),
1759            "synthetic.jwt-token_123"
1760        );
1761    }
1762
1763    #[test]
1764    fn validator_shutdown_and_trustworthy_completion_have_one_atomic_winner() {
1765        let losing_store = Arc::new(TokenStore::default());
1766        assert!(
1767            losing_store
1768                .begin_authentication()
1769                .commit("initial-token".to_owned())
1770        );
1771        let losing_tracker = Arc::new(ValidationTracker::default());
1772        let losing_basis = losing_store
1773            .versioned_snapshot()
1774            .unwrap_or_else(|| panic!("fixture token must be installed"));
1775        let losing_attempt = ValidationAttempt::new(
1776            Arc::clone(&losing_store),
1777            losing_basis,
1778            Some(&losing_tracker),
1779        )
1780        .unwrap_or_else(|| panic!("fixture validation must be admitted"));
1781
1782        let closing_revision = losing_tracker
1783            .close()
1784            .unwrap_or_else(|| panic!("shutdown must claim the admitted validation"));
1785        let losing_completion = finish_trustworthy_validation(
1786            &losing_store,
1787            losing_attempt,
1788            Some("rotated-token".to_owned()),
1789        );
1790        losing_store.invalidate_revision_if_current(closing_revision);
1791
1792        assert!(matches!(
1793            losing_completion,
1794            Err(Error::AmbiguousSessionValidation)
1795        ));
1796        assert!(!losing_store.is_authenticated());
1797
1798        let winning_store = Arc::new(TokenStore::default());
1799        assert!(
1800            winning_store
1801                .begin_authentication()
1802                .commit("initial-token".to_owned())
1803        );
1804        let winning_tracker = Arc::new(ValidationTracker::default());
1805        let winning_basis = winning_store
1806            .versioned_snapshot()
1807            .unwrap_or_else(|| panic!("fixture token must be installed"));
1808        let winning_attempt = ValidationAttempt::new(
1809            Arc::clone(&winning_store),
1810            winning_basis,
1811            Some(&winning_tracker),
1812        )
1813        .unwrap_or_else(|| panic!("fixture validation must be admitted"));
1814
1815        let winning_completion = finish_trustworthy_validation(
1816            &winning_store,
1817            winning_attempt,
1818            Some("rotated-token".to_owned()),
1819        )
1820        .unwrap_or_else(|error| panic!("trustworthy completion must win: {error}"));
1821
1822        assert_eq!(winning_completion, Some(UpdateOutcome::Applied));
1823        assert_eq!(winning_tracker.close(), None);
1824        assert_eq!(winning_store.snapshot().as_deref(), Some("rotated-token"));
1825    }
1826
1827    #[test]
1828    fn retry_after_parses_delta_seconds_and_bounds_hostile_values() {
1829        let mut headers = header::HeaderMap::new();
1830        headers.insert(header::RETRY_AFTER, header::HeaderValue::from_static("45"));
1831        assert_eq!(
1832            parse_retry_after(&headers, UNIX_EPOCH),
1833            Some(Duration::from_secs(45))
1834        );
1835
1836        headers.insert(
1837            header::RETRY_AFTER,
1838            header::HeaderValue::from_static("18446744073709551615"),
1839        );
1840        assert_eq!(parse_retry_after(&headers, UNIX_EPOCH), Some(MAX_COOLDOWN));
1841    }
1842
1843    #[test]
1844    fn retry_after_parses_http_dates_without_waiting_for_past_dates() {
1845        let now = UNIX_EPOCH + Duration::from_secs(1_000_000);
1846        let future = now + Duration::from_secs(75);
1847        let mut headers = header::HeaderMap::new();
1848        let future_header = header::HeaderValue::from_str(&httpdate::fmt_http_date(future))
1849            .unwrap_or_else(|error| panic!("fixture header must be valid: {error}"));
1850        headers.insert(header::RETRY_AFTER, future_header);
1851        assert_eq!(
1852            parse_retry_after(&headers, now),
1853            Some(Duration::from_secs(75))
1854        );
1855
1856        let past_header = header::HeaderValue::from_str(&httpdate::fmt_http_date(UNIX_EPOCH))
1857            .unwrap_or_else(|error| panic!("fixture header must be valid: {error}"));
1858        headers.insert(header::RETRY_AFTER, past_header);
1859        assert_eq!(parse_retry_after(&headers, now), Some(Duration::ZERO));
1860    }
1861
1862    #[test]
1863    fn retry_after_rejects_malformed_headers() {
1864        let mut headers = header::HeaderMap::new();
1865        headers.insert(
1866            header::RETRY_AFTER,
1867            header::HeaderValue::from_static("not-a-delay"),
1868        );
1869        assert_eq!(parse_retry_after(&headers, UNIX_EPOCH), None);
1870    }
1871
1872    #[test]
1873    fn provider_retry_delay_uses_the_longer_value_without_adding_delays() {
1874        let provider_delay = Error::ProviderRateLimited {
1875            kind: RateLimitKind::General,
1876            retry_after: Duration::from_secs(20),
1877        };
1878        assert_eq!(
1879            retry_delay(&provider_delay, Duration::from_secs(5)),
1880            Duration::from_secs(20)
1881        );
1882        assert_eq!(
1883            retry_delay(&provider_delay, Duration::from_secs(30)),
1884            Duration::from_secs(30)
1885        );
1886    }
1887
1888    fn untrustworthy_mutation_errors() -> Vec<(Error, AmbiguityOrigin)> {
1889        let decode_error = serde_json::from_str::<()>("not json")
1890            .err()
1891            .unwrap_or_else(|| panic!("fixture JSON must fail to decode"));
1892        vec![
1893            (
1894                Error::UnexpectedStatus { status: 502 },
1895                AmbiguityOrigin::HttpStatus(502),
1896            ),
1897            (
1898                Error::UnexpectedStatus { status: 504 },
1899                AmbiguityOrigin::HttpStatus(504),
1900            ),
1901            (Error::Decode(decode_error), AmbiguityOrigin::Decode),
1902            (
1903                Error::ResponseTooLarge { limit_bytes: 16 },
1904                AmbiguityOrigin::ResponseTooLarge,
1905            ),
1906            (
1907                Error::InconsistentResponseStatus {
1908                    success: true,
1909                    code: 7,
1910                },
1911                AmbiguityOrigin::InconsistentStatus,
1912            ),
1913            (
1914                Error::ProviderRateLimited {
1915                    kind: RateLimitKind::General,
1916                    retry_after: Duration::from_secs(1),
1917                },
1918                AmbiguityOrigin::RateLimited,
1919            ),
1920            (
1921                Error::BackgroundTaskFailed {
1922                    task: "fixture task",
1923                },
1924                AmbiguityOrigin::Unclassified,
1925            ),
1926        ]
1927    }
1928
1929    #[test]
1930    fn ambiguous_mutations_carry_their_failure_origin_and_stay_distinguishable() {
1931        let mut rendered = Vec::new();
1932        for (error, expected_origin) in untrustworthy_mutation_errors() {
1933            let ambiguous = ambiguous_mutation(MutationKind::OrderPlacement, error);
1934            let text = ambiguous.to_string();
1935            let Error::AmbiguousMutation {
1936                operation,
1937                code,
1938                name,
1939                origin,
1940            } = ambiguous
1941            else {
1942                panic!("untrustworthy outcome must stay an ambiguous mutation");
1943            };
1944            assert_eq!(operation, "order placement");
1945            assert_eq!(code, None);
1946            assert_eq!(name, None);
1947            assert_eq!(origin, Some(expected_origin));
1948            rendered.push(text);
1949        }
1950        let unique: std::collections::BTreeSet<_> = rendered.iter().map(String::as_str).collect();
1951        assert_eq!(
1952            unique.len(),
1953            rendered.len(),
1954            "distinct origins must render distinct evidence: {rendered:?}"
1955        );
1956        assert!(rendered[0].contains("HTTP status 502"));
1957        assert!(rendered[1].contains("HTTP status 504"));
1958
1959        // A decoded non-definitive provider rejection is itself the evidence:
1960        // the code is carried and no transport origin is fabricated beside it.
1961        let Error::AmbiguousMutation { code, origin, .. } = ambiguous_mutation(
1962            MutationKind::OrderPlacement,
1963            Error::Provider(ProviderError::new(6, Some("OrderPending"), None, None)),
1964        ) else {
1965            panic!("pending provider rejection must stay ambiguous");
1966        };
1967        assert_eq!(code, Some(6));
1968        assert_eq!(origin, None);
1969    }
1970
1971    #[test]
1972    fn every_constructible_non_passthrough_outcome_carries_exactly_one_evidence_field() {
1973        // `Transport` is absent because `reqwest::Error` has no public
1974        // constructor; every other variant of `Error` that the ambiguity
1975        // mapping can receive is represented here or in the pass-through
1976        // test below.
1977        let errors = || {
1978            vec![
1979                Error::InvalidIdentifier {
1980                    kind: "fixture",
1981                    reason: "fixture",
1982                },
1983                Error::CredentialsRejected {
1984                    code: 1,
1985                    name: None,
1986                },
1987                Error::SessionValidationRejected {
1988                    code: 1,
1989                    name: None,
1990                },
1991                Error::InconsistentResponseStatus {
1992                    success: true,
1993                    code: 7,
1994                },
1995                Error::MissingAuthenticationToken,
1996                Error::InvalidAuthenticationToken,
1997                Error::AmbiguousSessionValidation,
1998                Error::UnexpectedStatusResponse,
1999                Error::BackgroundTaskFailed {
2000                    task: "fixture task",
2001                },
2002                Error::UnexpectedStatus { status: 503 },
2003                Error::ResponseTooLarge { limit_bytes: 16 },
2004                Error::ProviderRateLimited {
2005                    kind: RateLimitKind::General,
2006                    retry_after: Duration::from_secs(1),
2007                },
2008                Error::Provider(ProviderError::new(0, None, None, None)),
2009            ]
2010        };
2011        for error in errors() {
2012            let Error::AmbiguousMutation { code, origin, .. } =
2013                ambiguous_mutation(MutationKind::PositionClose, error)
2014            else {
2015                panic!("a non-passthrough outcome must stay an ambiguous mutation");
2016            };
2017            assert!(
2018                code.is_some() != origin.is_some(),
2019                "exactly one of code and origin must be set, got code {code:?} origin {origin:?}"
2020            );
2021        }
2022    }
2023
2024    #[test]
2025    fn pre_send_failures_are_not_ambiguous_mutations() {
2026        let errors = || {
2027            vec![
2028                Error::NotAuthenticated,
2029                Error::Configuration("synthetic".to_owned()),
2030                Error::LocallyRateLimited {
2031                    kind: RateLimitKind::General,
2032                    retry_after: Duration::from_secs(1),
2033                },
2034                Error::Url(
2035                    "::".parse::<url::Url>()
2036                        .err()
2037                        .unwrap_or_else(|| panic!("fixture URL must fail to parse")),
2038                ),
2039            ]
2040        };
2041        for error in errors() {
2042            let expected = error.to_string();
2043            let mapped = ambiguous_mutation(MutationKind::OrderCancellation, error);
2044            assert_eq!(mapped.to_string(), expected);
2045        }
2046    }
2047
2048    #[test]
2049    fn mutation_policies_whitelist_only_documented_definitive_rejections() {
2050        let cases: &[(MutationKind, &[i32], &[i32])] = &[
2051            (
2052                MutationKind::OrderPlacement,
2053                &[1, 2, 3, 4, 5, 8, 9, 10],
2054                &[0, 6, 7, 11, 99],
2055            ),
2056            (
2057                MutationKind::OrderCancellation,
2058                &[1, 2, 3, 6],
2059                &[0, 4, 5, 7, 99],
2060            ),
2061            (
2062                MutationKind::OrderModification,
2063                &[1, 2, 3, 6, 7],
2064                &[0, 4, 5, 8, 99],
2065            ),
2066            (
2067                MutationKind::PositionClose,
2068                &[1, 2, 3, 4, 5, 8],
2069                &[0, 6, 7, 9, 99],
2070            ),
2071            (
2072                MutationKind::PartialPositionClose,
2073                &[1, 2, 3, 4, 5, 6, 9],
2074                &[0, 7, 8, 10, 99],
2075            ),
2076        ];
2077
2078        for &(kind, definitive, ambiguous) in cases {
2079            for &code in definitive {
2080                assert!(
2081                    kind.is_definitive_rejection(code),
2082                    "{kind:?} code {code} must be definitive"
2083                );
2084            }
2085            for &code in ambiguous {
2086                assert!(
2087                    !kind.is_definitive_rejection(code),
2088                    "{kind:?} code {code} must be ambiguous"
2089                );
2090            }
2091        }
2092    }
2093
2094    #[test]
2095    fn published_mutation_code_names_agree_with_the_rejection_policy() {
2096        // The provider documents pending and unknown outcomes as the only
2097        // ambiguous rejections. Every other published code must therefore be
2098        // definitive, and a definitive rejection must always have published
2099        // code text to report.
2100        const AMBIGUOUS_NAMES: &[&str] = &["Pending", "OrderPending", "UnknownError"];
2101
2102        for kind in [
2103            MutationKind::OrderPlacement,
2104            MutationKind::OrderCancellation,
2105            MutationKind::OrderModification,
2106            MutationKind::PositionClose,
2107            MutationKind::PartialPositionClose,
2108        ] {
2109            let table = kind.error_code_table();
2110            for code in 1..=16 {
2111                assert_eq!(
2112                    kind.is_definitive_rejection(code),
2113                    table
2114                        .name(code)
2115                        .is_some_and(|name| !AMBIGUOUS_NAMES.contains(&name)),
2116                    "{kind:?} code {code} must be definitive exactly when its published name is neither pending nor unknown"
2117                );
2118            }
2119        }
2120    }
2121}