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