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 set_exit_reserve(&self, reserve: usize) -> Result<(), Error> {
207 self.rate_limits.set_exit_reserve(reserve)
208 }
209
210 pub fn builder(credentials: Credentials) -> ClientBuilder {
212 ClientBuilder::new(AuthenticationCredentials::ApiKey(credentials))
213 }
214
215 pub fn application_builder(credentials: ApplicationCredentials) -> ClientBuilder {
217 ClientBuilder::new(AuthenticationCredentials::Application(credentials))
218 }
219
220 #[must_use]
225 pub fn realtime(&self, hub: Hub) -> RealtimeClient {
226 RealtimeClient::new(
227 hub,
228 self.endpoints.clone(),
229 self.realtime_http.clone(),
230 Arc::clone(&self.token),
231 self.realtime_config,
232 )
233 }
234
235 pub async fn authenticate(&self) -> Result<(), Error> {
247 let attempt = self.token.begin_authentication();
248 let response: LoginResponse = match self.credentials.as_ref() {
249 AuthenticationCredentials::ApiKey(credentials) => {
250 let body = LoginApiKeyRequest {
251 user_name: credentials.expose_user_name(),
252 api_key: credentials.expose_api_key(),
253 };
254 self.post_unauthenticated("api/Auth/loginKey", &body)
255 .await?
256 }
257 AuthenticationCredentials::Application(credentials) => {
258 let body = LoginAppRequest {
259 user_name: credentials.expose_user_name(),
260 password: credentials.expose_password(),
261 device_id: credentials.expose_device_id(),
262 app_id: credentials.expose_app_id(),
263 verify_key: credentials.expose_verify_key(),
264 };
265 self.post_unauthenticated("api/Auth/loginApp", &body)
266 .await?
267 }
268 };
269 validate_response_status(response.success, response.error_code)?;
270 if !response.success {
271 return Err(Error::CredentialsRejected {
272 code: response.error_code,
273 name: ErrorCodeTable::Login.name(response.error_code),
274 });
275 }
276 let token = validate_token(response.token.as_deref())?;
277 if !attempt.commit(token) {
278 tracing::debug!(
279 "discarded a delayed authentication response after another authentication completed"
280 );
281 }
282 Ok(())
283 }
284
285 pub async fn authenticate_with_validation(
295 &self,
296 period: Duration,
297 ) -> Result<SessionValidator, Error> {
298 if period.is_zero() {
299 return Err(Error::Configuration(
300 "validation period must be non-zero".to_owned(),
301 ));
302 }
303 let first_tick = tokio::time::Instant::now()
304 .checked_add(period)
305 .ok_or_else(|| {
306 Error::Configuration(
307 "validation period cannot be represented by the Tokio clock".to_owned(),
308 )
309 })?;
310 self.authenticate().await?;
311 let cancellation = CancellationToken::new();
312 let task_cancellation = cancellation.clone();
313 let validation_tracker = Arc::new(ValidationTracker::default());
314 let task_validation_tracker = Arc::clone(&validation_tracker);
315 let client = self.clone();
316 let task = tokio::spawn(async move {
317 let mut ticker = tokio::time::interval_at(first_tick, period);
318 ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
319 loop {
320 tokio::select! {
321 () = task_cancellation.cancelled() => break Ok(()),
322 _ = ticker.tick() => {
323 tokio::select! {
324 biased;
325 result = client.validate_session_tracked(Some(Arc::clone(
326 &task_validation_tracker,
327 ))) => {
328 match result {
329 Ok(()) => {}
330 Err(error)
331 if is_terminal_session_error(&error)
332 && !client
333 .token
334 .has_viable_session_or_authentication() =>
335 {
336 break Err(error);
337 }
338 Err(error) => {
339 tracing::warn!(%error, "ProjectX token validation failed");
340 }
341 }
342 }
343 () = task_cancellation.cancelled() => {
344 if client.token.is_authenticated() {
345 break Ok(());
346 }
347 break Err(Error::AmbiguousSessionValidation);
348 }
349 }
350 }
351 }
352 }
353 });
354 Ok(SessionValidator {
355 cancellation,
356 task: Some(task),
357 token: Arc::clone(&self.token),
358 validation_tracker,
359 })
360 }
361
362 pub async fn validate_session(&self) -> Result<(), Error> {
371 self.validate_session_tracked(None).await
372 }
373
374 pub async fn logout(&self) -> Result<OperationResponse, Error> {
386 self.require_authentication()?;
387 let url = self.endpoints.api_url("api/Auth/logout")?;
388 self.rate_limits
389 .try_acquire(RateLimitKind::General)
390 .map_err(|retry_after| Error::LocallyRateLimited {
391 kind: RateLimitKind::General,
392 retry_after,
393 })?;
394 let basis = self
395 .token
396 .versioned_snapshot()
397 .ok_or(Error::NotAuthenticated)?;
398 let attempt = LogoutAttempt::new(Arc::clone(&self.token), basis);
399 let response = match self
400 .http
401 .request(Method::POST, url)
402 .bearer_auth(attempt.basis().expose())
403 .send()
404 .await
405 {
406 Ok(response) => response,
407 Err(error) if error.is_connect() || error.is_builder() => {
408 attempt.retain_session();
409 return Err(Error::Transport(error));
410 }
411 Err(error) => return Err(Error::Transport(error)),
412 };
413 if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
414 let retry_after =
415 self.apply_provider_cooldown(RateLimitKind::General, response.headers());
416 attempt.retain_session();
417 return Err(Error::ProviderRateLimited {
418 kind: RateLimitKind::General,
419 retry_after,
420 });
421 }
422 let response: Envelope<EmptyBody> = self.decode(response).await?;
423 accepted(response, ErrorCodeTable::Logout)?;
424 Ok(OperationResponse)
425 }
426
427 pub async fn ping(&self) -> Result<(), Error> {
437 let url = self.endpoints.api_url("api/Status/ping")?;
438 let response = self
439 .http
440 .request(Method::GET, url)
441 .send()
442 .await
443 .map_err(Error::Transport)?;
444 let bytes = self.read_bounded_response(response).await?;
445 if bytes == b"pong" {
446 Ok(())
447 } else {
448 Err(Error::UnexpectedStatusResponse)
449 }
450 }
451
452 async fn validate_session_tracked(
453 &self,
454 tracker: Option<Arc<ValidationTracker>>,
455 ) -> Result<(), Error> {
456 let (response, attempt) = self.post_validation(tracker).await?;
457 let response: ValidateResponse = self.decode(response).await?;
458 validate_response_status(response.success, response.error_code)?;
459 if !response.success {
460 let error = Error::SessionValidationRejected {
461 code: response.error_code,
462 name: ErrorCodeTable::Validate.name(response.error_code),
463 };
464 if matches!(response.error_code, 1..=3) {
465 return Err(error);
466 }
467 return Err(Error::AmbiguousSessionValidation);
468 }
469 let new_token = response
470 .new_token
471 .as_deref()
472 .map(|token| validate_token(Some(token)))
473 .transpose()?;
474 if let Some(outcome) = finish_trustworthy_validation(&self.token, attempt, new_token)? {
475 match outcome {
476 UpdateOutcome::Applied => {}
477 UpdateOutcome::Deferred => {
478 tracing::debug!(
479 "deferred token rotation until concurrent authentication completes"
480 );
481 }
482 UpdateOutcome::Stale => {
483 tracing::debug!(
484 "discarded token rotation from a stale session-validation response"
485 );
486 }
487 }
488 }
489 Ok(())
490 }
491
492 pub async fn search_active_accounts(&self) -> Result<ProviderList<Account>, Error> {
501 self.search_accounts(true).await
502 }
503
504 pub async fn search_accounts(
518 &self,
519 only_active_accounts: bool,
520 ) -> Result<ProviderList<Account>, Error> {
521 let response: Envelope<AccountsBody> = self
522 .post_authenticated(
523 RateLimitKind::General,
524 "api/Account/search",
525 &AccountSearchRequest {
526 only_active_accounts,
527 },
528 )
529 .await?;
530 Ok(accepted(response, ErrorCodeTable::SuccessOnly)?.accounts)
531 }
532
533 pub async fn available_contracts(&self, live: bool) -> Result<ProviderList<Contract>, Error> {
542 let response: Envelope<ContractsBody> = self
543 .post_authenticated(
544 RateLimitKind::General,
545 "api/Contract/available",
546 &AvailableContractsRequest { live },
547 )
548 .await?;
549 Ok(accepted(response, ErrorCodeTable::SuccessOnly)?.contracts)
550 }
551
552 pub async fn search_contracts(
563 &self,
564 request: &SearchContracts,
565 ) -> Result<ProviderList<Contract>, Error> {
566 let response: Envelope<ContractsBody> = self
567 .post_authenticated(RateLimitKind::General, "api/Contract/search", request)
568 .await?;
569 Ok(accepted(response, ErrorCodeTable::SuccessOnly)?.contracts)
570 }
571
572 pub async fn contract_by_id(&self, contract_id: &crate::ContractId) -> Result<Contract, Error> {
578 let response: Envelope<ContractBody> = self
579 .post_authenticated(
580 RateLimitKind::General,
581 "api/Contract/searchById",
582 &ContractRequest { contract_id },
583 )
584 .await?;
585 Ok(accepted(response, ErrorCodeTable::ContractSearchById)?.contract)
586 }
587
588 pub async fn retrieve_bars(
597 &self,
598 request: &HistoryRequest,
599 ) -> Result<ProviderList<Bar>, Error> {
600 let response: Envelope<BarsBody> = self
601 .post_authenticated(RateLimitKind::History, "api/History/retrieveBars", request)
602 .await?;
603 Ok(accepted(response, ErrorCodeTable::Bars)?.bars)
604 }
605
606 pub async fn search_orders(&self, request: &OrderSearch) -> Result<ProviderList<Order>, Error> {
615 let response: Envelope<OrdersBody> = self
616 .post_authenticated(RateLimitKind::General, "api/Order/search", request)
617 .await?;
618 Ok(accepted(response, ErrorCodeTable::OrderSearch)?.orders)
619 }
620
621 pub async fn order_by_id(
627 &self,
628 account_id: AccountId,
629 order_id: OrderId,
630 ) -> Result<Order, Error> {
631 let response: Envelope<OrderBody> = self
632 .post_authenticated(
633 RateLimitKind::General,
634 "api/Order/searchById",
635 &AccountOrderRequest {
636 account_id,
637 order_id,
638 },
639 )
640 .await?;
641 Ok(accepted(response, ErrorCodeTable::OrderSearchById)?.order)
642 }
643
644 pub async fn search_open_orders(
658 &self,
659 account_id: AccountId,
660 ) -> Result<ProviderList<Order>, Error> {
661 let response: Envelope<OrdersBody> = self
662 .post_authenticated(
663 RateLimitKind::General,
664 "api/Order/searchOpen",
665 &AccountRequest { account_id },
666 )
667 .await?;
668 Ok(accepted(response, ErrorCodeTable::OrderSearch)?.orders)
669 }
670
671 pub async fn query_orders(&self, request: &OrderQuery) -> Result<OrderPage, Error> {
694 let response: Envelope<OrderPage> = self
695 .post_authenticated(RateLimitKind::General, "api/Order/v2/query", request)
696 .await?;
697 accepted(response, ErrorCodeTable::OrderSearch)
698 }
699
700 pub async fn query_orders_for_exit(&self, request: &OrderQuery) -> Result<OrderPage, Error> {
718 let response: Envelope<OrderPage> =
719 self.post_exit_read("api/Order/v2/query", request).await?;
720 accepted(response, ErrorCodeTable::OrderSearch)
721 }
722
723 pub async fn place_order(&self, request: &PlaceOrder) -> Result<OrderResponse, Error> {
739 let kind = MutationKind::OrderPlacement;
740 let response: Envelope<PlaceOrderBody, PlaceOrderBody> = self
741 .post_authenticated_no_retry(kind, request)
742 .await
743 .map_err(|error| ambiguous_mutation(kind, error))?;
744 let body =
745 accepted_with_detail(response, kind.error_code_table(), |detail| detail.order_id)
746 .map_err(|error| ambiguous_mutation(kind, error))?;
747 let order_id = body.order_id.ok_or(Error::AmbiguousMutation {
748 operation: kind.operation(),
749 code: None,
750 name: None,
751 origin: Some(AmbiguityOrigin::MissingResult),
752 })?;
753 Ok(OrderResponse { order_id })
754 }
755
756 pub async fn cancel_order(&self, request: &CancelOrder) -> Result<OperationResponse, Error> {
772 self.mutation(MutationKind::OrderCancellation, request)
773 .await
774 }
775
776 pub async fn modify_order(&self, request: &ModifyOrder) -> Result<OperationResponse, Error> {
787 self.mutation(MutationKind::OrderModification, request)
788 .await
789 }
790
791 pub async fn search_open_positions(
803 &self,
804 account_id: AccountId,
805 ) -> Result<ProviderList<Position>, Error> {
806 let response: Envelope<PositionsBody> = self
807 .post_authenticated(
808 RateLimitKind::General,
809 "api/Position/searchOpen",
810 &AccountRequest { account_id },
811 )
812 .await?;
813 Ok(accepted(response, ErrorCodeTable::PositionSearch)?.positions)
814 }
815
816 pub async fn search_open_positions_for_exit(
832 &self,
833 account_id: AccountId,
834 ) -> Result<ProviderList<Position>, Error> {
835 let response: Envelope<PositionsBody> = self
836 .post_exit_read("api/Position/searchOpen", &AccountRequest { account_id })
837 .await?;
838 Ok(accepted(response, ErrorCodeTable::PositionSearch)?.positions)
839 }
840
841 pub async fn close_contract(
857 &self,
858 request: &CloseContract,
859 ) -> Result<OperationResponse, Error> {
860 self.mutation(MutationKind::PositionClose, request).await
861 }
862
863 pub async fn partial_close_contract(
886 &self,
887 request: &PartialCloseContract,
888 ) -> Result<OperationResponse, Error> {
889 self.mutation(MutationKind::PartialPositionClose, request)
890 .await
891 }
892
893 pub async fn search_trades(&self, request: &TradeSearch) -> Result<ProviderList<Trade>, Error> {
902 let response: Envelope<TradesBody> = self
903 .post_authenticated(RateLimitKind::General, "api/Trade/search", request)
904 .await?;
905 Ok(accepted(response, ErrorCodeTable::TradeSearch)?.trades)
906 }
907
908 pub async fn query_trades(&self, request: &TradeQuery) -> Result<ProviderList<Trade>, Error> {
920 let response: Envelope<TradesBody> = self
921 .post_authenticated(RateLimitKind::General, "api/Trade/search", request)
922 .await?;
923 Ok(accepted(response, ErrorCodeTable::TradeSearch)?.trades)
924 }
925
926 async fn mutation<T>(&self, kind: MutationKind, request: &T) -> Result<OperationResponse, Error>
927 where
928 T: Serialize + ?Sized,
929 {
930 let response: Envelope<EmptyBody> = self
931 .post_authenticated_no_retry(kind, request)
932 .await
933 .map_err(|error| ambiguous_mutation(kind, error))?;
934 accepted(response, kind.error_code_table())
935 .map_err(|error| ambiguous_mutation(kind, error))?;
936 Ok(OperationResponse)
937 }
938
939 async fn post_unauthenticated<T, R>(&self, path: &str, body: &T) -> Result<R, Error>
940 where
941 T: Serialize + ?Sized,
942 R: DeserializeOwned,
943 {
944 let url = self.endpoints.api_url(path)?;
945 let response = self
946 .http
947 .request(Method::POST, url)
948 .json(body)
949 .send()
950 .await
951 .map_err(Error::Transport)?;
952 self.decode(response).await
953 }
954
955 async fn post_authenticated<T, R>(
956 &self,
957 kind: RateLimitKind,
958 path: &str,
959 body: &T,
960 ) -> Result<R, Error>
961 where
962 T: Serialize + ?Sized,
963 R: DeserializeOwned,
964 {
965 self.post_authenticated_tracked(kind, path, body)
966 .await
967 .map(|(response, _basis)| response)
968 }
969
970 async fn post_authenticated_tracked<T, R>(
971 &self,
972 kind: RateLimitKind,
973 path: &str,
974 body: &T,
975 ) -> Result<(R, TokenSnapshot), Error>
976 where
977 T: Serialize + ?Sized,
978 R: DeserializeOwned,
979 {
980 let encoded = serde_json::to_vec(body).map_err(Error::Encode)?;
981 self.post_authenticated_encoded(kind, path, &encoded).await
982 }
983
984 async fn post_authenticated_encoded<R>(
985 &self,
986 kind: RateLimitKind,
987 path: &str,
988 body: &[u8],
989 ) -> Result<(R, TokenSnapshot), Error>
990 where
991 R: DeserializeOwned,
992 {
993 self.require_authentication()?;
994 let mut attempt = 0;
995 let mut delay = self.retry_initial;
996 loop {
997 self.rate_limits.wait(kind).await;
998 match self.post_authenticated_once(kind, path, body).await {
999 Ok(response) => return Ok(response),
1000 Err(error) if attempt < self.max_retries && should_retry(&error) => {
1001 attempt += 1;
1002 tokio::time::sleep(retry_delay(&error, delay)).await;
1003 delay = delay.saturating_mul(2).min(self.retry_max);
1004 }
1005 Err(error) => return Err(error),
1006 }
1007 }
1008 }
1009
1010 async fn post_authenticated_no_retry<T, R>(
1011 &self,
1012 mutation: MutationKind,
1013 body: &T,
1014 ) -> Result<R, Error>
1015 where
1016 T: Serialize + ?Sized,
1017 R: DeserializeOwned,
1018 {
1019 let exit = match mutation {
1020 MutationKind::OrderCancellation
1021 | MutationKind::PositionClose
1022 | MutationKind::PartialPositionClose => true,
1023 MutationKind::OrderPlacement | MutationKind::OrderModification => false,
1024 };
1025 self.post_once(exit, mutation.path(), body).await
1026 }
1027
1028 async fn post_exit_read<T, R>(&self, path: &str, body: &T) -> Result<R, Error>
1030 where
1031 T: Serialize + ?Sized,
1032 R: DeserializeOwned,
1033 {
1034 self.post_once(true, path, body).await
1035 }
1036
1037 async fn post_once<T, R>(&self, exit: bool, path: &str, body: &T) -> Result<R, Error>
1040 where
1041 T: Serialize + ?Sized,
1042 R: DeserializeOwned,
1043 {
1044 let encoded = serde_json::to_vec(body).map_err(Error::Encode)?;
1045 self.require_authentication()?;
1046 let kind = RateLimitKind::General;
1047 if exit {
1048 self.rate_limits.try_acquire_exit()
1049 } else {
1050 self.rate_limits.try_acquire(kind)
1051 }
1052 .map_err(|retry_after| Error::LocallyRateLimited { kind, retry_after })?;
1053 self.post_authenticated_once(kind, path, &encoded)
1054 .await
1055 .map(|(response, _basis)| response)
1056 }
1057
1058 async fn post_authenticated_once<R>(
1059 &self,
1060 kind: RateLimitKind,
1061 path: &str,
1062 body: &[u8],
1063 ) -> Result<(R, TokenSnapshot), Error>
1064 where
1065 R: DeserializeOwned,
1066 {
1067 let token = self
1068 .token
1069 .versioned_snapshot()
1070 .ok_or(Error::NotAuthenticated)?;
1071 let url = self.endpoints.api_url(path)?;
1072 let request = self
1073 .http
1074 .request(Method::POST, url)
1075 .bearer_auth(token.expose())
1076 .body(body.to_vec());
1077 let response = request.send().await.map_err(Error::Transport)?;
1078 if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
1079 let retry_after = self.apply_provider_cooldown(kind, response.headers());
1080 return Err(Error::ProviderRateLimited { kind, retry_after });
1081 }
1082 if response.status() == reqwest::StatusCode::UNAUTHORIZED {
1083 self.token.invalidate_if_current(&token);
1084 return Err(Error::UnexpectedStatus { status: 401 });
1085 }
1086 self.decode(response)
1087 .await
1088 .map(|response| (response, token))
1089 }
1090
1091 async fn post_validation(
1092 &self,
1093 tracker: Option<Arc<ValidationTracker>>,
1094 ) -> Result<(reqwest::Response, ValidationAttempt), Error> {
1095 self.require_authentication()?;
1096 let mut attempt = 0;
1097 let mut delay = self.retry_initial;
1098 loop {
1099 self.rate_limits.wait(RateLimitKind::General).await;
1100 match self.post_validation_once(tracker.as_ref()).await {
1101 Ok(response) => return Ok(response),
1102 Err(error) if attempt < self.max_retries && should_retry(&error) => {
1103 attempt += 1;
1104 tokio::time::sleep(retry_delay(&error, delay)).await;
1105 delay = delay.saturating_mul(2).min(self.retry_max);
1106 }
1107 Err(error) => return Err(error),
1108 }
1109 }
1110 }
1111
1112 async fn post_validation_once(
1113 &self,
1114 tracker: Option<&Arc<ValidationTracker>>,
1115 ) -> Result<(reqwest::Response, ValidationAttempt), Error> {
1116 let url = self.endpoints.api_url("api/Auth/validate")?;
1117 let basis = self
1118 .token
1119 .versioned_snapshot()
1120 .ok_or(Error::NotAuthenticated)?;
1121 let attempt = ValidationAttempt::new(Arc::clone(&self.token), basis, tracker)
1122 .ok_or(Error::NotAuthenticated)?;
1123 let response = match self
1124 .http
1125 .request(Method::POST, url)
1126 .bearer_auth(attempt.basis().expose())
1127 .send()
1128 .await
1129 {
1130 Ok(response) => response,
1131 Err(error) if error.is_connect() || error.is_builder() => {
1132 attempt.disarm();
1133 return Err(Error::Transport(error));
1134 }
1135 Err(_error) => return Err(Error::AmbiguousSessionValidation),
1136 };
1137 if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
1138 let retry_after =
1139 self.apply_provider_cooldown(RateLimitKind::General, response.headers());
1140 attempt.disarm();
1141 return Err(Error::ProviderRateLimited {
1142 kind: RateLimitKind::General,
1143 retry_after,
1144 });
1145 }
1146 if response.status() == reqwest::StatusCode::UNAUTHORIZED {
1147 return Err(Error::UnexpectedStatus { status: 401 });
1148 }
1149 if !response.status().is_success() {
1150 return Err(Error::AmbiguousSessionValidation);
1151 }
1152 Ok((response, attempt))
1153 }
1154
1155 fn apply_provider_cooldown(
1156 &self,
1157 kind: RateLimitKind,
1158 headers: &header::HeaderMap,
1159 ) -> Duration {
1160 let retry_after = parse_retry_after(headers, SystemTime::now())
1161 .unwrap_or_else(|| self.rate_limits.limit(kind).window())
1162 .min(MAX_COOLDOWN);
1163 self.rate_limits.cool_down(kind, retry_after);
1164 retry_after
1165 }
1166
1167 fn require_authentication(&self) -> Result<(), Error> {
1168 if self.token.is_authenticated() {
1169 Ok(())
1170 } else {
1171 Err(Error::NotAuthenticated)
1172 }
1173 }
1174
1175 async fn decode<R>(&self, response: reqwest::Response) -> Result<R, Error>
1176 where
1177 R: DeserializeOwned,
1178 {
1179 let bytes = self.read_bounded_response(response).await?;
1180 serde_json::from_slice(&bytes).map_err(Error::Decode)
1181 }
1182
1183 async fn read_bounded_response(&self, response: reqwest::Response) -> Result<Vec<u8>, Error> {
1184 let status = response.status();
1185 if !status.is_success() {
1186 return Err(Error::UnexpectedStatus {
1187 status: status.as_u16(),
1188 });
1189 }
1190 let content_length = response.content_length();
1191 if content_length.is_some_and(|length| length > self.response_limit as u64) {
1192 return Err(Error::ResponseTooLarge {
1193 limit_bytes: self.response_limit,
1194 });
1195 }
1196
1197 let capacity = content_length
1198 .and_then(|length| usize::try_from(length).ok())
1199 .unwrap_or(0);
1200 let mut bytes = Vec::with_capacity(capacity);
1201 let mut stream = response.bytes_stream();
1202 while let Some(chunk) = stream.next().await {
1203 let chunk = chunk.map_err(Error::Transport)?;
1204 if bytes.len().saturating_add(chunk.len()) > self.response_limit {
1205 return Err(Error::ResponseTooLarge {
1206 limit_bytes: self.response_limit,
1207 });
1208 }
1209 bytes.extend_from_slice(&chunk);
1210 }
1211 Ok(bytes)
1212 }
1213}
1214
1215impl fmt::Debug for Client {
1216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1217 f.debug_struct("Client")
1218 .field("credentials", &self.credentials)
1219 .field("endpoints", &self.endpoints)
1220 .field("response_limit", &self.response_limit)
1221 .field("max_retries", &self.max_retries)
1222 .field("rate_limits", &self.rate_limits.config())
1223 .finish_non_exhaustive()
1224 }
1225}
1226
1227impl Clone for Client {
1228 fn clone(&self) -> Self {
1229 Self {
1230 credentials: Arc::clone(&self.credentials),
1231 endpoints: self.endpoints.clone(),
1232 http: self.http.clone(),
1233 realtime_http: self.realtime_http.clone(),
1234 token: Arc::clone(&self.token),
1235 rate_limits: Arc::clone(&self.rate_limits),
1236 response_limit: self.response_limit,
1237 realtime_config: self.realtime_config,
1238 max_retries: self.max_retries,
1239 retry_initial: self.retry_initial,
1240 retry_max: self.retry_max,
1241 }
1242 }
1243}
1244
1245#[must_use = "dropping the validator cancels periodic token validation"]
1247pub struct SessionValidator {
1248 cancellation: CancellationToken,
1249 task: Option<JoinHandle<Result<(), Error>>>,
1250 token: Arc<TokenStore>,
1251 validation_tracker: Arc<ValidationTracker>,
1252}
1253
1254impl SessionValidator {
1255 fn close_validation(&self) -> bool {
1256 let Some(revision) = self.validation_tracker.close() else {
1257 return false;
1258 };
1259 self.token.invalidate_revision_if_current(revision);
1260 true
1261 }
1262
1263 pub async fn shutdown(mut self) -> Result<(), Error> {
1272 let validation_was_active = self.close_validation();
1273 self.cancellation.cancel();
1274 let task_result = if let Some(task) = self.task.take() {
1275 task.await
1276 .map_err(|_join_error| Error::BackgroundTaskFailed {
1277 task: "session validator",
1278 })?
1279 } else {
1280 Ok(())
1281 };
1282 if validation_was_active {
1283 return Err(Error::AmbiguousSessionValidation);
1284 }
1285 task_result
1286 }
1287
1288 #[must_use]
1290 pub fn is_finished(&self) -> bool {
1291 self.task.as_ref().is_none_or(JoinHandle::is_finished)
1292 }
1293}
1294
1295impl fmt::Debug for SessionValidator {
1296 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1297 formatter
1298 .debug_struct("SessionValidator")
1299 .field("cancelled", &self.cancellation.is_cancelled())
1300 .finish_non_exhaustive()
1301 }
1302}
1303
1304impl Drop for SessionValidator {
1305 fn drop(&mut self) {
1306 let _validation_was_active = self.close_validation();
1307 self.cancellation.cancel();
1308 if let Some(task) = self.task.take() {
1309 task.abort();
1310 }
1311 }
1312}
1313
1314#[must_use]
1316pub struct ClientBuilder {
1317 credentials: AuthenticationCredentials,
1318 endpoints: Endpoints,
1319 timeout: Duration,
1320 response_limit: usize,
1321 realtime_config: crate::realtime::RealtimeConfig,
1322 proxy: Option<String>,
1323 max_retries: u32,
1324 retry_initial: Duration,
1325 retry_max: Duration,
1326 rate_limits: Option<RateLimitConfig>,
1327}
1328
1329impl ClientBuilder {
1330 fn new(credentials: AuthenticationCredentials) -> Self {
1331 Self {
1332 credentials,
1333 endpoints: Endpoints::default(),
1334 timeout: DEFAULT_TIMEOUT,
1335 response_limit: DEFAULT_RESPONSE_LIMIT,
1336 realtime_config: crate::realtime::RealtimeConfig::default(),
1337 proxy: None,
1338 max_retries: DEFAULT_MAX_RETRIES,
1339 retry_initial: DEFAULT_RETRY_INITIAL,
1340 retry_max: DEFAULT_RETRY_MAX,
1341 rate_limits: Some(RateLimitConfig::default()),
1342 }
1343 }
1344
1345 pub fn endpoints(mut self, endpoints: Endpoints) -> Self {
1347 self.endpoints = endpoints;
1348 self
1349 }
1350
1351 pub fn timeout(mut self, timeout: Duration) -> Self {
1353 self.timeout = timeout;
1354 self
1355 }
1356
1357 pub fn response_limit(mut self, bytes: usize) -> Self {
1359 self.response_limit = bytes;
1360 self
1361 }
1362
1363 pub fn realtime_writer_capacity(mut self, capacity: usize) -> Self {
1367 self.realtime_config.writer_capacity = capacity;
1368 self
1369 }
1370
1371 pub fn realtime_pending_invocation_capacity(mut self, capacity: usize) -> Self {
1376 self.realtime_config.pending_capacity = capacity;
1377 self
1378 }
1379
1380 pub fn realtime_invocation_timeout(mut self, timeout: Duration) -> Self {
1385 self.realtime_config.invocation_timeout = timeout;
1386 self
1387 }
1388
1389 pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
1394 self.proxy = Some(proxy.into());
1395 self
1396 }
1397
1398 pub fn max_retries(mut self, max_retries: u32) -> Self {
1402 self.max_retries = max_retries;
1403 self
1404 }
1405
1406 pub fn retry_delays(mut self, initial: Duration, maximum: Duration) -> Self {
1408 self.retry_initial = initial;
1409 self.retry_max = maximum;
1410 self
1411 }
1412
1413 pub fn rate_limits(mut self, rate_limits: RateLimitConfig) -> Self {
1418 self.rate_limits = Some(rate_limits);
1419 self
1420 }
1421
1422 pub fn disable_rate_limits(mut self) -> Self {
1428 self.rate_limits = None;
1429 self
1430 }
1431
1432 pub fn build(self) -> Result<Client, Error> {
1439 self.realtime_config.validate()?;
1440 if self.timeout.is_zero()
1441 || self.response_limit == 0
1442 || self.retry_initial.is_zero()
1443 || self.retry_max < self.retry_initial
1444 {
1445 return Err(Error::Configuration(
1446 "timeout, response limit, and retry delays must be valid and non-zero".to_owned(),
1447 ));
1448 }
1449 let now = tokio::time::Instant::now();
1450 if [self.timeout, self.retry_initial, self.retry_max]
1451 .into_iter()
1452 .any(|duration| now.checked_add(duration).is_none())
1453 {
1454 return Err(Error::Configuration(
1455 "timeout and retry delays must be representable by the Tokio clock".to_owned(),
1456 ));
1457 }
1458 if self.proxy.is_some() && self.endpoints.uses_plaintext_transport() {
1459 return Err(Error::Configuration(
1460 "a proxy cannot be combined with plain-HTTP loopback endpoints".to_owned(),
1461 ));
1462 }
1463 let mut headers = header::HeaderMap::new();
1464 headers.insert(
1465 header::USER_AGENT,
1466 header::HeaderValue::from_static(USER_AGENT),
1467 );
1468 headers.insert(
1469 header::ACCEPT,
1470 header::HeaderValue::from_static("text/plain"),
1471 );
1472 headers.insert(
1473 header::CONTENT_TYPE,
1474 header::HeaderValue::from_static("application/json"),
1475 );
1476
1477 let mut builder = reqwest::Client::builder()
1478 .no_proxy()
1479 .retry(reqwest::retry::never())
1480 .default_headers(headers)
1481 .timeout(self.timeout)
1482 .redirect(Policy::none());
1483 let mut realtime_builder = reqwest::Client::builder()
1484 .no_proxy()
1485 .retry(reqwest::retry::never())
1486 .http1_only()
1487 .timeout(self.timeout)
1488 .redirect(Policy::none());
1489 if let Some(proxy) = self.proxy {
1490 let proxy = Proxy::all(proxy).map_err(Error::Transport)?;
1491 builder = builder.proxy(proxy.clone());
1492 realtime_builder = realtime_builder.proxy(proxy);
1493 }
1494 let http = builder.build().map_err(Error::Transport)?;
1495 let realtime_http = realtime_builder.build().map_err(Error::Transport)?;
1496 Ok(Client {
1497 credentials: Arc::new(self.credentials),
1498 endpoints: self.endpoints,
1499 http,
1500 realtime_http,
1501 token: Arc::new(TokenStore::default()),
1502 rate_limits: Arc::new(RateLimits::new(self.rate_limits)),
1503 response_limit: self.response_limit,
1504 realtime_config: self.realtime_config,
1505 max_retries: self.max_retries,
1506 retry_initial: self.retry_initial,
1507 retry_max: self.retry_max,
1508 })
1509 }
1510}
1511
1512fn accepted<T>(response: Envelope<T>, codes: ErrorCodeTable) -> Result<T, Error> {
1515 accepted_with_detail(response, codes, |_| None)
1516}
1517
1518fn accepted_with_detail<T, R>(
1521 response: Envelope<T, R>,
1522 codes: ErrorCodeTable,
1523 rejected_order: fn(R) -> Option<OrderId>,
1524) -> Result<T, Error> {
1525 match response {
1526 Envelope::Accepted(body) => Ok(body),
1527 Envelope::Rejected {
1528 error_code,
1529 error_message,
1530 detail,
1531 } => Err(ProviderError::new(
1532 error_code,
1533 codes.name(error_code),
1534 rejected_order(detail),
1535 error_message,
1536 )
1537 .into()),
1538 Envelope::InconsistentStatus {
1539 success,
1540 error_code,
1541 } => Err(Error::InconsistentResponseStatus {
1542 success,
1543 code: error_code,
1544 }),
1545 }
1546}
1547
1548fn validate_token(raw: Option<&str>) -> Result<String, Error> {
1549 let token = raw
1550 .filter(|value| !value.is_empty())
1551 .ok_or(Error::MissingAuthenticationToken)?;
1552 if !token.bytes().all(|byte| byte.is_ascii_graphic()) {
1553 return Err(Error::InvalidAuthenticationToken);
1554 }
1555 header::HeaderValue::from_str(token).map_err(|_| Error::InvalidAuthenticationToken)?;
1556 Ok(token.to_owned())
1557}
1558
1559fn finish_trustworthy_validation(
1560 store: &TokenStore,
1561 mut attempt: ValidationAttempt,
1562 new_token: Option<String>,
1563) -> Result<Option<UpdateOutcome>, Error> {
1564 if !attempt.claim_trustworthy_completion() {
1565 return Err(Error::AmbiguousSessionValidation);
1566 }
1567 let outcome = new_token.map(|token| store.rotate_if_current(attempt.basis(), token));
1568 attempt.disarm();
1569 Ok(outcome)
1570}
1571
1572fn should_retry(error: &Error) -> bool {
1573 match error {
1574 Error::Transport(error) => error.is_timeout() || error.is_connect(),
1575 Error::ProviderRateLimited { .. } => true,
1576 Error::UnexpectedStatus { status } => *status == 429 || *status >= 500,
1577 _ => false,
1578 }
1579}
1580
1581fn is_terminal_session_error(error: &Error) -> bool {
1582 matches!(
1583 error,
1584 Error::NotAuthenticated
1585 | Error::MissingAuthenticationToken
1586 | Error::InvalidAuthenticationToken
1587 | Error::AmbiguousSessionValidation
1588 | Error::ResponseTooLarge { .. }
1589 | Error::Decode(_)
1590 | Error::InconsistentResponseStatus { .. }
1591 | Error::UnexpectedStatus { status: 401 }
1592 | Error::SessionValidationRejected { code: 1..=3, .. }
1593 )
1594}
1595
1596fn retry_delay(error: &Error, backoff: Duration) -> Duration {
1597 match error {
1598 Error::ProviderRateLimited { retry_after, .. } => backoff.max(*retry_after),
1599 _ => backoff,
1600 }
1601}
1602
1603fn parse_retry_after(headers: &header::HeaderMap, now: SystemTime) -> Option<Duration> {
1604 let raw = headers.get(header::RETRY_AFTER)?.to_str().ok()?.trim();
1605 if let Ok(seconds) = raw.parse::<u64>() {
1606 return Some(Duration::from_secs(seconds).min(MAX_COOLDOWN));
1607 }
1608 let deadline = httpdate::parse_http_date(raw).ok()?;
1609 Some(
1610 deadline
1611 .duration_since(now)
1612 .unwrap_or(Duration::ZERO)
1613 .min(MAX_COOLDOWN),
1614 )
1615}
1616
1617#[derive(Clone, Copy, Debug)]
1618enum MutationKind {
1619 OrderPlacement,
1620 OrderCancellation,
1621 OrderModification,
1622 PositionClose,
1623 PartialPositionClose,
1624}
1625
1626impl MutationKind {
1627 const fn operation(self) -> &'static str {
1628 match self {
1629 Self::OrderPlacement => "order placement",
1630 Self::OrderCancellation => "order cancellation",
1631 Self::OrderModification => "order modification",
1632 Self::PositionClose => "position close",
1633 Self::PartialPositionClose => "partial position close",
1634 }
1635 }
1636
1637 const fn path(self) -> &'static str {
1638 match self {
1639 Self::OrderPlacement => "api/Order/place",
1640 Self::OrderCancellation => "api/Order/cancel",
1641 Self::OrderModification => "api/Order/modify",
1642 Self::PositionClose => "api/Position/closeContract",
1643 Self::PartialPositionClose => "api/Position/partialCloseContract",
1644 }
1645 }
1646
1647 const fn error_code_table(self) -> ErrorCodeTable {
1648 match self {
1649 Self::OrderPlacement => ErrorCodeTable::OrderPlacement,
1650 Self::OrderCancellation => ErrorCodeTable::OrderCancellation,
1651 Self::OrderModification => ErrorCodeTable::OrderModification,
1652 Self::PositionClose => ErrorCodeTable::PositionClose,
1653 Self::PartialPositionClose => ErrorCodeTable::PartialPositionClose,
1654 }
1655 }
1656
1657 const fn is_definitive_rejection(self, code: i32) -> bool {
1658 match self {
1662 Self::OrderPlacement => matches!(code, 1..=5 | 8..=10),
1663 Self::OrderCancellation => matches!(code, 1..=3 | 6),
1664 Self::OrderModification => matches!(code, 1..=3 | 6 | 7),
1665 Self::PositionClose => matches!(code, 1..=5 | 8),
1666 Self::PartialPositionClose => matches!(code, 1..=6 | 9),
1667 }
1668 }
1669}
1670
1671fn ambiguity_origin(error: &Error) -> AmbiguityOrigin {
1679 match error {
1680 Error::Transport(failure) => {
1681 if failure.is_timeout() {
1682 AmbiguityOrigin::TransportTimeout
1683 } else if failure.is_connect() {
1684 AmbiguityOrigin::TransportConnect
1685 } else {
1686 AmbiguityOrigin::Transport
1687 }
1688 }
1689 Error::ProviderRateLimited { .. } => AmbiguityOrigin::RateLimited,
1690 Error::UnexpectedStatus { status } => AmbiguityOrigin::HttpStatus(*status),
1691 Error::Decode(_) => AmbiguityOrigin::Decode,
1692 Error::ResponseTooLarge { .. } => AmbiguityOrigin::ResponseTooLarge,
1693 Error::InconsistentResponseStatus { .. } => AmbiguityOrigin::InconsistentStatus,
1694 _ => AmbiguityOrigin::Unclassified,
1695 }
1696}
1697
1698fn ambiguous_mutation(kind: MutationKind, error: Error) -> Error {
1699 match error {
1700 Error::Provider(provider) if kind.is_definitive_rejection(provider.code) => {
1701 Error::Provider(provider)
1702 }
1703 Error::Provider(provider) => Error::AmbiguousMutation {
1704 operation: kind.operation(),
1705 code: Some(provider.code),
1706 name: provider.name,
1707 origin: None,
1708 },
1709 Error::NotAuthenticated
1713 | Error::UnexpectedStatus { status: 401 }
1714 | Error::Configuration(_)
1715 | Error::Encode(_)
1716 | Error::Url(_)
1717 | Error::LocallyRateLimited { .. } => error,
1718 error => Error::AmbiguousMutation {
1719 operation: kind.operation(),
1720 code: None,
1721 name: None,
1722 origin: Some(ambiguity_origin(&error)),
1723 },
1724 }
1725}
1726
1727fn validate_response_status(success: bool, code: i32) -> Result<(), Error> {
1728 if success == (code == 0) {
1729 Ok(())
1730 } else {
1731 Err(Error::InconsistentResponseStatus { success, code })
1732 }
1733}
1734
1735#[derive(Serialize)]
1736#[serde(rename_all = "camelCase")]
1737struct LoginApiKeyRequest<'a> {
1738 user_name: &'a str,
1739 api_key: &'a str,
1740}
1741
1742#[derive(Serialize)]
1743#[serde(rename_all = "camelCase")]
1744struct LoginAppRequest<'a> {
1745 user_name: &'a str,
1746 password: &'a str,
1747 device_id: &'a str,
1748 app_id: &'a str,
1749 verify_key: &'a str,
1750}
1751
1752#[derive(Deserialize)]
1753#[serde(rename_all = "camelCase")]
1754struct LoginResponse {
1755 success: bool,
1756 error_code: i32,
1757 token: Option<String>,
1758}
1759
1760#[derive(Deserialize)]
1761#[serde(rename_all = "camelCase")]
1762struct ValidateResponse {
1763 success: bool,
1764 error_code: i32,
1765 new_token: Option<String>,
1766}
1767
1768#[derive(Serialize)]
1769#[serde(rename_all = "camelCase")]
1770struct AccountSearchRequest {
1771 only_active_accounts: bool,
1772}
1773
1774#[derive(Serialize)]
1775struct AvailableContractsRequest {
1776 live: bool,
1777}
1778
1779#[derive(Serialize)]
1780#[serde(rename_all = "camelCase")]
1781struct AccountRequest {
1782 account_id: AccountId,
1783}
1784
1785#[derive(Serialize)]
1786#[serde(rename_all = "camelCase")]
1787struct AccountOrderRequest {
1788 account_id: AccountId,
1789 order_id: OrderId,
1790}
1791
1792#[derive(Serialize)]
1793#[serde(rename_all = "camelCase")]
1794struct ContractRequest<'a> {
1795 contract_id: &'a crate::ContractId,
1796}
1797
1798#[cfg(test)]
1799mod tests {
1800 use std::time::UNIX_EPOCH;
1801
1802 use super::*;
1803
1804 fn fixture_credentials() -> Credentials {
1805 Credentials::new("synthetic-user", "synthetic-key")
1806 .unwrap_or_else(|error| panic!("fixture credentials must be valid: {error}"))
1807 }
1808
1809 #[test]
1810 fn builder_rejects_durations_outside_the_tokio_clock_range() {
1811 let timeout = Client::builder(fixture_credentials())
1812 .timeout(Duration::MAX)
1813 .build();
1814 assert!(matches!(timeout, Err(Error::Configuration(_))));
1815
1816 let retry = Client::builder(fixture_credentials())
1817 .retry_delays(Duration::from_secs(1), Duration::MAX)
1818 .build();
1819 assert!(matches!(retry, Err(Error::Configuration(_))));
1820 }
1821
1822 #[test]
1823 fn builder_rejects_a_proxy_for_plaintext_loopback_endpoints() {
1824 let endpoints = Endpoints::custom("http://127.0.0.1:8080", "http://[::1]:8080")
1825 .unwrap_or_else(|error| panic!("fixture endpoints must be valid: {error}"));
1826 let client = Client::builder(fixture_credentials())
1827 .endpoints(endpoints)
1828 .proxy("http://127.0.0.1:8888")
1829 .build();
1830
1831 assert!(matches!(client, Err(Error::Configuration(_))));
1832 }
1833
1834 #[test]
1835 fn bearer_token_validation_rejects_whitespace_and_control_bytes() {
1836 assert!(matches!(
1837 validate_token(Some("token with spaces")),
1838 Err(Error::InvalidAuthenticationToken)
1839 ));
1840 assert!(matches!(
1841 validate_token(Some("token\n")),
1842 Err(Error::InvalidAuthenticationToken)
1843 ));
1844 assert!(matches!(
1845 validate_token(Some("")),
1846 Err(Error::MissingAuthenticationToken)
1847 ));
1848 assert_eq!(
1849 validate_token(Some("synthetic.jwt-token_123"))
1850 .unwrap_or_else(|error| panic!("fixture token must be valid: {error}")),
1851 "synthetic.jwt-token_123"
1852 );
1853 }
1854
1855 #[test]
1856 fn validator_shutdown_and_trustworthy_completion_have_one_atomic_winner() {
1857 let losing_store = Arc::new(TokenStore::default());
1858 assert!(
1859 losing_store
1860 .begin_authentication()
1861 .commit("initial-token".to_owned())
1862 );
1863 let losing_tracker = Arc::new(ValidationTracker::default());
1864 let losing_basis = losing_store
1865 .versioned_snapshot()
1866 .unwrap_or_else(|| panic!("fixture token must be installed"));
1867 let losing_attempt = ValidationAttempt::new(
1868 Arc::clone(&losing_store),
1869 losing_basis,
1870 Some(&losing_tracker),
1871 )
1872 .unwrap_or_else(|| panic!("fixture validation must be admitted"));
1873
1874 let closing_revision = losing_tracker
1875 .close()
1876 .unwrap_or_else(|| panic!("shutdown must claim the admitted validation"));
1877 let losing_completion = finish_trustworthy_validation(
1878 &losing_store,
1879 losing_attempt,
1880 Some("rotated-token".to_owned()),
1881 );
1882 losing_store.invalidate_revision_if_current(closing_revision);
1883
1884 assert!(matches!(
1885 losing_completion,
1886 Err(Error::AmbiguousSessionValidation)
1887 ));
1888 assert!(!losing_store.is_authenticated());
1889
1890 let winning_store = Arc::new(TokenStore::default());
1891 assert!(
1892 winning_store
1893 .begin_authentication()
1894 .commit("initial-token".to_owned())
1895 );
1896 let winning_tracker = Arc::new(ValidationTracker::default());
1897 let winning_basis = winning_store
1898 .versioned_snapshot()
1899 .unwrap_or_else(|| panic!("fixture token must be installed"));
1900 let winning_attempt = ValidationAttempt::new(
1901 Arc::clone(&winning_store),
1902 winning_basis,
1903 Some(&winning_tracker),
1904 )
1905 .unwrap_or_else(|| panic!("fixture validation must be admitted"));
1906
1907 let winning_completion = finish_trustworthy_validation(
1908 &winning_store,
1909 winning_attempt,
1910 Some("rotated-token".to_owned()),
1911 )
1912 .unwrap_or_else(|error| panic!("trustworthy completion must win: {error}"));
1913
1914 assert_eq!(winning_completion, Some(UpdateOutcome::Applied));
1915 assert_eq!(winning_tracker.close(), None);
1916 assert_eq!(winning_store.snapshot().as_deref(), Some("rotated-token"));
1917 }
1918
1919 #[test]
1920 fn retry_after_parses_delta_seconds_and_bounds_hostile_values() {
1921 let mut headers = header::HeaderMap::new();
1922 headers.insert(header::RETRY_AFTER, header::HeaderValue::from_static("45"));
1923 assert_eq!(
1924 parse_retry_after(&headers, UNIX_EPOCH),
1925 Some(Duration::from_secs(45))
1926 );
1927
1928 headers.insert(
1929 header::RETRY_AFTER,
1930 header::HeaderValue::from_static("18446744073709551615"),
1931 );
1932 assert_eq!(parse_retry_after(&headers, UNIX_EPOCH), Some(MAX_COOLDOWN));
1933 }
1934
1935 #[test]
1936 fn retry_after_parses_http_dates_without_waiting_for_past_dates() {
1937 let now = UNIX_EPOCH + Duration::from_secs(1_000_000);
1938 let future = now + Duration::from_secs(75);
1939 let mut headers = header::HeaderMap::new();
1940 let future_header = header::HeaderValue::from_str(&httpdate::fmt_http_date(future))
1941 .unwrap_or_else(|error| panic!("fixture header must be valid: {error}"));
1942 headers.insert(header::RETRY_AFTER, future_header);
1943 assert_eq!(
1944 parse_retry_after(&headers, now),
1945 Some(Duration::from_secs(75))
1946 );
1947
1948 let past_header = header::HeaderValue::from_str(&httpdate::fmt_http_date(UNIX_EPOCH))
1949 .unwrap_or_else(|error| panic!("fixture header must be valid: {error}"));
1950 headers.insert(header::RETRY_AFTER, past_header);
1951 assert_eq!(parse_retry_after(&headers, now), Some(Duration::ZERO));
1952 }
1953
1954 #[test]
1955 fn retry_after_rejects_malformed_headers() {
1956 let mut headers = header::HeaderMap::new();
1957 headers.insert(
1958 header::RETRY_AFTER,
1959 header::HeaderValue::from_static("not-a-delay"),
1960 );
1961 assert_eq!(parse_retry_after(&headers, UNIX_EPOCH), None);
1962 }
1963
1964 #[test]
1965 fn provider_retry_delay_uses_the_longer_value_without_adding_delays() {
1966 let provider_delay = Error::ProviderRateLimited {
1967 kind: RateLimitKind::General,
1968 retry_after: Duration::from_secs(20),
1969 };
1970 assert_eq!(
1971 retry_delay(&provider_delay, Duration::from_secs(5)),
1972 Duration::from_secs(20)
1973 );
1974 assert_eq!(
1975 retry_delay(&provider_delay, Duration::from_secs(30)),
1976 Duration::from_secs(30)
1977 );
1978 }
1979
1980 fn untrustworthy_mutation_errors() -> Vec<(Error, AmbiguityOrigin)> {
1981 let decode_error = serde_json::from_str::<()>("not json")
1982 .err()
1983 .unwrap_or_else(|| panic!("fixture JSON must fail to decode"));
1984 vec![
1985 (
1986 Error::UnexpectedStatus { status: 502 },
1987 AmbiguityOrigin::HttpStatus(502),
1988 ),
1989 (
1990 Error::UnexpectedStatus { status: 504 },
1991 AmbiguityOrigin::HttpStatus(504),
1992 ),
1993 (Error::Decode(decode_error), AmbiguityOrigin::Decode),
1994 (
1995 Error::ResponseTooLarge { limit_bytes: 16 },
1996 AmbiguityOrigin::ResponseTooLarge,
1997 ),
1998 (
1999 Error::InconsistentResponseStatus {
2000 success: true,
2001 code: 7,
2002 },
2003 AmbiguityOrigin::InconsistentStatus,
2004 ),
2005 (
2006 Error::ProviderRateLimited {
2007 kind: RateLimitKind::General,
2008 retry_after: Duration::from_secs(1),
2009 },
2010 AmbiguityOrigin::RateLimited,
2011 ),
2012 (
2013 Error::BackgroundTaskFailed {
2014 task: "fixture task",
2015 },
2016 AmbiguityOrigin::Unclassified,
2017 ),
2018 ]
2019 }
2020
2021 #[test]
2022 fn ambiguous_mutations_carry_their_failure_origin_and_stay_distinguishable() {
2023 let mut rendered = Vec::new();
2024 for (error, expected_origin) in untrustworthy_mutation_errors() {
2025 let ambiguous = ambiguous_mutation(MutationKind::OrderPlacement, error);
2026 let text = ambiguous.to_string();
2027 let Error::AmbiguousMutation {
2028 operation,
2029 code,
2030 name,
2031 origin,
2032 } = ambiguous
2033 else {
2034 panic!("untrustworthy outcome must stay an ambiguous mutation");
2035 };
2036 assert_eq!(operation, "order placement");
2037 assert_eq!(code, None);
2038 assert_eq!(name, None);
2039 assert_eq!(origin, Some(expected_origin));
2040 rendered.push(text);
2041 }
2042 let unique: std::collections::BTreeSet<_> = rendered.iter().map(String::as_str).collect();
2043 assert_eq!(
2044 unique.len(),
2045 rendered.len(),
2046 "distinct origins must render distinct evidence: {rendered:?}"
2047 );
2048 assert!(rendered[0].contains("HTTP status 502"));
2049 assert!(rendered[1].contains("HTTP status 504"));
2050
2051 let Error::AmbiguousMutation { code, origin, .. } = ambiguous_mutation(
2054 MutationKind::OrderPlacement,
2055 Error::Provider(ProviderError::new(6, Some("OrderPending"), None, None)),
2056 ) else {
2057 panic!("pending provider rejection must stay ambiguous");
2058 };
2059 assert_eq!(code, Some(6));
2060 assert_eq!(origin, None);
2061 }
2062
2063 #[test]
2064 fn every_constructible_non_passthrough_outcome_carries_exactly_one_evidence_field() {
2065 let errors = || {
2070 vec![
2071 Error::InvalidIdentifier {
2072 kind: "fixture",
2073 reason: "fixture",
2074 },
2075 Error::CredentialsRejected {
2076 code: 1,
2077 name: None,
2078 },
2079 Error::SessionValidationRejected {
2080 code: 1,
2081 name: None,
2082 },
2083 Error::InconsistentResponseStatus {
2084 success: true,
2085 code: 7,
2086 },
2087 Error::MissingAuthenticationToken,
2088 Error::InvalidAuthenticationToken,
2089 Error::AmbiguousSessionValidation,
2090 Error::UnexpectedStatusResponse,
2091 Error::BackgroundTaskFailed {
2092 task: "fixture task",
2093 },
2094 Error::UnexpectedStatus { status: 503 },
2095 Error::ResponseTooLarge { limit_bytes: 16 },
2096 Error::ProviderRateLimited {
2097 kind: RateLimitKind::General,
2098 retry_after: Duration::from_secs(1),
2099 },
2100 Error::Provider(ProviderError::new(0, None, None, None)),
2101 ]
2102 };
2103 for error in errors() {
2104 let Error::AmbiguousMutation { code, origin, .. } =
2105 ambiguous_mutation(MutationKind::PositionClose, error)
2106 else {
2107 panic!("a non-passthrough outcome must stay an ambiguous mutation");
2108 };
2109 assert!(
2110 code.is_some() != origin.is_some(),
2111 "exactly one of code and origin must be set, got code {code:?} origin {origin:?}"
2112 );
2113 }
2114 }
2115
2116 #[test]
2117 fn pre_send_failures_are_not_ambiguous_mutations() {
2118 let errors = || {
2119 vec![
2120 Error::NotAuthenticated,
2121 Error::Configuration("synthetic".to_owned()),
2122 Error::LocallyRateLimited {
2123 kind: RateLimitKind::General,
2124 retry_after: Duration::from_secs(1),
2125 },
2126 Error::Url(
2127 "::".parse::<url::Url>()
2128 .err()
2129 .unwrap_or_else(|| panic!("fixture URL must fail to parse")),
2130 ),
2131 ]
2132 };
2133 for error in errors() {
2134 let expected = error.to_string();
2135 let mapped = ambiguous_mutation(MutationKind::OrderCancellation, error);
2136 assert_eq!(mapped.to_string(), expected);
2137 }
2138 }
2139
2140 #[test]
2141 fn mutation_policies_whitelist_only_documented_definitive_rejections() {
2142 let cases: &[(MutationKind, &[i32], &[i32])] = &[
2143 (
2144 MutationKind::OrderPlacement,
2145 &[1, 2, 3, 4, 5, 8, 9, 10],
2146 &[0, 6, 7, 11, 99],
2147 ),
2148 (
2149 MutationKind::OrderCancellation,
2150 &[1, 2, 3, 6],
2151 &[0, 4, 5, 7, 99],
2152 ),
2153 (
2154 MutationKind::OrderModification,
2155 &[1, 2, 3, 6, 7],
2156 &[0, 4, 5, 8, 99],
2157 ),
2158 (
2159 MutationKind::PositionClose,
2160 &[1, 2, 3, 4, 5, 8],
2161 &[0, 6, 7, 9, 99],
2162 ),
2163 (
2164 MutationKind::PartialPositionClose,
2165 &[1, 2, 3, 4, 5, 6, 9],
2166 &[0, 7, 8, 10, 99],
2167 ),
2168 ];
2169
2170 for &(kind, definitive, ambiguous) in cases {
2171 for &code in definitive {
2172 assert!(
2173 kind.is_definitive_rejection(code),
2174 "{kind:?} code {code} must be definitive"
2175 );
2176 }
2177 for &code in ambiguous {
2178 assert!(
2179 !kind.is_definitive_rejection(code),
2180 "{kind:?} code {code} must be ambiguous"
2181 );
2182 }
2183 }
2184 }
2185
2186 #[test]
2187 fn published_mutation_code_names_agree_with_the_rejection_policy() {
2188 const AMBIGUOUS_NAMES: &[&str] = &["Pending", "OrderPending", "UnknownError"];
2193
2194 for kind in [
2195 MutationKind::OrderPlacement,
2196 MutationKind::OrderCancellation,
2197 MutationKind::OrderModification,
2198 MutationKind::PositionClose,
2199 MutationKind::PartialPositionClose,
2200 ] {
2201 let table = kind.error_code_table();
2202 for code in 1..=16 {
2203 assert_eq!(
2204 kind.is_definitive_rejection(code),
2205 table
2206 .name(code)
2207 .is_some_and(|name| !AMBIGUOUS_NAMES.contains(&name)),
2208 "{kind:?} code {code} must be definitive exactly when its published name is neither pending nor unknown"
2209 );
2210 }
2211 }
2212 }
2213}