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, AmbiguityOrigin, ApplicationCredentials, Bar, CancelOrder, CloseContract,
21 Contract, Credentials, Endpoints, Error, HistoryRequest, Hub, ModifyOrder, OperationResponse,
22 Order, OrderId, OrderPage, OrderQuery, OrderResponse, OrderSearch, PartialCloseContract,
23 PlaceOrder, Position, ProviderError, ProviderList, RateLimitConfig, RateLimitKind,
24 RealtimeClient, SearchContracts, Trade, TradeQuery, TradeSearch,
25 credentials::AuthenticationCredentials,
26 error_codes::ErrorCodeTable,
27 models::{
28 AccountsBody, BarsBody, ContractBody, ContractsBody, EmptyBody, Envelope, OrderBody,
29 OrdersBody, PlaceOrderBody, PositionsBody, TradesBody,
30 },
31 rate_limit::{MAX_COOLDOWN, RateLimits},
32 token::{TokenRevision, TokenSnapshot, TokenStore, UpdateOutcome},
33};
34
35const DEFAULT_TIMEOUT: Duration = Duration::from_mins(1);
36const DEFAULT_RESPONSE_LIMIT: usize = 16 * 1024 * 1024;
37const DEFAULT_MAX_RETRIES: u32 = 3;
38const DEFAULT_RETRY_INITIAL: Duration = Duration::from_secs(1);
39const DEFAULT_RETRY_MAX: Duration = Duration::from_secs(10);
40const USER_AGENT: &str = concat!("projectx-client/", env!("CARGO_PKG_VERSION"));
41
42pub struct Client {
50 credentials: Arc<AuthenticationCredentials>,
51 endpoints: Endpoints,
52 http: reqwest::Client,
53 realtime_http: reqwest::Client,
54 token: Arc<TokenStore>,
55 rate_limits: Arc<RateLimits>,
56 response_limit: usize,
57 realtime_config: crate::realtime::RealtimeConfig,
58 max_retries: u32,
59 retry_initial: Duration,
60 retry_max: Duration,
61}
62
63struct LogoutAttempt {
66 store: Arc<TokenStore>,
67 basis: TokenSnapshot,
68 armed: bool,
69}
70
71impl LogoutAttempt {
72 fn new(store: Arc<TokenStore>, basis: TokenSnapshot) -> Self {
73 Self {
74 store,
75 basis,
76 armed: true,
77 }
78 }
79
80 fn basis(&self) -> &TokenSnapshot {
81 &self.basis
82 }
83
84 fn retain_session(mut self) {
85 self.armed = false;
86 }
87}
88
89impl Drop for LogoutAttempt {
90 fn drop(&mut self) {
91 if self.armed {
92 self.store.invalidate_if_current(&self.basis);
93 }
94 }
95}
96
97struct ValidationAttempt {
100 store: Arc<TokenStore>,
101 basis: TokenSnapshot,
102 tracker: Option<Arc<ValidationTracker>>,
103 armed: bool,
104}
105
106impl ValidationAttempt {
107 fn new(
108 store: Arc<TokenStore>,
109 basis: TokenSnapshot,
110 tracker: Option<&Arc<ValidationTracker>>,
111 ) -> Option<Self> {
112 if tracker.is_some_and(|tracker| !tracker.register(basis.revision())) {
113 return None;
114 }
115 Some(Self {
116 store,
117 basis,
118 tracker: tracker.cloned(),
119 armed: true,
120 })
121 }
122
123 fn basis(&self) -> &TokenSnapshot {
124 &self.basis
125 }
126
127 fn disarm(mut self) {
128 self.armed = false;
129 self.release_tracker();
130 }
131
132 fn claim_trustworthy_completion(&mut self) -> bool {
133 let Some(tracker) = self.tracker.take() else {
134 return true;
135 };
136 tracker.claim_completion(self.basis.revision())
137 }
138
139 fn release_tracker(&mut self) {
140 if let Some(tracker) = self.tracker.take() {
141 let _claimed = tracker.claim_completion(self.basis.revision());
142 }
143 }
144}
145
146impl Drop for ValidationAttempt {
147 fn drop(&mut self) {
148 if self.armed {
149 self.store.invalidate_if_current(&self.basis);
150 }
151 self.release_tracker();
152 }
153}
154
155#[derive(Default)]
157struct ValidationTracker {
158 state: Mutex<ValidationTrackerState>,
159}
160
161#[derive(Default)]
162struct ValidationTrackerState {
163 closed: bool,
164 active: Option<TokenRevision>,
165}
166
167impl ValidationTracker {
168 fn register(&self, basis: TokenRevision) -> bool {
169 let mut state = self.state.lock();
170 if state.closed || state.active.is_some() {
171 return false;
172 }
173 state.active = Some(basis);
174 true
175 }
176
177 fn claim_completion(&self, basis: TokenRevision) -> bool {
178 let mut state = self.state.lock();
179 if state.active == Some(basis) {
180 state.active = None;
181 return true;
182 }
183 false
184 }
185
186 fn close(&self) -> Option<TokenRevision> {
187 let mut state = self.state.lock();
188 state.closed = true;
189 state.active.take()
190 }
191}
192
193impl Client {
194 pub fn builder(credentials: Credentials) -> ClientBuilder {
196 ClientBuilder::new(AuthenticationCredentials::ApiKey(credentials))
197 }
198
199 pub fn application_builder(credentials: ApplicationCredentials) -> ClientBuilder {
201 ClientBuilder::new(AuthenticationCredentials::Application(credentials))
202 }
203
204 #[must_use]
209 pub fn realtime(&self, hub: Hub) -> RealtimeClient {
210 RealtimeClient::new(
211 hub,
212 self.endpoints.clone(),
213 self.realtime_http.clone(),
214 Arc::clone(&self.token),
215 self.realtime_config,
216 )
217 }
218
219 pub async fn authenticate(&self) -> Result<(), Error> {
231 let attempt = self.token.begin_authentication();
232 let response: LoginResponse = match self.credentials.as_ref() {
233 AuthenticationCredentials::ApiKey(credentials) => {
234 let body = LoginApiKeyRequest {
235 user_name: credentials.expose_user_name(),
236 api_key: credentials.expose_api_key(),
237 };
238 self.post_unauthenticated("api/Auth/loginKey", &body)
239 .await?
240 }
241 AuthenticationCredentials::Application(credentials) => {
242 let body = LoginAppRequest {
243 user_name: credentials.expose_user_name(),
244 password: credentials.expose_password(),
245 device_id: credentials.expose_device_id(),
246 app_id: credentials.expose_app_id(),
247 verify_key: credentials.expose_verify_key(),
248 };
249 self.post_unauthenticated("api/Auth/loginApp", &body)
250 .await?
251 }
252 };
253 validate_response_status(response.success, response.error_code)?;
254 if !response.success {
255 return Err(Error::CredentialsRejected {
256 code: response.error_code,
257 name: ErrorCodeTable::Login.name(response.error_code),
258 });
259 }
260 let token = validate_token(response.token.as_deref())?;
261 if !attempt.commit(token) {
262 tracing::debug!(
263 "discarded a delayed authentication response after another authentication completed"
264 );
265 }
266 Ok(())
267 }
268
269 pub async fn authenticate_with_validation(
279 &self,
280 period: Duration,
281 ) -> Result<SessionValidator, Error> {
282 if period.is_zero() {
283 return Err(Error::Configuration(
284 "validation period must be non-zero".to_owned(),
285 ));
286 }
287 let first_tick = tokio::time::Instant::now()
288 .checked_add(period)
289 .ok_or_else(|| {
290 Error::Configuration(
291 "validation period cannot be represented by the Tokio clock".to_owned(),
292 )
293 })?;
294 self.authenticate().await?;
295 let cancellation = CancellationToken::new();
296 let task_cancellation = cancellation.clone();
297 let validation_tracker = Arc::new(ValidationTracker::default());
298 let task_validation_tracker = Arc::clone(&validation_tracker);
299 let client = self.clone();
300 let task = tokio::spawn(async move {
301 let mut ticker = tokio::time::interval_at(first_tick, period);
302 ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
303 loop {
304 tokio::select! {
305 () = task_cancellation.cancelled() => break Ok(()),
306 _ = ticker.tick() => {
307 tokio::select! {
308 biased;
309 result = client.validate_session_tracked(Some(Arc::clone(
310 &task_validation_tracker,
311 ))) => {
312 match result {
313 Ok(()) => {}
314 Err(error)
315 if is_terminal_session_error(&error)
316 && !client
317 .token
318 .has_viable_session_or_authentication() =>
319 {
320 break Err(error);
321 }
322 Err(error) => {
323 tracing::warn!(%error, "ProjectX token validation failed");
324 }
325 }
326 }
327 () = task_cancellation.cancelled() => {
328 if client.token.is_authenticated() {
329 break Ok(());
330 }
331 break Err(Error::AmbiguousSessionValidation);
332 }
333 }
334 }
335 }
336 }
337 });
338 Ok(SessionValidator {
339 cancellation,
340 task: Some(task),
341 token: Arc::clone(&self.token),
342 validation_tracker,
343 })
344 }
345
346 pub async fn validate_session(&self) -> Result<(), Error> {
355 self.validate_session_tracked(None).await
356 }
357
358 pub async fn logout(&self) -> Result<OperationResponse, Error> {
370 self.require_authentication()?;
371 let url = self.endpoints.api_url("api/Auth/logout")?;
372 self.rate_limits
373 .try_acquire(RateLimitKind::General)
374 .map_err(|retry_after| Error::LocallyRateLimited {
375 kind: RateLimitKind::General,
376 retry_after,
377 })?;
378 let basis = self
379 .token
380 .versioned_snapshot()
381 .ok_or(Error::NotAuthenticated)?;
382 let attempt = LogoutAttempt::new(Arc::clone(&self.token), basis);
383 let response = match self
384 .http
385 .request(Method::POST, url)
386 .bearer_auth(attempt.basis().expose())
387 .send()
388 .await
389 {
390 Ok(response) => response,
391 Err(error) if error.is_connect() || error.is_builder() => {
392 attempt.retain_session();
393 return Err(Error::Transport(error));
394 }
395 Err(error) => return Err(Error::Transport(error)),
396 };
397 if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
398 let retry_after =
399 self.apply_provider_cooldown(RateLimitKind::General, response.headers());
400 attempt.retain_session();
401 return Err(Error::ProviderRateLimited {
402 kind: RateLimitKind::General,
403 retry_after,
404 });
405 }
406 let response: Envelope<EmptyBody> = self.decode(response).await?;
407 accepted(response, ErrorCodeTable::Logout)?;
408 Ok(OperationResponse)
409 }
410
411 pub async fn ping(&self) -> Result<(), Error> {
421 let url = self.endpoints.api_url("api/Status/ping")?;
422 let response = self
423 .http
424 .request(Method::GET, url)
425 .send()
426 .await
427 .map_err(Error::Transport)?;
428 let bytes = self.read_bounded_response(response).await?;
429 if bytes == b"pong" {
430 Ok(())
431 } else {
432 Err(Error::UnexpectedStatusResponse)
433 }
434 }
435
436 async fn validate_session_tracked(
437 &self,
438 tracker: Option<Arc<ValidationTracker>>,
439 ) -> Result<(), Error> {
440 let (response, attempt) = self.post_validation(tracker).await?;
441 let response: ValidateResponse = self.decode(response).await?;
442 validate_response_status(response.success, response.error_code)?;
443 if !response.success {
444 let error = Error::SessionValidationRejected {
445 code: response.error_code,
446 name: ErrorCodeTable::Validate.name(response.error_code),
447 };
448 if matches!(response.error_code, 1..=3) {
449 return Err(error);
450 }
451 return Err(Error::AmbiguousSessionValidation);
452 }
453 let new_token = response
454 .new_token
455 .as_deref()
456 .map(|token| validate_token(Some(token)))
457 .transpose()?;
458 if let Some(outcome) = finish_trustworthy_validation(&self.token, attempt, new_token)? {
459 match outcome {
460 UpdateOutcome::Applied => {}
461 UpdateOutcome::Deferred => {
462 tracing::debug!(
463 "deferred token rotation until concurrent authentication completes"
464 );
465 }
466 UpdateOutcome::Stale => {
467 tracing::debug!(
468 "discarded token rotation from a stale session-validation response"
469 );
470 }
471 }
472 }
473 Ok(())
474 }
475
476 pub async fn search_active_accounts(&self) -> Result<ProviderList<Account>, Error> {
485 self.search_accounts(true).await
486 }
487
488 pub async fn search_accounts(
502 &self,
503 only_active_accounts: bool,
504 ) -> Result<ProviderList<Account>, Error> {
505 let response: Envelope<AccountsBody> = self
506 .post_authenticated(
507 RateLimitKind::General,
508 "api/Account/search",
509 &AccountSearchRequest {
510 only_active_accounts,
511 },
512 )
513 .await?;
514 Ok(accepted(response, ErrorCodeTable::SuccessOnly)?.accounts)
515 }
516
517 pub async fn available_contracts(&self, live: bool) -> Result<ProviderList<Contract>, Error> {
526 let response: Envelope<ContractsBody> = self
527 .post_authenticated(
528 RateLimitKind::General,
529 "api/Contract/available",
530 &AvailableContractsRequest { live },
531 )
532 .await?;
533 Ok(accepted(response, ErrorCodeTable::SuccessOnly)?.contracts)
534 }
535
536 pub async fn search_contracts(
547 &self,
548 request: &SearchContracts,
549 ) -> Result<ProviderList<Contract>, Error> {
550 let response: Envelope<ContractsBody> = self
551 .post_authenticated(RateLimitKind::General, "api/Contract/search", request)
552 .await?;
553 Ok(accepted(response, ErrorCodeTable::SuccessOnly)?.contracts)
554 }
555
556 pub async fn contract_by_id(&self, contract_id: &crate::ContractId) -> Result<Contract, Error> {
562 let response: Envelope<ContractBody> = self
563 .post_authenticated(
564 RateLimitKind::General,
565 "api/Contract/searchById",
566 &ContractRequest { contract_id },
567 )
568 .await?;
569 Ok(accepted(response, ErrorCodeTable::ContractSearchById)?.contract)
570 }
571
572 pub async fn retrieve_bars(
581 &self,
582 request: &HistoryRequest,
583 ) -> Result<ProviderList<Bar>, Error> {
584 let response: Envelope<BarsBody> = self
585 .post_authenticated(RateLimitKind::History, "api/History/retrieveBars", request)
586 .await?;
587 Ok(accepted(response, ErrorCodeTable::Bars)?.bars)
588 }
589
590 pub async fn search_orders(&self, request: &OrderSearch) -> Result<ProviderList<Order>, Error> {
599 let response: Envelope<OrdersBody> = self
600 .post_authenticated(RateLimitKind::General, "api/Order/search", request)
601 .await?;
602 Ok(accepted(response, ErrorCodeTable::OrderSearch)?.orders)
603 }
604
605 pub async fn order_by_id(
611 &self,
612 account_id: AccountId,
613 order_id: OrderId,
614 ) -> Result<Order, Error> {
615 let response: Envelope<OrderBody> = self
616 .post_authenticated(
617 RateLimitKind::General,
618 "api/Order/searchById",
619 &AccountOrderRequest {
620 account_id,
621 order_id,
622 },
623 )
624 .await?;
625 Ok(accepted(response, ErrorCodeTable::OrderSearchById)?.order)
626 }
627
628 pub async fn search_open_orders(
642 &self,
643 account_id: AccountId,
644 ) -> Result<ProviderList<Order>, Error> {
645 let response: Envelope<OrdersBody> = self
646 .post_authenticated(
647 RateLimitKind::General,
648 "api/Order/searchOpen",
649 &AccountRequest { account_id },
650 )
651 .await?;
652 Ok(accepted(response, ErrorCodeTable::OrderSearch)?.orders)
653 }
654
655 pub async fn query_orders(&self, request: &OrderQuery) -> Result<OrderPage, Error> {
678 let response: Envelope<OrderPage> = self
679 .post_authenticated(RateLimitKind::General, "api/Order/v2/query", request)
680 .await?;
681 accepted(response, ErrorCodeTable::OrderSearch)
682 }
683
684 pub async fn place_order(&self, request: &PlaceOrder) -> Result<OrderResponse, Error> {
700 let kind = MutationKind::OrderPlacement;
701 let response: Envelope<PlaceOrderBody, PlaceOrderBody> = self
702 .post_authenticated_no_retry(RateLimitKind::General, kind.path(), request)
703 .await
704 .map_err(|error| ambiguous_mutation(kind, error))?;
705 let body =
706 accepted_with_detail(response, kind.error_code_table(), |detail| detail.order_id)
707 .map_err(|error| ambiguous_mutation(kind, error))?;
708 let order_id = body.order_id.ok_or(Error::AmbiguousMutation {
709 operation: kind.operation(),
710 code: None,
711 name: None,
712 origin: Some(AmbiguityOrigin::MissingResult),
713 })?;
714 Ok(OrderResponse { order_id })
715 }
716
717 pub async fn cancel_order(&self, request: &CancelOrder) -> Result<OperationResponse, Error> {
733 self.mutation(MutationKind::OrderCancellation, request)
734 .await
735 }
736
737 pub async fn modify_order(&self, request: &ModifyOrder) -> Result<OperationResponse, Error> {
748 self.mutation(MutationKind::OrderModification, request)
749 .await
750 }
751
752 pub async fn search_open_positions(
764 &self,
765 account_id: AccountId,
766 ) -> Result<ProviderList<Position>, Error> {
767 let response: Envelope<PositionsBody> = self
768 .post_authenticated(
769 RateLimitKind::General,
770 "api/Position/searchOpen",
771 &AccountRequest { account_id },
772 )
773 .await?;
774 Ok(accepted(response, ErrorCodeTable::PositionSearch)?.positions)
775 }
776
777 pub async fn close_contract(
793 &self,
794 request: &CloseContract,
795 ) -> Result<OperationResponse, Error> {
796 self.mutation(MutationKind::PositionClose, request).await
797 }
798
799 pub async fn partial_close_contract(
822 &self,
823 request: &PartialCloseContract,
824 ) -> Result<OperationResponse, Error> {
825 self.mutation(MutationKind::PartialPositionClose, request)
826 .await
827 }
828
829 pub async fn search_trades(&self, request: &TradeSearch) -> Result<ProviderList<Trade>, Error> {
838 let response: Envelope<TradesBody> = self
839 .post_authenticated(RateLimitKind::General, "api/Trade/search", request)
840 .await?;
841 Ok(accepted(response, ErrorCodeTable::TradeSearch)?.trades)
842 }
843
844 pub async fn query_trades(&self, request: &TradeQuery) -> Result<ProviderList<Trade>, Error> {
856 let response: Envelope<TradesBody> = self
857 .post_authenticated(RateLimitKind::General, "api/Trade/search", request)
858 .await?;
859 Ok(accepted(response, ErrorCodeTable::TradeSearch)?.trades)
860 }
861
862 async fn mutation<T>(&self, kind: MutationKind, request: &T) -> Result<OperationResponse, Error>
863 where
864 T: Serialize + ?Sized,
865 {
866 let response: Envelope<EmptyBody> = self
867 .post_authenticated_no_retry(RateLimitKind::General, kind.path(), request)
868 .await
869 .map_err(|error| ambiguous_mutation(kind, error))?;
870 accepted(response, kind.error_code_table())
871 .map_err(|error| ambiguous_mutation(kind, error))?;
872 Ok(OperationResponse)
873 }
874
875 async fn post_unauthenticated<T, R>(&self, path: &str, body: &T) -> Result<R, Error>
876 where
877 T: Serialize + ?Sized,
878 R: DeserializeOwned,
879 {
880 let url = self.endpoints.api_url(path)?;
881 let response = self
882 .http
883 .request(Method::POST, url)
884 .json(body)
885 .send()
886 .await
887 .map_err(Error::Transport)?;
888 self.decode(response).await
889 }
890
891 async fn post_authenticated<T, R>(
892 &self,
893 kind: RateLimitKind,
894 path: &str,
895 body: &T,
896 ) -> Result<R, Error>
897 where
898 T: Serialize + ?Sized,
899 R: DeserializeOwned,
900 {
901 self.post_authenticated_tracked(kind, path, body)
902 .await
903 .map(|(response, _basis)| response)
904 }
905
906 async fn post_authenticated_tracked<T, R>(
907 &self,
908 kind: RateLimitKind,
909 path: &str,
910 body: &T,
911 ) -> Result<(R, TokenSnapshot), Error>
912 where
913 T: Serialize + ?Sized,
914 R: DeserializeOwned,
915 {
916 let encoded = serde_json::to_vec(body).map_err(Error::Encode)?;
917 self.post_authenticated_encoded(kind, path, &encoded).await
918 }
919
920 async fn post_authenticated_encoded<R>(
921 &self,
922 kind: RateLimitKind,
923 path: &str,
924 body: &[u8],
925 ) -> Result<(R, TokenSnapshot), Error>
926 where
927 R: DeserializeOwned,
928 {
929 self.require_authentication()?;
930 let mut attempt = 0;
931 let mut delay = self.retry_initial;
932 loop {
933 self.rate_limits.wait(kind).await;
934 match self.post_authenticated_once(kind, path, body).await {
935 Ok(response) => return Ok(response),
936 Err(error) if attempt < self.max_retries && should_retry(&error) => {
937 attempt += 1;
938 tokio::time::sleep(retry_delay(&error, delay)).await;
939 delay = delay.saturating_mul(2).min(self.retry_max);
940 }
941 Err(error) => return Err(error),
942 }
943 }
944 }
945
946 async fn post_authenticated_no_retry<T, R>(
947 &self,
948 kind: RateLimitKind,
949 path: &str,
950 body: &T,
951 ) -> Result<R, Error>
952 where
953 T: Serialize + ?Sized,
954 R: DeserializeOwned,
955 {
956 let encoded = serde_json::to_vec(body).map_err(Error::Encode)?;
957 self.require_authentication()?;
958 self.rate_limits
959 .try_acquire(kind)
960 .map_err(|retry_after| Error::LocallyRateLimited { kind, retry_after })?;
961 self.post_authenticated_once(kind, path, &encoded)
962 .await
963 .map(|(response, _basis)| response)
964 }
965
966 async fn post_authenticated_once<R>(
967 &self,
968 kind: RateLimitKind,
969 path: &str,
970 body: &[u8],
971 ) -> Result<(R, TokenSnapshot), Error>
972 where
973 R: DeserializeOwned,
974 {
975 let token = self
976 .token
977 .versioned_snapshot()
978 .ok_or(Error::NotAuthenticated)?;
979 let url = self.endpoints.api_url(path)?;
980 let request = self
981 .http
982 .request(Method::POST, url)
983 .bearer_auth(token.expose())
984 .body(body.to_vec());
985 let response = request.send().await.map_err(Error::Transport)?;
986 if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
987 let retry_after = self.apply_provider_cooldown(kind, response.headers());
988 return Err(Error::ProviderRateLimited { kind, retry_after });
989 }
990 if response.status() == reqwest::StatusCode::UNAUTHORIZED {
991 self.token.invalidate_if_current(&token);
992 return Err(Error::UnexpectedStatus { status: 401 });
993 }
994 self.decode(response)
995 .await
996 .map(|response| (response, token))
997 }
998
999 async fn post_validation(
1000 &self,
1001 tracker: Option<Arc<ValidationTracker>>,
1002 ) -> Result<(reqwest::Response, ValidationAttempt), Error> {
1003 self.require_authentication()?;
1004 let mut attempt = 0;
1005 let mut delay = self.retry_initial;
1006 loop {
1007 self.rate_limits.wait(RateLimitKind::General).await;
1008 match self.post_validation_once(tracker.as_ref()).await {
1009 Ok(response) => return Ok(response),
1010 Err(error) if attempt < self.max_retries && should_retry(&error) => {
1011 attempt += 1;
1012 tokio::time::sleep(retry_delay(&error, delay)).await;
1013 delay = delay.saturating_mul(2).min(self.retry_max);
1014 }
1015 Err(error) => return Err(error),
1016 }
1017 }
1018 }
1019
1020 async fn post_validation_once(
1021 &self,
1022 tracker: Option<&Arc<ValidationTracker>>,
1023 ) -> Result<(reqwest::Response, ValidationAttempt), Error> {
1024 let url = self.endpoints.api_url("api/Auth/validate")?;
1025 let basis = self
1026 .token
1027 .versioned_snapshot()
1028 .ok_or(Error::NotAuthenticated)?;
1029 let attempt = ValidationAttempt::new(Arc::clone(&self.token), basis, tracker)
1030 .ok_or(Error::NotAuthenticated)?;
1031 let response = match self
1032 .http
1033 .request(Method::POST, url)
1034 .bearer_auth(attempt.basis().expose())
1035 .send()
1036 .await
1037 {
1038 Ok(response) => response,
1039 Err(error) if error.is_connect() || error.is_builder() => {
1040 attempt.disarm();
1041 return Err(Error::Transport(error));
1042 }
1043 Err(_error) => return Err(Error::AmbiguousSessionValidation),
1044 };
1045 if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
1046 let retry_after =
1047 self.apply_provider_cooldown(RateLimitKind::General, response.headers());
1048 attempt.disarm();
1049 return Err(Error::ProviderRateLimited {
1050 kind: RateLimitKind::General,
1051 retry_after,
1052 });
1053 }
1054 if response.status() == reqwest::StatusCode::UNAUTHORIZED {
1055 return Err(Error::UnexpectedStatus { status: 401 });
1056 }
1057 if !response.status().is_success() {
1058 return Err(Error::AmbiguousSessionValidation);
1059 }
1060 Ok((response, attempt))
1061 }
1062
1063 fn apply_provider_cooldown(
1064 &self,
1065 kind: RateLimitKind,
1066 headers: &header::HeaderMap,
1067 ) -> Duration {
1068 let retry_after = parse_retry_after(headers, SystemTime::now())
1069 .unwrap_or_else(|| self.rate_limits.limit(kind).window())
1070 .min(MAX_COOLDOWN);
1071 self.rate_limits.cool_down(kind, retry_after);
1072 retry_after
1073 }
1074
1075 fn require_authentication(&self) -> Result<(), Error> {
1076 if self.token.is_authenticated() {
1077 Ok(())
1078 } else {
1079 Err(Error::NotAuthenticated)
1080 }
1081 }
1082
1083 async fn decode<R>(&self, response: reqwest::Response) -> Result<R, Error>
1084 where
1085 R: DeserializeOwned,
1086 {
1087 let bytes = self.read_bounded_response(response).await?;
1088 serde_json::from_slice(&bytes).map_err(Error::Decode)
1089 }
1090
1091 async fn read_bounded_response(&self, response: reqwest::Response) -> Result<Vec<u8>, Error> {
1092 let status = response.status();
1093 if !status.is_success() {
1094 return Err(Error::UnexpectedStatus {
1095 status: status.as_u16(),
1096 });
1097 }
1098 let content_length = response.content_length();
1099 if content_length.is_some_and(|length| length > self.response_limit as u64) {
1100 return Err(Error::ResponseTooLarge {
1101 limit_bytes: self.response_limit,
1102 });
1103 }
1104
1105 let capacity = content_length
1106 .and_then(|length| usize::try_from(length).ok())
1107 .unwrap_or(0);
1108 let mut bytes = Vec::with_capacity(capacity);
1109 let mut stream = response.bytes_stream();
1110 while let Some(chunk) = stream.next().await {
1111 let chunk = chunk.map_err(Error::Transport)?;
1112 if bytes.len().saturating_add(chunk.len()) > self.response_limit {
1113 return Err(Error::ResponseTooLarge {
1114 limit_bytes: self.response_limit,
1115 });
1116 }
1117 bytes.extend_from_slice(&chunk);
1118 }
1119 Ok(bytes)
1120 }
1121}
1122
1123impl fmt::Debug for Client {
1124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1125 f.debug_struct("Client")
1126 .field("credentials", &self.credentials)
1127 .field("endpoints", &self.endpoints)
1128 .field("response_limit", &self.response_limit)
1129 .field("max_retries", &self.max_retries)
1130 .field("rate_limits", &self.rate_limits.config())
1131 .finish_non_exhaustive()
1132 }
1133}
1134
1135impl Clone for Client {
1136 fn clone(&self) -> Self {
1137 Self {
1138 credentials: Arc::clone(&self.credentials),
1139 endpoints: self.endpoints.clone(),
1140 http: self.http.clone(),
1141 realtime_http: self.realtime_http.clone(),
1142 token: Arc::clone(&self.token),
1143 rate_limits: Arc::clone(&self.rate_limits),
1144 response_limit: self.response_limit,
1145 realtime_config: self.realtime_config,
1146 max_retries: self.max_retries,
1147 retry_initial: self.retry_initial,
1148 retry_max: self.retry_max,
1149 }
1150 }
1151}
1152
1153#[must_use = "dropping the validator cancels periodic token validation"]
1155pub struct SessionValidator {
1156 cancellation: CancellationToken,
1157 task: Option<JoinHandle<Result<(), Error>>>,
1158 token: Arc<TokenStore>,
1159 validation_tracker: Arc<ValidationTracker>,
1160}
1161
1162impl SessionValidator {
1163 fn close_validation(&self) -> bool {
1164 let Some(revision) = self.validation_tracker.close() else {
1165 return false;
1166 };
1167 self.token.invalidate_revision_if_current(revision);
1168 true
1169 }
1170
1171 pub async fn shutdown(mut self) -> Result<(), Error> {
1180 let validation_was_active = self.close_validation();
1181 self.cancellation.cancel();
1182 let task_result = if let Some(task) = self.task.take() {
1183 task.await
1184 .map_err(|_join_error| Error::BackgroundTaskFailed {
1185 task: "session validator",
1186 })?
1187 } else {
1188 Ok(())
1189 };
1190 if validation_was_active {
1191 return Err(Error::AmbiguousSessionValidation);
1192 }
1193 task_result
1194 }
1195
1196 #[must_use]
1198 pub fn is_finished(&self) -> bool {
1199 self.task.as_ref().is_none_or(JoinHandle::is_finished)
1200 }
1201}
1202
1203impl fmt::Debug for SessionValidator {
1204 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1205 formatter
1206 .debug_struct("SessionValidator")
1207 .field("cancelled", &self.cancellation.is_cancelled())
1208 .finish_non_exhaustive()
1209 }
1210}
1211
1212impl Drop for SessionValidator {
1213 fn drop(&mut self) {
1214 let _validation_was_active = self.close_validation();
1215 self.cancellation.cancel();
1216 if let Some(task) = self.task.take() {
1217 task.abort();
1218 }
1219 }
1220}
1221
1222#[must_use]
1224pub struct ClientBuilder {
1225 credentials: AuthenticationCredentials,
1226 endpoints: Endpoints,
1227 timeout: Duration,
1228 response_limit: usize,
1229 realtime_config: crate::realtime::RealtimeConfig,
1230 proxy: Option<String>,
1231 max_retries: u32,
1232 retry_initial: Duration,
1233 retry_max: Duration,
1234 rate_limits: Option<RateLimitConfig>,
1235}
1236
1237impl ClientBuilder {
1238 fn new(credentials: AuthenticationCredentials) -> Self {
1239 Self {
1240 credentials,
1241 endpoints: Endpoints::default(),
1242 timeout: DEFAULT_TIMEOUT,
1243 response_limit: DEFAULT_RESPONSE_LIMIT,
1244 realtime_config: crate::realtime::RealtimeConfig::default(),
1245 proxy: None,
1246 max_retries: DEFAULT_MAX_RETRIES,
1247 retry_initial: DEFAULT_RETRY_INITIAL,
1248 retry_max: DEFAULT_RETRY_MAX,
1249 rate_limits: Some(RateLimitConfig::default()),
1250 }
1251 }
1252
1253 pub fn endpoints(mut self, endpoints: Endpoints) -> Self {
1255 self.endpoints = endpoints;
1256 self
1257 }
1258
1259 pub fn timeout(mut self, timeout: Duration) -> Self {
1261 self.timeout = timeout;
1262 self
1263 }
1264
1265 pub fn response_limit(mut self, bytes: usize) -> Self {
1267 self.response_limit = bytes;
1268 self
1269 }
1270
1271 pub fn realtime_writer_capacity(mut self, capacity: usize) -> Self {
1275 self.realtime_config.writer_capacity = capacity;
1276 self
1277 }
1278
1279 pub fn realtime_pending_invocation_capacity(mut self, capacity: usize) -> Self {
1284 self.realtime_config.pending_capacity = capacity;
1285 self
1286 }
1287
1288 pub fn realtime_invocation_timeout(mut self, timeout: Duration) -> Self {
1293 self.realtime_config.invocation_timeout = timeout;
1294 self
1295 }
1296
1297 pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
1302 self.proxy = Some(proxy.into());
1303 self
1304 }
1305
1306 pub fn max_retries(mut self, max_retries: u32) -> Self {
1310 self.max_retries = max_retries;
1311 self
1312 }
1313
1314 pub fn retry_delays(mut self, initial: Duration, maximum: Duration) -> Self {
1316 self.retry_initial = initial;
1317 self.retry_max = maximum;
1318 self
1319 }
1320
1321 pub fn rate_limits(mut self, rate_limits: RateLimitConfig) -> Self {
1326 self.rate_limits = Some(rate_limits);
1327 self
1328 }
1329
1330 pub fn disable_rate_limits(mut self) -> Self {
1336 self.rate_limits = None;
1337 self
1338 }
1339
1340 pub fn build(self) -> Result<Client, Error> {
1347 self.realtime_config.validate()?;
1348 if self.timeout.is_zero()
1349 || self.response_limit == 0
1350 || self.retry_initial.is_zero()
1351 || self.retry_max < self.retry_initial
1352 {
1353 return Err(Error::Configuration(
1354 "timeout, response limit, and retry delays must be valid and non-zero".to_owned(),
1355 ));
1356 }
1357 let now = tokio::time::Instant::now();
1358 if [self.timeout, self.retry_initial, self.retry_max]
1359 .into_iter()
1360 .any(|duration| now.checked_add(duration).is_none())
1361 {
1362 return Err(Error::Configuration(
1363 "timeout and retry delays must be representable by the Tokio clock".to_owned(),
1364 ));
1365 }
1366 if self.proxy.is_some() && self.endpoints.uses_plaintext_transport() {
1367 return Err(Error::Configuration(
1368 "a proxy cannot be combined with plain-HTTP loopback endpoints".to_owned(),
1369 ));
1370 }
1371 let mut headers = header::HeaderMap::new();
1372 headers.insert(
1373 header::USER_AGENT,
1374 header::HeaderValue::from_static(USER_AGENT),
1375 );
1376 headers.insert(
1377 header::ACCEPT,
1378 header::HeaderValue::from_static("text/plain"),
1379 );
1380 headers.insert(
1381 header::CONTENT_TYPE,
1382 header::HeaderValue::from_static("application/json"),
1383 );
1384
1385 let mut builder = reqwest::Client::builder()
1386 .no_proxy()
1387 .retry(reqwest::retry::never())
1388 .default_headers(headers)
1389 .timeout(self.timeout)
1390 .redirect(Policy::none());
1391 let mut realtime_builder = reqwest::Client::builder()
1392 .no_proxy()
1393 .retry(reqwest::retry::never())
1394 .http1_only()
1395 .timeout(self.timeout)
1396 .redirect(Policy::none());
1397 if let Some(proxy) = self.proxy {
1398 let proxy = Proxy::all(proxy).map_err(Error::Transport)?;
1399 builder = builder.proxy(proxy.clone());
1400 realtime_builder = realtime_builder.proxy(proxy);
1401 }
1402 let http = builder.build().map_err(Error::Transport)?;
1403 let realtime_http = realtime_builder.build().map_err(Error::Transport)?;
1404 Ok(Client {
1405 credentials: Arc::new(self.credentials),
1406 endpoints: self.endpoints,
1407 http,
1408 realtime_http,
1409 token: Arc::new(TokenStore::default()),
1410 rate_limits: Arc::new(RateLimits::new(self.rate_limits)),
1411 response_limit: self.response_limit,
1412 realtime_config: self.realtime_config,
1413 max_retries: self.max_retries,
1414 retry_initial: self.retry_initial,
1415 retry_max: self.retry_max,
1416 })
1417 }
1418}
1419
1420fn accepted<T>(response: Envelope<T>, codes: ErrorCodeTable) -> Result<T, Error> {
1423 accepted_with_detail(response, codes, |_| None)
1424}
1425
1426fn accepted_with_detail<T, R>(
1429 response: Envelope<T, R>,
1430 codes: ErrorCodeTable,
1431 rejected_order: fn(R) -> Option<OrderId>,
1432) -> Result<T, Error> {
1433 match response {
1434 Envelope::Accepted(body) => Ok(body),
1435 Envelope::Rejected {
1436 error_code,
1437 error_message,
1438 detail,
1439 } => Err(ProviderError::new(
1440 error_code,
1441 codes.name(error_code),
1442 rejected_order(detail),
1443 error_message,
1444 )
1445 .into()),
1446 Envelope::InconsistentStatus {
1447 success,
1448 error_code,
1449 } => Err(Error::InconsistentResponseStatus {
1450 success,
1451 code: error_code,
1452 }),
1453 }
1454}
1455
1456fn validate_token(raw: Option<&str>) -> Result<String, Error> {
1457 let token = raw
1458 .filter(|value| !value.is_empty())
1459 .ok_or(Error::MissingAuthenticationToken)?;
1460 if !token.bytes().all(|byte| byte.is_ascii_graphic()) {
1461 return Err(Error::InvalidAuthenticationToken);
1462 }
1463 header::HeaderValue::from_str(token).map_err(|_| Error::InvalidAuthenticationToken)?;
1464 Ok(token.to_owned())
1465}
1466
1467fn finish_trustworthy_validation(
1468 store: &TokenStore,
1469 mut attempt: ValidationAttempt,
1470 new_token: Option<String>,
1471) -> Result<Option<UpdateOutcome>, Error> {
1472 if !attempt.claim_trustworthy_completion() {
1473 return Err(Error::AmbiguousSessionValidation);
1474 }
1475 let outcome = new_token.map(|token| store.rotate_if_current(attempt.basis(), token));
1476 attempt.disarm();
1477 Ok(outcome)
1478}
1479
1480fn should_retry(error: &Error) -> bool {
1481 match error {
1482 Error::Transport(error) => error.is_timeout() || error.is_connect(),
1483 Error::ProviderRateLimited { .. } => true,
1484 Error::UnexpectedStatus { status } => *status == 429 || *status >= 500,
1485 _ => false,
1486 }
1487}
1488
1489fn is_terminal_session_error(error: &Error) -> bool {
1490 matches!(
1491 error,
1492 Error::NotAuthenticated
1493 | Error::MissingAuthenticationToken
1494 | Error::InvalidAuthenticationToken
1495 | Error::AmbiguousSessionValidation
1496 | Error::ResponseTooLarge { .. }
1497 | Error::Decode(_)
1498 | Error::InconsistentResponseStatus { .. }
1499 | Error::UnexpectedStatus { status: 401 }
1500 | Error::SessionValidationRejected { code: 1..=3, .. }
1501 )
1502}
1503
1504fn retry_delay(error: &Error, backoff: Duration) -> Duration {
1505 match error {
1506 Error::ProviderRateLimited { retry_after, .. } => backoff.max(*retry_after),
1507 _ => backoff,
1508 }
1509}
1510
1511fn parse_retry_after(headers: &header::HeaderMap, now: SystemTime) -> Option<Duration> {
1512 let raw = headers.get(header::RETRY_AFTER)?.to_str().ok()?.trim();
1513 if let Ok(seconds) = raw.parse::<u64>() {
1514 return Some(Duration::from_secs(seconds).min(MAX_COOLDOWN));
1515 }
1516 let deadline = httpdate::parse_http_date(raw).ok()?;
1517 Some(
1518 deadline
1519 .duration_since(now)
1520 .unwrap_or(Duration::ZERO)
1521 .min(MAX_COOLDOWN),
1522 )
1523}
1524
1525#[derive(Clone, Copy, Debug)]
1526enum MutationKind {
1527 OrderPlacement,
1528 OrderCancellation,
1529 OrderModification,
1530 PositionClose,
1531 PartialPositionClose,
1532}
1533
1534impl MutationKind {
1535 const fn operation(self) -> &'static str {
1536 match self {
1537 Self::OrderPlacement => "order placement",
1538 Self::OrderCancellation => "order cancellation",
1539 Self::OrderModification => "order modification",
1540 Self::PositionClose => "position close",
1541 Self::PartialPositionClose => "partial position close",
1542 }
1543 }
1544
1545 const fn path(self) -> &'static str {
1546 match self {
1547 Self::OrderPlacement => "api/Order/place",
1548 Self::OrderCancellation => "api/Order/cancel",
1549 Self::OrderModification => "api/Order/modify",
1550 Self::PositionClose => "api/Position/closeContract",
1551 Self::PartialPositionClose => "api/Position/partialCloseContract",
1552 }
1553 }
1554
1555 const fn error_code_table(self) -> ErrorCodeTable {
1556 match self {
1557 Self::OrderPlacement => ErrorCodeTable::OrderPlacement,
1558 Self::OrderCancellation => ErrorCodeTable::OrderCancellation,
1559 Self::OrderModification => ErrorCodeTable::OrderModification,
1560 Self::PositionClose => ErrorCodeTable::PositionClose,
1561 Self::PartialPositionClose => ErrorCodeTable::PartialPositionClose,
1562 }
1563 }
1564
1565 const fn is_definitive_rejection(self, code: i32) -> bool {
1566 match self {
1570 Self::OrderPlacement => matches!(code, 1..=5 | 8..=10),
1571 Self::OrderCancellation => matches!(code, 1..=3 | 6),
1572 Self::OrderModification => matches!(code, 1..=3 | 6 | 7),
1573 Self::PositionClose => matches!(code, 1..=5 | 8),
1574 Self::PartialPositionClose => matches!(code, 1..=6 | 9),
1575 }
1576 }
1577}
1578
1579fn ambiguity_origin(error: &Error) -> AmbiguityOrigin {
1587 match error {
1588 Error::Transport(failure) => {
1589 if failure.is_timeout() {
1590 AmbiguityOrigin::TransportTimeout
1591 } else if failure.is_connect() {
1592 AmbiguityOrigin::TransportConnect
1593 } else {
1594 AmbiguityOrigin::Transport
1595 }
1596 }
1597 Error::ProviderRateLimited { .. } => AmbiguityOrigin::RateLimited,
1598 Error::UnexpectedStatus { status } => AmbiguityOrigin::HttpStatus(*status),
1599 Error::Decode(_) => AmbiguityOrigin::Decode,
1600 Error::ResponseTooLarge { .. } => AmbiguityOrigin::ResponseTooLarge,
1601 Error::InconsistentResponseStatus { .. } => AmbiguityOrigin::InconsistentStatus,
1602 _ => AmbiguityOrigin::Unclassified,
1603 }
1604}
1605
1606fn ambiguous_mutation(kind: MutationKind, error: Error) -> Error {
1607 match error {
1608 Error::Provider(provider) if kind.is_definitive_rejection(provider.code) => {
1609 Error::Provider(provider)
1610 }
1611 Error::Provider(provider) => Error::AmbiguousMutation {
1612 operation: kind.operation(),
1613 code: Some(provider.code),
1614 name: provider.name,
1615 origin: None,
1616 },
1617 Error::NotAuthenticated
1621 | Error::UnexpectedStatus { status: 401 }
1622 | Error::Configuration(_)
1623 | Error::Encode(_)
1624 | Error::Url(_)
1625 | Error::LocallyRateLimited { .. } => error,
1626 error => Error::AmbiguousMutation {
1627 operation: kind.operation(),
1628 code: None,
1629 name: None,
1630 origin: Some(ambiguity_origin(&error)),
1631 },
1632 }
1633}
1634
1635fn validate_response_status(success: bool, code: i32) -> Result<(), Error> {
1636 if success == (code == 0) {
1637 Ok(())
1638 } else {
1639 Err(Error::InconsistentResponseStatus { success, code })
1640 }
1641}
1642
1643#[derive(Serialize)]
1644#[serde(rename_all = "camelCase")]
1645struct LoginApiKeyRequest<'a> {
1646 user_name: &'a str,
1647 api_key: &'a str,
1648}
1649
1650#[derive(Serialize)]
1651#[serde(rename_all = "camelCase")]
1652struct LoginAppRequest<'a> {
1653 user_name: &'a str,
1654 password: &'a str,
1655 device_id: &'a str,
1656 app_id: &'a str,
1657 verify_key: &'a str,
1658}
1659
1660#[derive(Deserialize)]
1661#[serde(rename_all = "camelCase")]
1662struct LoginResponse {
1663 success: bool,
1664 error_code: i32,
1665 token: Option<String>,
1666}
1667
1668#[derive(Deserialize)]
1669#[serde(rename_all = "camelCase")]
1670struct ValidateResponse {
1671 success: bool,
1672 error_code: i32,
1673 new_token: Option<String>,
1674}
1675
1676#[derive(Serialize)]
1677#[serde(rename_all = "camelCase")]
1678struct AccountSearchRequest {
1679 only_active_accounts: bool,
1680}
1681
1682#[derive(Serialize)]
1683struct AvailableContractsRequest {
1684 live: bool,
1685}
1686
1687#[derive(Serialize)]
1688#[serde(rename_all = "camelCase")]
1689struct AccountRequest {
1690 account_id: AccountId,
1691}
1692
1693#[derive(Serialize)]
1694#[serde(rename_all = "camelCase")]
1695struct AccountOrderRequest {
1696 account_id: AccountId,
1697 order_id: OrderId,
1698}
1699
1700#[derive(Serialize)]
1701#[serde(rename_all = "camelCase")]
1702struct ContractRequest<'a> {
1703 contract_id: &'a crate::ContractId,
1704}
1705
1706#[cfg(test)]
1707mod tests {
1708 use std::time::UNIX_EPOCH;
1709
1710 use super::*;
1711
1712 fn fixture_credentials() -> Credentials {
1713 Credentials::new("synthetic-user", "synthetic-key")
1714 .unwrap_or_else(|error| panic!("fixture credentials must be valid: {error}"))
1715 }
1716
1717 #[test]
1718 fn builder_rejects_durations_outside_the_tokio_clock_range() {
1719 let timeout = Client::builder(fixture_credentials())
1720 .timeout(Duration::MAX)
1721 .build();
1722 assert!(matches!(timeout, Err(Error::Configuration(_))));
1723
1724 let retry = Client::builder(fixture_credentials())
1725 .retry_delays(Duration::from_secs(1), Duration::MAX)
1726 .build();
1727 assert!(matches!(retry, Err(Error::Configuration(_))));
1728 }
1729
1730 #[test]
1731 fn builder_rejects_a_proxy_for_plaintext_loopback_endpoints() {
1732 let endpoints = Endpoints::custom("http://127.0.0.1:8080", "http://[::1]:8080")
1733 .unwrap_or_else(|error| panic!("fixture endpoints must be valid: {error}"));
1734 let client = Client::builder(fixture_credentials())
1735 .endpoints(endpoints)
1736 .proxy("http://127.0.0.1:8888")
1737 .build();
1738
1739 assert!(matches!(client, Err(Error::Configuration(_))));
1740 }
1741
1742 #[test]
1743 fn bearer_token_validation_rejects_whitespace_and_control_bytes() {
1744 assert!(matches!(
1745 validate_token(Some("token with spaces")),
1746 Err(Error::InvalidAuthenticationToken)
1747 ));
1748 assert!(matches!(
1749 validate_token(Some("token\n")),
1750 Err(Error::InvalidAuthenticationToken)
1751 ));
1752 assert!(matches!(
1753 validate_token(Some("")),
1754 Err(Error::MissingAuthenticationToken)
1755 ));
1756 assert_eq!(
1757 validate_token(Some("synthetic.jwt-token_123"))
1758 .unwrap_or_else(|error| panic!("fixture token must be valid: {error}")),
1759 "synthetic.jwt-token_123"
1760 );
1761 }
1762
1763 #[test]
1764 fn validator_shutdown_and_trustworthy_completion_have_one_atomic_winner() {
1765 let losing_store = Arc::new(TokenStore::default());
1766 assert!(
1767 losing_store
1768 .begin_authentication()
1769 .commit("initial-token".to_owned())
1770 );
1771 let losing_tracker = Arc::new(ValidationTracker::default());
1772 let losing_basis = losing_store
1773 .versioned_snapshot()
1774 .unwrap_or_else(|| panic!("fixture token must be installed"));
1775 let losing_attempt = ValidationAttempt::new(
1776 Arc::clone(&losing_store),
1777 losing_basis,
1778 Some(&losing_tracker),
1779 )
1780 .unwrap_or_else(|| panic!("fixture validation must be admitted"));
1781
1782 let closing_revision = losing_tracker
1783 .close()
1784 .unwrap_or_else(|| panic!("shutdown must claim the admitted validation"));
1785 let losing_completion = finish_trustworthy_validation(
1786 &losing_store,
1787 losing_attempt,
1788 Some("rotated-token".to_owned()),
1789 );
1790 losing_store.invalidate_revision_if_current(closing_revision);
1791
1792 assert!(matches!(
1793 losing_completion,
1794 Err(Error::AmbiguousSessionValidation)
1795 ));
1796 assert!(!losing_store.is_authenticated());
1797
1798 let winning_store = Arc::new(TokenStore::default());
1799 assert!(
1800 winning_store
1801 .begin_authentication()
1802 .commit("initial-token".to_owned())
1803 );
1804 let winning_tracker = Arc::new(ValidationTracker::default());
1805 let winning_basis = winning_store
1806 .versioned_snapshot()
1807 .unwrap_or_else(|| panic!("fixture token must be installed"));
1808 let winning_attempt = ValidationAttempt::new(
1809 Arc::clone(&winning_store),
1810 winning_basis,
1811 Some(&winning_tracker),
1812 )
1813 .unwrap_or_else(|| panic!("fixture validation must be admitted"));
1814
1815 let winning_completion = finish_trustworthy_validation(
1816 &winning_store,
1817 winning_attempt,
1818 Some("rotated-token".to_owned()),
1819 )
1820 .unwrap_or_else(|error| panic!("trustworthy completion must win: {error}"));
1821
1822 assert_eq!(winning_completion, Some(UpdateOutcome::Applied));
1823 assert_eq!(winning_tracker.close(), None);
1824 assert_eq!(winning_store.snapshot().as_deref(), Some("rotated-token"));
1825 }
1826
1827 #[test]
1828 fn retry_after_parses_delta_seconds_and_bounds_hostile_values() {
1829 let mut headers = header::HeaderMap::new();
1830 headers.insert(header::RETRY_AFTER, header::HeaderValue::from_static("45"));
1831 assert_eq!(
1832 parse_retry_after(&headers, UNIX_EPOCH),
1833 Some(Duration::from_secs(45))
1834 );
1835
1836 headers.insert(
1837 header::RETRY_AFTER,
1838 header::HeaderValue::from_static("18446744073709551615"),
1839 );
1840 assert_eq!(parse_retry_after(&headers, UNIX_EPOCH), Some(MAX_COOLDOWN));
1841 }
1842
1843 #[test]
1844 fn retry_after_parses_http_dates_without_waiting_for_past_dates() {
1845 let now = UNIX_EPOCH + Duration::from_secs(1_000_000);
1846 let future = now + Duration::from_secs(75);
1847 let mut headers = header::HeaderMap::new();
1848 let future_header = header::HeaderValue::from_str(&httpdate::fmt_http_date(future))
1849 .unwrap_or_else(|error| panic!("fixture header must be valid: {error}"));
1850 headers.insert(header::RETRY_AFTER, future_header);
1851 assert_eq!(
1852 parse_retry_after(&headers, now),
1853 Some(Duration::from_secs(75))
1854 );
1855
1856 let past_header = header::HeaderValue::from_str(&httpdate::fmt_http_date(UNIX_EPOCH))
1857 .unwrap_or_else(|error| panic!("fixture header must be valid: {error}"));
1858 headers.insert(header::RETRY_AFTER, past_header);
1859 assert_eq!(parse_retry_after(&headers, now), Some(Duration::ZERO));
1860 }
1861
1862 #[test]
1863 fn retry_after_rejects_malformed_headers() {
1864 let mut headers = header::HeaderMap::new();
1865 headers.insert(
1866 header::RETRY_AFTER,
1867 header::HeaderValue::from_static("not-a-delay"),
1868 );
1869 assert_eq!(parse_retry_after(&headers, UNIX_EPOCH), None);
1870 }
1871
1872 #[test]
1873 fn provider_retry_delay_uses_the_longer_value_without_adding_delays() {
1874 let provider_delay = Error::ProviderRateLimited {
1875 kind: RateLimitKind::General,
1876 retry_after: Duration::from_secs(20),
1877 };
1878 assert_eq!(
1879 retry_delay(&provider_delay, Duration::from_secs(5)),
1880 Duration::from_secs(20)
1881 );
1882 assert_eq!(
1883 retry_delay(&provider_delay, Duration::from_secs(30)),
1884 Duration::from_secs(30)
1885 );
1886 }
1887
1888 fn untrustworthy_mutation_errors() -> Vec<(Error, AmbiguityOrigin)> {
1889 let decode_error = serde_json::from_str::<()>("not json")
1890 .err()
1891 .unwrap_or_else(|| panic!("fixture JSON must fail to decode"));
1892 vec![
1893 (
1894 Error::UnexpectedStatus { status: 502 },
1895 AmbiguityOrigin::HttpStatus(502),
1896 ),
1897 (
1898 Error::UnexpectedStatus { status: 504 },
1899 AmbiguityOrigin::HttpStatus(504),
1900 ),
1901 (Error::Decode(decode_error), AmbiguityOrigin::Decode),
1902 (
1903 Error::ResponseTooLarge { limit_bytes: 16 },
1904 AmbiguityOrigin::ResponseTooLarge,
1905 ),
1906 (
1907 Error::InconsistentResponseStatus {
1908 success: true,
1909 code: 7,
1910 },
1911 AmbiguityOrigin::InconsistentStatus,
1912 ),
1913 (
1914 Error::ProviderRateLimited {
1915 kind: RateLimitKind::General,
1916 retry_after: Duration::from_secs(1),
1917 },
1918 AmbiguityOrigin::RateLimited,
1919 ),
1920 (
1921 Error::BackgroundTaskFailed {
1922 task: "fixture task",
1923 },
1924 AmbiguityOrigin::Unclassified,
1925 ),
1926 ]
1927 }
1928
1929 #[test]
1930 fn ambiguous_mutations_carry_their_failure_origin_and_stay_distinguishable() {
1931 let mut rendered = Vec::new();
1932 for (error, expected_origin) in untrustworthy_mutation_errors() {
1933 let ambiguous = ambiguous_mutation(MutationKind::OrderPlacement, error);
1934 let text = ambiguous.to_string();
1935 let Error::AmbiguousMutation {
1936 operation,
1937 code,
1938 name,
1939 origin,
1940 } = ambiguous
1941 else {
1942 panic!("untrustworthy outcome must stay an ambiguous mutation");
1943 };
1944 assert_eq!(operation, "order placement");
1945 assert_eq!(code, None);
1946 assert_eq!(name, None);
1947 assert_eq!(origin, Some(expected_origin));
1948 rendered.push(text);
1949 }
1950 let unique: std::collections::BTreeSet<_> = rendered.iter().map(String::as_str).collect();
1951 assert_eq!(
1952 unique.len(),
1953 rendered.len(),
1954 "distinct origins must render distinct evidence: {rendered:?}"
1955 );
1956 assert!(rendered[0].contains("HTTP status 502"));
1957 assert!(rendered[1].contains("HTTP status 504"));
1958
1959 let Error::AmbiguousMutation { code, origin, .. } = ambiguous_mutation(
1962 MutationKind::OrderPlacement,
1963 Error::Provider(ProviderError::new(6, Some("OrderPending"), None, None)),
1964 ) else {
1965 panic!("pending provider rejection must stay ambiguous");
1966 };
1967 assert_eq!(code, Some(6));
1968 assert_eq!(origin, None);
1969 }
1970
1971 #[test]
1972 fn every_constructible_non_passthrough_outcome_carries_exactly_one_evidence_field() {
1973 let errors = || {
1978 vec![
1979 Error::InvalidIdentifier {
1980 kind: "fixture",
1981 reason: "fixture",
1982 },
1983 Error::CredentialsRejected {
1984 code: 1,
1985 name: None,
1986 },
1987 Error::SessionValidationRejected {
1988 code: 1,
1989 name: None,
1990 },
1991 Error::InconsistentResponseStatus {
1992 success: true,
1993 code: 7,
1994 },
1995 Error::MissingAuthenticationToken,
1996 Error::InvalidAuthenticationToken,
1997 Error::AmbiguousSessionValidation,
1998 Error::UnexpectedStatusResponse,
1999 Error::BackgroundTaskFailed {
2000 task: "fixture task",
2001 },
2002 Error::UnexpectedStatus { status: 503 },
2003 Error::ResponseTooLarge { limit_bytes: 16 },
2004 Error::ProviderRateLimited {
2005 kind: RateLimitKind::General,
2006 retry_after: Duration::from_secs(1),
2007 },
2008 Error::Provider(ProviderError::new(0, None, None, None)),
2009 ]
2010 };
2011 for error in errors() {
2012 let Error::AmbiguousMutation { code, origin, .. } =
2013 ambiguous_mutation(MutationKind::PositionClose, error)
2014 else {
2015 panic!("a non-passthrough outcome must stay an ambiguous mutation");
2016 };
2017 assert!(
2018 code.is_some() != origin.is_some(),
2019 "exactly one of code and origin must be set, got code {code:?} origin {origin:?}"
2020 );
2021 }
2022 }
2023
2024 #[test]
2025 fn pre_send_failures_are_not_ambiguous_mutations() {
2026 let errors = || {
2027 vec![
2028 Error::NotAuthenticated,
2029 Error::Configuration("synthetic".to_owned()),
2030 Error::LocallyRateLimited {
2031 kind: RateLimitKind::General,
2032 retry_after: Duration::from_secs(1),
2033 },
2034 Error::Url(
2035 "::".parse::<url::Url>()
2036 .err()
2037 .unwrap_or_else(|| panic!("fixture URL must fail to parse")),
2038 ),
2039 ]
2040 };
2041 for error in errors() {
2042 let expected = error.to_string();
2043 let mapped = ambiguous_mutation(MutationKind::OrderCancellation, error);
2044 assert_eq!(mapped.to_string(), expected);
2045 }
2046 }
2047
2048 #[test]
2049 fn mutation_policies_whitelist_only_documented_definitive_rejections() {
2050 let cases: &[(MutationKind, &[i32], &[i32])] = &[
2051 (
2052 MutationKind::OrderPlacement,
2053 &[1, 2, 3, 4, 5, 8, 9, 10],
2054 &[0, 6, 7, 11, 99],
2055 ),
2056 (
2057 MutationKind::OrderCancellation,
2058 &[1, 2, 3, 6],
2059 &[0, 4, 5, 7, 99],
2060 ),
2061 (
2062 MutationKind::OrderModification,
2063 &[1, 2, 3, 6, 7],
2064 &[0, 4, 5, 8, 99],
2065 ),
2066 (
2067 MutationKind::PositionClose,
2068 &[1, 2, 3, 4, 5, 8],
2069 &[0, 6, 7, 9, 99],
2070 ),
2071 (
2072 MutationKind::PartialPositionClose,
2073 &[1, 2, 3, 4, 5, 6, 9],
2074 &[0, 7, 8, 10, 99],
2075 ),
2076 ];
2077
2078 for &(kind, definitive, ambiguous) in cases {
2079 for &code in definitive {
2080 assert!(
2081 kind.is_definitive_rejection(code),
2082 "{kind:?} code {code} must be definitive"
2083 );
2084 }
2085 for &code in ambiguous {
2086 assert!(
2087 !kind.is_definitive_rejection(code),
2088 "{kind:?} code {code} must be ambiguous"
2089 );
2090 }
2091 }
2092 }
2093
2094 #[test]
2095 fn published_mutation_code_names_agree_with_the_rejection_policy() {
2096 const AMBIGUOUS_NAMES: &[&str] = &["Pending", "OrderPending", "UnknownError"];
2101
2102 for kind in [
2103 MutationKind::OrderPlacement,
2104 MutationKind::OrderCancellation,
2105 MutationKind::OrderModification,
2106 MutationKind::PositionClose,
2107 MutationKind::PartialPositionClose,
2108 ] {
2109 let table = kind.error_code_table();
2110 for code in 1..=16 {
2111 assert_eq!(
2112 kind.is_definitive_rejection(code),
2113 table
2114 .name(code)
2115 .is_some_and(|name| !AMBIGUOUS_NAMES.contains(&name)),
2116 "{kind:?} code {code} must be definitive exactly when its published name is neither pending nor unknown"
2117 );
2118 }
2119 }
2120 }
2121}