1use 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
43pub 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
64struct 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
98struct 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#[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 pub fn builder(credentials: Credentials) -> ClientBuilder {
197 ClientBuilder::new(AuthenticationCredentials::ApiKey(credentials))
198 }
199
200 pub fn application_builder(credentials: ApplicationCredentials) -> ClientBuilder {
202 ClientBuilder::new(AuthenticationCredentials::Application(credentials))
203 }
204
205 #[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 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 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 pub async fn validate_session(&self) -> Result<(), Error> {
356 self.validate_session_tracked(None).await
357 }
358
359 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 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 pub async fn search_active_accounts(&self) -> Result<Vec<Account>, Error> {
483 self.search_accounts(true).await
484 }
485
486 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 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 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 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 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 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 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 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 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 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 pub async fn cancel_order(&self, request: &CancelOrder) -> Result<OperationResponse, Error> {
689 self.mutation(MutationKind::OrderCancellation, request)
690 .await
691 }
692
693 pub async fn modify_order(&self, request: &ModifyOrder) -> Result<OperationResponse, Error> {
704 self.mutation(MutationKind::OrderModification, request)
705 .await
706 }
707
708 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 pub async fn close_contract(
743 &self,
744 request: &CloseContract,
745 ) -> Result<OperationResponse, Error> {
746 self.mutation(MutationKind::PositionClose, request).await
747 }
748
749 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 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 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#[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 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 #[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#[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 pub fn endpoints(mut self, endpoints: Endpoints) -> Self {
1199 self.endpoints = endpoints;
1200 self
1201 }
1202
1203 pub fn timeout(mut self, timeout: Duration) -> Self {
1205 self.timeout = timeout;
1206 self
1207 }
1208
1209 pub fn response_limit(mut self, bytes: usize) -> Self {
1211 self.response_limit = bytes;
1212 self
1213 }
1214
1215 pub fn realtime_event_capacity(mut self, capacity: usize) -> Self {
1222 self.realtime_config.event_capacity = capacity;
1223 self
1224 }
1225
1226 pub fn realtime_writer_capacity(mut self, capacity: usize) -> Self {
1230 self.realtime_config.writer_capacity = capacity;
1231 self
1232 }
1233
1234 pub fn realtime_pending_invocation_capacity(mut self, capacity: usize) -> Self {
1239 self.realtime_config.pending_capacity = capacity;
1240 self
1241 }
1242
1243 pub fn realtime_invocation_timeout(mut self, timeout: Duration) -> Self {
1248 self.realtime_config.invocation_timeout = timeout;
1249 self
1250 }
1251
1252 pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
1257 self.proxy = Some(proxy.into());
1258 self
1259 }
1260
1261 pub fn max_retries(mut self, max_retries: u32) -> Self {
1265 self.max_retries = max_retries;
1266 self
1267 }
1268
1269 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 pub fn rate_limits(mut self, rate_limits: RateLimitConfig) -> Self {
1281 self.rate_limits = Some(rate_limits);
1282 self
1283 }
1284
1285 pub fn disable_rate_limits(mut self) -> Self {
1291 self.rate_limits = None;
1292 self
1293 }
1294
1295 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
1375fn 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 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 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}