1use std::{
54 collections::HashMap,
55 fmt::Debug,
56 num::NonZeroU32,
57 sync::{Arc, LazyLock},
58};
59
60use ahash::AHashMap;
61use jiff::{Timestamp, tz::Offset};
62use nautilus_common::cache::InstrumentLookupError;
63use nautilus_core::{
64 UnixNanos,
65 consts::NAUTILUS_USER_AGENT,
66 string::urlencoding,
67 time::{AtomicTime, get_atomic_clock_realtime},
68};
69use nautilus_model::{
70 data::{
71 Bar, BarType, BookOrder, FundingRateUpdate, OrderBookDelta, OrderBookDeltas, TradeTick,
72 },
73 enums::{
74 AggregationSource, BarAggregation, BookAction, OrderSide as NautilusOrderSide, PriceType,
75 RecordFlag,
76 },
77 events::AccountState,
78 identifiers::{AccountId, InstrumentId},
79 instruments::{Instrument, InstrumentAny},
80 reports::{FillReport, OrderStatusReport, PositionStatusReport},
81 types::{Price, Quantity},
82};
83use nautilus_network::{
84 http::{HttpClient, Method, USER_AGENT},
85 ratelimiter::{RateLimiter, clock::MonotonicClock, quota::Quota},
86 retry::{RetryConfig, RetryError, RetryManager},
87};
88use parking_lot::Mutex;
89use rust_decimal::Decimal;
90use serde::{Deserialize, Serialize, de::DeserializeOwned};
91use tokio_util::sync::CancellationToken;
92use ustr::Ustr;
93
94use super::error::DydxHttpError;
95use crate::{
96 common::{
97 consts::{DYDX_HTTP_URL, DYDX_TESTNET_HTTP_URL},
98 enums::{DydxCandleResolution, DydxNetwork},
99 instrument_cache::InstrumentCache,
100 parse::extract_raw_symbol,
101 },
102 http::parse::{parse_account_state_from_http, parse_instrument_any},
103};
104
105const DYDX_MAX_BARS_PER_REQUEST: u32 = 1_000;
107
108const ENDPOINT_PERPETUAL_MARKETS: &str = "/v4/perpetualMarkets";
110
111const QUERY_MARKET_TYPE_PERPETUAL: &str = "marketType=PERPETUAL";
112const DYDX_INDEXER_REPORT_LIMIT: u32 = 1_000;
113
114fn bar_type_to_resolution(bar_type: &BarType) -> anyhow::Result<DydxCandleResolution> {
115 if bar_type.aggregation_source() != AggregationSource::External {
116 anyhow::bail!(
117 "dYdX only supports EXTERNAL aggregation, was {:?}",
118 bar_type.aggregation_source()
119 );
120 }
121
122 let spec = bar_type.spec();
123 if spec.price_type != PriceType::Last {
124 anyhow::bail!(
125 "dYdX only supports LAST price type, was {:?}",
126 spec.price_type
127 );
128 }
129
130 DydxCandleResolution::from_bar_spec(&spec)
131}
132
133pub static DYDX_REST_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
139 Quota::per_second(NonZeroU32::new(9).expect("non-zero")).expect("valid constant")
140});
141
142type DydxRestRateLimiter = Arc<RateLimiter<Ustr, MonotonicClock>>;
143
144static DYDX_REST_RATE_LIMITERS: LazyLock<Mutex<AHashMap<String, DydxRestRateLimiter>>> =
149 LazyLock::new(|| Mutex::new(AHashMap::new()));
150
151static DYDX_RATE_LIMIT_KEY: LazyLock<Ustr> = LazyLock::new(|| Ustr::from("dydx:rest"));
152
153fn rate_limit_keys() -> Vec<Ustr> {
154 vec![*DYDX_RATE_LIMIT_KEY]
155}
156
157fn rest_rate_limiter(base_url: &str) -> DydxRestRateLimiter {
158 DYDX_REST_RATE_LIMITERS
159 .lock()
160 .entry(base_url.to_string())
161 .or_insert_with(|| Arc::new(RateLimiter::new_with_quota(Some(*DYDX_REST_QUOTA), vec![])))
162 .clone()
163}
164
165#[derive(Debug, Serialize, Deserialize)]
170pub struct DydxResponse<T> {
171 pub data: T,
173}
174
175pub struct DydxRawHttpClient {
185 base_url: String,
186 client: HttpClient,
187 retry_manager: RetryManager<DydxHttpError>,
188 cancellation_token: CancellationToken,
189 network: DydxNetwork,
190}
191
192impl Default for DydxRawHttpClient {
193 fn default() -> Self {
194 Self::new(None, 60, None, DydxNetwork::Mainnet, None)
195 .expect("Failed to create default DydxRawHttpClient")
196 }
197}
198
199impl Debug for DydxRawHttpClient {
200 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201 f.debug_struct(stringify!(DydxRawHttpClient))
202 .field("base_url", &self.base_url)
203 .field("network", &self.network)
204 .finish_non_exhaustive()
205 }
206}
207
208impl DydxRawHttpClient {
209 pub fn cancel_all_requests(&self) {
211 self.cancellation_token.cancel();
212 }
213
214 pub fn cancellation_token(&self) -> &CancellationToken {
216 &self.cancellation_token
217 }
218
219 pub fn new(
228 base_url: Option<String>,
229 timeout_secs: u64,
230 proxy_url: Option<String>,
231 network: DydxNetwork,
232 retry_config: Option<RetryConfig>,
233 ) -> anyhow::Result<Self> {
234 let base_url = match network {
235 DydxNetwork::Testnet => base_url.unwrap_or_else(|| DYDX_TESTNET_HTTP_URL.to_string()),
236 DydxNetwork::Mainnet => base_url.unwrap_or_else(|| DYDX_HTTP_URL.to_string()),
237 };
238
239 let retry_manager = RetryManager::new(retry_config.unwrap_or_default());
240
241 let mut headers = HashMap::new();
242 headers.insert(USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string());
243
244 let client = HttpClient::builder()
245 .headers(headers)
246 .timeout_secs(timeout_secs)
247 .maybe_proxy_url(proxy_url)
248 .rate_limiters(vec![rest_rate_limiter(&base_url)])
249 .build()
250 .map_err(|e| {
251 DydxHttpError::ValidationError(format!("Failed to create HTTP client: {e}"))
252 })?;
253
254 Ok(Self {
255 base_url,
256 client,
257 retry_manager,
258 cancellation_token: CancellationToken::new(),
259 network,
260 })
261 }
262
263 #[must_use]
265 pub const fn is_testnet(&self) -> bool {
266 matches!(self.network, DydxNetwork::Testnet)
267 }
268
269 #[must_use]
271 pub fn base_url(&self) -> &str {
272 &self.base_url
273 }
274
275 pub async fn send_request<T>(
287 &self,
288 method: Method,
289 endpoint: &str,
290 query_params: Option<&str>,
291 ) -> Result<T, DydxHttpError>
292 where
293 T: DeserializeOwned,
294 {
295 let url = if let Some(params) = query_params {
296 format!("{}{endpoint}?{params}", self.base_url)
297 } else {
298 format!("{}{endpoint}", self.base_url)
299 };
300
301 let operation = || async {
302 let request = self
303 .client
304 .request_with_ustr_keys(
305 method.clone(),
306 url.clone(),
307 None,
308 None,
309 None,
310 None,
311 Some(rate_limit_keys()),
312 )
313 .await
314 .map_err(|e| DydxHttpError::HttpClientError(e.to_string()))?;
315
316 if !request.status.is_success() {
317 return Err(DydxHttpError::HttpStatus {
318 status: request.status.as_u16(),
319 message: String::from_utf8_lossy(&request.body).to_string(),
320 });
321 }
322
323 Ok(request)
324 };
325
326 let should_retry = |error: &DydxHttpError| -> bool {
331 match error {
332 DydxHttpError::HttpClientError(_) => true,
333 DydxHttpError::HttpStatus { status, .. } => *status == 429 || *status >= 500,
334 _ => false,
335 }
336 };
337
338 let response = self
339 .retry_manager
340 .execute_with_retry_with_cancel(
341 endpoint,
342 operation,
343 should_retry,
344 create_retry_error,
345 &self.cancellation_token,
346 )
347 .await?;
348
349 serde_json::from_slice(&response.body).map_err(|e| DydxHttpError::Deserialization {
350 error: e.to_string(),
351 body: String::from_utf8_lossy(&response.body).to_string(),
352 })
353 }
354
355 pub async fn send_post_request<T, B>(
368 &self,
369 endpoint: &str,
370 body: &B,
371 ) -> Result<T, DydxHttpError>
372 where
373 T: DeserializeOwned,
374 B: Serialize,
375 {
376 let url = format!("{}{endpoint}", self.base_url);
377
378 let body_bytes = serde_json::to_vec(body).map_err(|e| DydxHttpError::Serialization {
379 error: e.to_string(),
380 })?;
381
382 let operation = || async {
383 let request = self
384 .client
385 .request_with_ustr_keys(
386 Method::POST,
387 url.clone(),
388 None,
389 None,
390 Some(body_bytes.clone()),
391 None,
392 Some(rate_limit_keys()),
393 )
394 .await
395 .map_err(|e| DydxHttpError::HttpClientError(e.to_string()))?;
396
397 if !request.status.is_success() {
398 return Err(DydxHttpError::HttpStatus {
399 status: request.status.as_u16(),
400 message: String::from_utf8_lossy(&request.body).to_string(),
401 });
402 }
403
404 Ok(request)
405 };
406
407 let should_retry = |error: &DydxHttpError| -> bool {
409 match error {
410 DydxHttpError::HttpClientError(_) => true,
411 DydxHttpError::HttpStatus { status, .. } => *status == 429 || *status >= 500,
412 _ => false,
413 }
414 };
415
416 let response = self
417 .retry_manager
418 .execute_with_retry_with_cancel(
419 endpoint,
420 operation,
421 should_retry,
422 create_retry_error,
423 &self.cancellation_token,
424 )
425 .await?;
426
427 serde_json::from_slice(&response.body).map_err(|e| DydxHttpError::Deserialization {
428 error: e.to_string(),
429 body: String::from_utf8_lossy(&response.body).to_string(),
430 })
431 }
432
433 pub async fn get_markets(&self) -> Result<super::models::MarketsResponse, DydxHttpError> {
439 self.send_request(Method::GET, ENDPOINT_PERPETUAL_MARKETS, None)
440 .await
441 }
442
443 pub async fn get_market(
451 &self,
452 ticker: &str,
453 ) -> Result<super::models::MarketsResponse, DydxHttpError> {
454 let query = format!("ticker={ticker}");
455 self.send_request(Method::GET, ENDPOINT_PERPETUAL_MARKETS, Some(&query))
456 .await
457 }
458
459 pub async fn get_orderbook(
465 &self,
466 ticker: &str,
467 ) -> Result<super::models::OrderbookResponse, DydxHttpError> {
468 let endpoint = format!("/v4/orderbooks/perpetualMarket/{ticker}");
469 self.send_request(Method::GET, &endpoint, None).await
470 }
471
472 pub async fn get_trades(
478 &self,
479 ticker: &str,
480 limit: Option<u32>,
481 starting_before_or_at_height: Option<u64>,
482 ) -> Result<super::models::TradesResponse, DydxHttpError> {
483 let endpoint = format!("/v4/trades/perpetualMarket/{ticker}");
484 let mut query_parts = Vec::new();
485
486 if let Some(l) = limit {
487 query_parts.push(format!("limit={l}"));
488 }
489
490 if let Some(height) = starting_before_or_at_height {
491 query_parts.push(format!("createdBeforeOrAtHeight={height}"));
492 }
493 let query = if query_parts.is_empty() {
494 None
495 } else {
496 Some(query_parts.join("&"))
497 };
498 self.send_request(Method::GET, &endpoint, query.as_deref())
499 .await
500 }
501
502 pub async fn get_candles(
508 &self,
509 ticker: &str,
510 resolution: DydxCandleResolution,
511 limit: Option<u32>,
512 from_iso: Option<Timestamp>,
513 to_iso: Option<Timestamp>,
514 ) -> Result<super::models::CandlesResponse, DydxHttpError> {
515 let endpoint = format!("/v4/candles/perpetualMarkets/{ticker}");
516 let mut query_parts = vec![format!("resolution={resolution}")];
517
518 if let Some(l) = limit {
519 query_parts.push(format!("limit={l}"));
520 }
521
522 if let Some(from) = from_iso {
523 let from_str = from.display_with_offset(Offset::UTC).to_string();
524 query_parts.push(format!("fromISO={}", urlencoding::encode(&from_str)));
525 }
526
527 if let Some(to) = to_iso {
528 let to_str = to.display_with_offset(Offset::UTC).to_string();
529 query_parts.push(format!("toISO={}", urlencoding::encode(&to_str)));
530 }
531 let query = query_parts.join("&");
532 self.send_request(Method::GET, &endpoint, Some(&query))
533 .await
534 }
535
536 pub async fn get_subaccount(
542 &self,
543 address: &str,
544 subaccount_number: u32,
545 ) -> Result<super::models::SubaccountResponse, DydxHttpError> {
546 let endpoint = format!("/v4/addresses/{address}/subaccountNumber/{subaccount_number}");
547 self.send_request(Method::GET, &endpoint, None).await
548 }
549
550 pub async fn get_fills(
556 &self,
557 address: &str,
558 subaccount_number: u32,
559 market: Option<&str>,
560 limit: Option<u32>,
561 ) -> Result<super::models::FillsResponse, DydxHttpError> {
562 let endpoint = "/v4/fills";
563 let mut query_parts = vec![
564 format!("address={address}"),
565 format!("subaccountNumber={subaccount_number}"),
566 ];
567
568 if let Some(m) = market {
569 query_parts.push(format!("market={m}"));
570 query_parts.push(QUERY_MARKET_TYPE_PERPETUAL.to_string());
571 }
572
573 if let Some(l) = limit {
574 query_parts.push(format!("limit={l}"));
575 }
576 let query = query_parts.join("&");
577 self.send_request(Method::GET, endpoint, Some(&query)).await
578 }
579
580 pub async fn get_orders(
586 &self,
587 address: &str,
588 subaccount_number: u32,
589 market: Option<&str>,
590 limit: Option<u32>,
591 ) -> Result<super::models::OrdersResponse, DydxHttpError> {
592 let endpoint = "/v4/orders";
593 let mut query_parts = vec![
594 format!("address={address}"),
595 format!("subaccountNumber={subaccount_number}"),
596 ];
597
598 if let Some(m) = market {
599 query_parts.push(format!("market={m}"));
600 query_parts.push(QUERY_MARKET_TYPE_PERPETUAL.to_string());
601 }
602
603 if let Some(l) = limit {
604 query_parts.push(format!("limit={l}"));
605 }
606 let query = query_parts.join("&");
607 self.send_request(Method::GET, endpoint, Some(&query)).await
608 }
609
610 pub async fn get_transfers(
616 &self,
617 address: &str,
618 subaccount_number: u32,
619 limit: Option<u32>,
620 ) -> Result<super::models::TransfersResponse, DydxHttpError> {
621 let endpoint = "/v4/transfers";
622 let mut query_parts = vec![
623 format!("address={address}"),
624 format!("subaccountNumber={subaccount_number}"),
625 ];
626
627 if let Some(l) = limit {
628 query_parts.push(format!("limit={l}"));
629 }
630 let query = query_parts.join("&");
631 self.send_request(Method::GET, endpoint, Some(&query)).await
632 }
633
634 pub async fn get_historical_funding(
640 &self,
641 ticker: &str,
642 limit: Option<u32>,
643 effective_before_or_at_height: Option<u64>,
644 effective_before_or_at: Option<Timestamp>,
645 ) -> Result<super::models::HistoricalFundingResponse, DydxHttpError> {
646 let endpoint = format!("/v4/historicalFunding/{ticker}");
647 let mut query_parts = Vec::new();
648
649 if let Some(l) = limit {
650 query_parts.push(format!("limit={l}"));
651 }
652
653 if let Some(height) = effective_before_or_at_height {
654 query_parts.push(format!("effectiveBeforeOrAtHeight={height}"));
655 }
656
657 if let Some(before) = effective_before_or_at {
658 let before_str = before.display_with_offset(Offset::UTC).to_string();
659 query_parts.push(format!(
660 "effectiveBeforeOrAt={}",
661 urlencoding::encode(&before_str)
662 ));
663 }
664
665 let query = if query_parts.is_empty() {
666 None
667 } else {
668 Some(query_parts.join("&"))
669 };
670 self.send_request(Method::GET, &endpoint, query.as_deref())
671 .await
672 }
673
674 pub async fn get_time(&self) -> Result<super::models::TimeResponse, DydxHttpError> {
680 self.send_request(Method::GET, "/v4/time", None).await
681 }
682
683 pub async fn get_height(&self) -> Result<super::models::HeightResponse, DydxHttpError> {
689 self.send_request(Method::GET, "/v4/height", None).await
690 }
691}
692
693#[derive(Debug)]
709#[cfg_attr(
710 feature = "python",
711 pyo3::pyclass(module = "nautilus_trader.adapters.dydx", from_py_object)
712)]
713#[cfg_attr(
714 feature = "python",
715 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.dydx")
716)]
717pub struct DydxHttpClient {
718 pub(crate) inner: Arc<DydxRawHttpClient>,
720 pub(crate) instrument_cache: Arc<InstrumentCache>,
725 clock: &'static AtomicTime,
726}
727
728impl Clone for DydxHttpClient {
729 fn clone(&self) -> Self {
730 Self {
731 inner: self.inner.clone(),
732 instrument_cache: Arc::clone(&self.instrument_cache),
733 clock: self.clock,
734 }
735 }
736}
737
738impl Default for DydxHttpClient {
739 fn default() -> Self {
740 Self::new(None, 60, None, DydxNetwork::Mainnet, None)
741 .expect("Failed to create default DydxHttpClient")
742 }
743}
744
745fn create_retry_error(error: RetryError) -> DydxHttpError {
746 match error {
747 RetryError::Canceled => {
748 DydxHttpError::Canceled("Adapter disconnecting or shutting down".to_string())
749 }
750 error @ RetryError::OperationTimeout { .. } => {
751 DydxHttpError::HttpClientError(error.to_string())
752 }
753 error => DydxHttpError::ValidationError(error.to_string()),
754 }
755}
756
757impl DydxHttpClient {
758 pub fn new(
771 base_url: Option<String>,
772 timeout_secs: u64,
773 proxy_url: Option<String>,
774 network: DydxNetwork,
775 retry_config: Option<RetryConfig>,
776 ) -> anyhow::Result<Self> {
777 Self::new_with_cache(
778 base_url,
779 timeout_secs,
780 proxy_url,
781 network,
782 retry_config,
783 Arc::new(InstrumentCache::new()),
784 )
785 }
786
787 pub fn new_with_cache(
800 base_url: Option<String>,
801 timeout_secs: u64,
802 proxy_url: Option<String>,
803 network: DydxNetwork,
804 retry_config: Option<RetryConfig>,
805 instrument_cache: Arc<InstrumentCache>,
806 ) -> anyhow::Result<Self> {
807 Ok(Self {
808 inner: Arc::new(DydxRawHttpClient::new(
809 base_url,
810 timeout_secs,
811 proxy_url,
812 network,
813 retry_config,
814 )?),
815 instrument_cache,
816 clock: get_atomic_clock_realtime(),
817 })
818 }
819
820 pub async fn request_instruments(
830 &self,
831 symbol: Option<String>,
832 maker_fee: Option<Decimal>,
833 taker_fee: Option<Decimal>,
834 ) -> anyhow::Result<Vec<InstrumentAny>> {
835 let markets_response = self.inner.get_markets().await?;
836 let ts_init = self.generate_ts_init();
837
838 let mut instruments = Vec::new();
839 let mut skipped_inactive = 0;
840
841 for (ticker, market) in markets_response.markets {
842 if let Some(ref sym) = symbol
844 && ticker != *sym
845 {
846 continue;
847 }
848
849 if !super::parse::is_market_active(&market.status) {
850 log::debug!(
851 "Skipping inactive market {ticker} (status: {:?})",
852 market.status
853 );
854 skipped_inactive += 1;
855 continue;
856 }
857
858 match super::parse::parse_instrument_any(&market, maker_fee, taker_fee, ts_init) {
859 Ok(instrument) => {
860 instruments.push(instrument);
861 }
862 Err(e) => {
863 log::error!("Failed to parse instrument {ticker}: {e}");
864 }
865 }
866 }
867
868 if skipped_inactive > 0 {
869 log::debug!(
870 "Parsed {} instruments, skipped {} inactive",
871 instruments.len(),
872 skipped_inactive
873 );
874 } else {
875 log::debug!("Parsed {} instruments", instruments.len());
876 }
877
878 Ok(instruments)
879 }
880
881 pub async fn fetch_and_cache_instruments(&self) -> anyhow::Result<()> {
893 let markets_response = self.inner.get_markets().await?;
895 let ts_init = self.generate_ts_init();
896
897 let mut parsed_instruments = Vec::new();
898 let mut parsed_markets = Vec::new();
899 let mut skipped_inactive = 0;
900
901 for (ticker, market) in markets_response.markets {
902 if !super::parse::is_market_active(&market.status) {
903 log::debug!(
904 "Skipping inactive market {ticker} (status: {:?})",
905 market.status
906 );
907 skipped_inactive += 1;
908 continue;
909 }
910
911 match super::parse::parse_instrument_any(&market, None, None, ts_init) {
912 Ok(instrument) => {
913 parsed_instruments.push(instrument);
914 parsed_markets.push(market);
915 }
916 Err(e) => {
917 log::error!("Failed to parse instrument {ticker}: {e}");
918 }
919 }
920 }
921
922 self.instrument_cache.clear();
924
925 let items: Vec<_> = parsed_instruments.into_iter().zip(parsed_markets).collect();
927
928 if !items.is_empty() {
929 self.instrument_cache.insert_many(items.clone());
930 }
931
932 let count = items.len();
933
934 if skipped_inactive > 0 {
935 log::debug!("Cached {count} instruments, skipped {skipped_inactive} inactive");
936 } else {
937 log::debug!("Cached {count} instruments");
938 }
939
940 Ok(())
941 }
942
943 pub async fn fetch_and_cache_single_instrument(
949 &self,
950 ticker: &str,
951 ) -> anyhow::Result<Option<InstrumentAny>> {
952 let markets_response = self.inner.get_market(ticker).await?;
953 let ts_init = self.generate_ts_init();
954
955 if let Some(market) = markets_response.markets.get(ticker) {
957 if !super::parse::is_market_active(&market.status) {
958 log::debug!(
959 "Skipping inactive market {ticker} (status: {:?})",
960 market.status
961 );
962 return Ok(None);
963 }
964
965 let instrument = parse_instrument_any(market, None, None, ts_init)?;
966 self.instrument_cache
967 .insert(instrument.clone(), market.clone());
968
969 log::debug!("Fetched and cached new instrument: {ticker}");
970 return Ok(Some(instrument));
971 }
972
973 Ok(None)
974 }
975
976 pub fn cache_instruments(&self, instruments: Vec<InstrumentAny>) {
981 self.instrument_cache.insert_instruments_only(instruments);
982 }
983
984 pub fn cache_instrument(&self, instrument: InstrumentAny) {
989 self.instrument_cache.insert_instrument_only(instrument);
990 }
991
992 #[must_use]
994 pub fn get_instrument(&self, instrument_id: &InstrumentId) -> Option<InstrumentAny> {
995 self.instrument_cache.get(instrument_id)
996 }
997
998 #[must_use]
1002 pub fn get_instrument_by_clob_id(&self, clob_pair_id: u32) -> Option<InstrumentAny> {
1003 self.instrument_cache.get_by_clob_id(clob_pair_id)
1004 }
1005
1006 #[must_use]
1010 pub fn get_instrument_by_market(&self, ticker: &str) -> Option<InstrumentAny> {
1011 self.instrument_cache.get_by_market(ticker)
1012 }
1013
1014 #[must_use]
1023 pub fn get_market_params(
1024 &self,
1025 instrument_id: &InstrumentId,
1026 ) -> Option<super::models::PerpetualMarket> {
1027 self.instrument_cache.get_market_params(instrument_id)
1028 }
1029
1030 pub async fn request_trades(
1039 &self,
1040 symbol: &str,
1041 limit: Option<u32>,
1042 starting_before_or_at_height: Option<u64>,
1043 ) -> anyhow::Result<super::models::TradesResponse> {
1044 self.inner
1045 .get_trades(symbol, limit, starting_before_or_at_height)
1046 .await
1047 .map_err(Into::into)
1048 }
1049
1050 pub async fn request_candles(
1059 &self,
1060 symbol: &str,
1061 resolution: DydxCandleResolution,
1062 limit: Option<u32>,
1063 from_iso: Option<Timestamp>,
1064 to_iso: Option<Timestamp>,
1065 ) -> anyhow::Result<super::models::CandlesResponse> {
1066 self.inner
1067 .get_candles(symbol, resolution, limit, from_iso, to_iso)
1068 .await
1069 .map_err(Into::into)
1070 }
1071
1072 pub async fn request_bars(
1090 &self,
1091 bar_type: BarType,
1092 start: Option<Timestamp>,
1093 end: Option<Timestamp>,
1094 limit: Option<u32>,
1095 timestamp_on_close: bool,
1096 ) -> anyhow::Result<Vec<Bar>> {
1097 let resolution = bar_type_to_resolution(&bar_type)?;
1098 let instrument_id = bar_type.instrument_id();
1099
1100 let instrument = self
1101 .get_instrument(&instrument_id)
1102 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1103
1104 let ticker = extract_raw_symbol(instrument_id.symbol.as_str());
1105 let price_precision = instrument.price_precision();
1106 let size_precision = instrument.size_precision();
1107 let ts_init = self.generate_ts_init();
1108
1109 let mut all_bars: Vec<Bar> = Vec::new();
1110
1111 let spec = bar_type.spec();
1113 let bar_secs: i64 = match spec.aggregation {
1114 BarAggregation::Minute => spec.step.get() as i64 * 60,
1115 BarAggregation::Hour => spec.step.get() as i64 * 3_600,
1116 BarAggregation::Day => spec.step.get() as i64 * 86_400,
1117 _ => anyhow::bail!("Unsupported aggregation: {:?}", spec.aggregation),
1118 };
1119
1120 match (start, end) {
1121 (Some(range_start), Some(range_end)) if range_end > range_start => {
1123 let overall_limit = limit.unwrap_or(u32::MAX);
1124 let mut remaining = overall_limit;
1125 let bars_per_call = DYDX_MAX_BARS_PER_REQUEST.min(remaining);
1126 let chunk_duration =
1127 jiff::SignedDuration::from_secs(bar_secs * bars_per_call as i64);
1128 let mut chunk_start = range_start;
1129
1130 while chunk_start < range_end && remaining > 0 {
1131 let chunk_end = (chunk_start + chunk_duration).min(range_end);
1132 let per_call_limit = remaining.min(DYDX_MAX_BARS_PER_REQUEST);
1133
1134 let response = self
1135 .inner
1136 .get_candles(
1137 ticker,
1138 resolution,
1139 Some(per_call_limit),
1140 Some(chunk_start),
1141 Some(chunk_end),
1142 )
1143 .await?;
1144
1145 let count = response.candles.len() as u32;
1146 if count == 0 {
1147 break;
1148 }
1149
1150 for candle in &response.candles {
1151 match super::parse::parse_bar(
1152 candle,
1153 bar_type,
1154 price_precision,
1155 size_precision,
1156 timestamp_on_close,
1157 ts_init,
1158 ) {
1159 Ok(bar) => all_bars.push(bar),
1160 Err(e) => log::warn!("Failed to parse candle for {instrument_id}: {e}"),
1161 }
1162 }
1163
1164 if remaining <= count {
1165 break;
1166 }
1167 remaining -= count;
1168 chunk_start += chunk_duration;
1169 }
1170 }
1171 _ => {
1173 let req_limit = limit.unwrap_or(DYDX_MAX_BARS_PER_REQUEST);
1174 let response = self
1175 .inner
1176 .get_candles(ticker, resolution, Some(req_limit), None, None)
1177 .await?;
1178
1179 for candle in &response.candles {
1180 match super::parse::parse_bar(
1181 candle,
1182 bar_type,
1183 price_precision,
1184 size_precision,
1185 timestamp_on_close,
1186 ts_init,
1187 ) {
1188 Ok(bar) => all_bars.push(bar),
1189 Err(e) => log::warn!("Failed to parse candle for {instrument_id}: {e}"),
1190 }
1191 }
1192 }
1193 }
1194
1195 let current_time_ns = self.generate_ts_init();
1197 all_bars.retain(|bar| bar.ts_event < current_time_ns);
1198
1199 Ok(all_bars)
1200 }
1201
1202 pub async fn request_trade_ticks(
1220 &self,
1221 instrument_id: InstrumentId,
1222 start: Option<Timestamp>,
1223 end: Option<Timestamp>,
1224 limit: Option<u32>,
1225 ) -> anyhow::Result<Vec<TradeTick>> {
1226 const DYDX_MAX_TRADES_PER_REQUEST: u32 = 1_000;
1227
1228 if let (Some(s), Some(e)) = (start, end) {
1230 anyhow::ensure!(s < e, "start ({s}) must be before end ({e})");
1231 }
1232
1233 let instrument = self
1234 .get_instrument(&instrument_id)
1235 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1236
1237 let ticker = extract_raw_symbol(instrument_id.symbol.as_str());
1238 let price_precision = instrument.price_precision();
1239 let size_precision = instrument.size_precision();
1240 let ts_init = self.generate_ts_init();
1241
1242 let overall_limit = limit.unwrap_or(u32::MAX);
1250 let mut remaining = overall_limit;
1251 let mut cursor_height: Option<u64> = None;
1252 let mut all_trades = Vec::new();
1253 let mut seen_trade_ids: ahash::AHashSet<String> = ahash::AHashSet::new();
1256
1257 loop {
1258 let page_limit = remaining.min(DYDX_MAX_TRADES_PER_REQUEST);
1259 let response = self
1260 .inner
1261 .get_trades(ticker, Some(page_limit), cursor_height)
1262 .await?;
1263
1264 let page_count = response.trades.len() as u32;
1265 if page_count == 0 {
1266 break;
1267 }
1268
1269 let oldest_trade = response.trades.last().unwrap();
1271 let oldest_height = oldest_trade.created_at_height;
1272 let oldest_created_at = oldest_trade.created_at;
1273
1274 let mut new_trades_this_page: usize = 0;
1276 let mut page_before_start = false;
1277
1278 for trade in &response.trades {
1279 if !seen_trade_ids.insert(trade.id.clone()) {
1280 continue;
1282 }
1283
1284 if start.is_some_and(|s| trade.created_at < s) {
1285 page_before_start = true;
1286 continue;
1287 }
1288
1289 if end.is_some_and(|e| trade.created_at > e) {
1290 continue;
1291 }
1292
1293 all_trades.push(super::parse::parse_trade_tick(
1294 trade,
1295 instrument_id,
1296 price_precision,
1297 size_precision,
1298 ts_init,
1299 )?);
1300 new_trades_this_page += 1;
1301 }
1302
1303 if let Some(s) = start
1305 && oldest_created_at < s
1306 {
1307 let _ = page_before_start;
1308 break;
1309 }
1310
1311 let next_cursor = Some(oldest_height.saturating_sub(1));
1319
1320 if oldest_height == 0 && new_trades_this_page == 0 {
1323 break;
1324 }
1325 cursor_height = next_cursor;
1326
1327 remaining = remaining.saturating_sub(new_trades_this_page as u32);
1328
1329 if page_count < page_limit || remaining == 0 {
1331 break;
1332 }
1333 }
1334
1335 all_trades.reverse();
1337
1338 if let Some(lim) = limit {
1340 all_trades.truncate(lim as usize);
1341 }
1342
1343 Ok(all_trades)
1344 }
1345
1346 pub async fn request_funding_rates(
1358 &self,
1359 instrument_id: InstrumentId,
1360 start: Option<Timestamp>,
1361 end: Option<Timestamp>,
1362 limit: Option<u32>,
1363 ) -> anyhow::Result<Vec<FundingRateUpdate>> {
1364 let ticker = extract_raw_symbol(instrument_id.symbol.as_str());
1365 let ts_init = self.generate_ts_init();
1366
1367 let response = self
1368 .inner
1369 .get_historical_funding(ticker, limit, None, end)
1370 .await?;
1371
1372 let mut rates = Vec::with_capacity(response.historical_funding.len());
1373
1374 for entry in &response.historical_funding {
1375 if start.is_some_and(|s| entry.effective_at < s) {
1377 continue;
1378 }
1379
1380 let ts_event =
1381 UnixNanos::from(u64::try_from(entry.effective_at.as_nanosecond()).map_err(
1382 |_| anyhow::anyhow!("Timestamp overflow for {}", entry.effective_at),
1383 )?);
1384
1385 rates.push(FundingRateUpdate::new(
1386 instrument_id,
1387 entry.rate,
1388 Some(60),
1389 None,
1390 ts_event,
1391 ts_init,
1392 ));
1393 }
1394
1395 rates.reverse();
1397
1398 log::debug!("Fetched {} funding rates for {instrument_id}", rates.len(),);
1399
1400 Ok(rates)
1401 }
1402
1403 pub async fn request_orderbook_snapshot(
1414 &self,
1415 instrument_id: InstrumentId,
1416 ) -> anyhow::Result<OrderBookDeltas> {
1417 let instrument = self
1418 .get_instrument(&instrument_id)
1419 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1420
1421 let ticker = extract_raw_symbol(instrument_id.symbol.as_str());
1422 let response = self.inner.get_orderbook(ticker).await?;
1423
1424 let ts_init = self.generate_ts_init();
1425 let snapshot_flag = RecordFlag::F_SNAPSHOT as u8;
1426
1427 let mut deltas = Vec::with_capacity(1 + response.bids.len() + response.asks.len());
1428
1429 if response.bids.is_empty() && response.asks.is_empty() {
1431 let mut clear_delta = OrderBookDelta::clear(instrument_id, 0, ts_init, ts_init);
1432 clear_delta.flags = snapshot_flag | RecordFlag::F_LAST as u8;
1433 deltas.push(clear_delta);
1434 return Ok(OrderBookDeltas::new(instrument_id, deltas));
1435 }
1436
1437 let mut clear_delta = OrderBookDelta::clear(instrument_id, 0, ts_init, ts_init);
1438 clear_delta.flags = snapshot_flag;
1439 deltas.push(clear_delta);
1440
1441 for (i, level) in response.bids.iter().enumerate() {
1442 let is_last = i == response.bids.len() - 1 && response.asks.is_empty();
1443 let flags = if is_last {
1444 snapshot_flag | RecordFlag::F_LAST as u8
1445 } else {
1446 snapshot_flag
1447 };
1448
1449 let order = BookOrder::new(
1450 NautilusOrderSide::Buy,
1451 Price::from_decimal_dp(level.price, instrument.price_precision())?,
1452 Quantity::from_decimal_dp(level.size, instrument.size_precision())?,
1453 0,
1454 );
1455
1456 deltas.push(OrderBookDelta::new(
1457 instrument_id,
1458 BookAction::Add,
1459 order,
1460 flags,
1461 0,
1462 ts_init,
1463 ts_init,
1464 ));
1465 }
1466
1467 for (i, level) in response.asks.iter().enumerate() {
1468 let is_last = i == response.asks.len() - 1;
1469 let flags = if is_last {
1470 snapshot_flag | RecordFlag::F_LAST as u8
1471 } else {
1472 snapshot_flag
1473 };
1474
1475 let order = BookOrder::new(
1476 NautilusOrderSide::Sell,
1477 Price::from_decimal_dp(level.price, instrument.price_precision())?,
1478 Quantity::from_decimal_dp(level.size, instrument.size_precision())?,
1479 0,
1480 );
1481
1482 deltas.push(OrderBookDelta::new(
1483 instrument_id,
1484 BookAction::Add,
1485 order,
1486 flags,
1487 0,
1488 ts_init,
1489 ts_init,
1490 ));
1491 }
1492
1493 Ok(OrderBookDeltas::new(instrument_id, deltas))
1494 }
1495
1496 #[must_use]
1502 pub fn raw_client(&self) -> &Arc<DydxRawHttpClient> {
1503 &self.inner
1504 }
1505
1506 #[must_use]
1508 pub fn is_testnet(&self) -> bool {
1509 self.inner.is_testnet()
1510 }
1511
1512 #[must_use]
1514 pub fn base_url(&self) -> &str {
1515 self.inner.base_url()
1516 }
1517
1518 #[must_use]
1520 pub fn is_cache_initialized(&self) -> bool {
1521 self.instrument_cache.is_initialized()
1522 }
1523
1524 #[must_use]
1526 pub fn cached_instruments_count(&self) -> usize {
1527 self.instrument_cache.len()
1528 }
1529
1530 #[must_use]
1534 pub fn instrument_cache(&self) -> &Arc<InstrumentCache> {
1535 &self.instrument_cache
1536 }
1537
1538 #[must_use]
1542 pub fn all_instruments(&self) -> Vec<InstrumentAny> {
1543 self.instrument_cache.all_instruments()
1544 }
1545
1546 #[must_use]
1548 pub fn all_instrument_ids(&self) -> Vec<InstrumentId> {
1549 self.instrument_cache.all_instrument_ids()
1550 }
1551
1552 fn generate_ts_init(&self) -> UnixNanos {
1553 self.clock.get_time_ns()
1554 }
1555
1556 pub async fn request_order_status_reports(
1565 &self,
1566 address: &str,
1567 subaccount_number: u32,
1568 account_id: AccountId,
1569 instrument_id: Option<InstrumentId>,
1570 ) -> anyhow::Result<Vec<OrderStatusReport>> {
1571 let ts_init = self.generate_ts_init();
1572
1573 let market = instrument_id.map(|id| {
1575 let symbol = id.symbol.to_string();
1576 symbol.trim_end_matches("-PERP").to_string()
1578 });
1579
1580 let orders = self
1581 .inner
1582 .get_orders(
1583 address,
1584 subaccount_number,
1585 market.as_deref(),
1586 Some(DYDX_INDEXER_REPORT_LIMIT),
1587 )
1588 .await?;
1589
1590 let mut reports = Vec::new();
1591
1592 for order in orders {
1593 let instrument = match self.get_instrument_by_clob_id(order.clob_pair_id) {
1595 Some(inst) => inst,
1596 None => {
1597 log::warn!(
1598 "Skipping order {}: no cached instrument for clob_pair_id {}",
1599 order.id,
1600 order.clob_pair_id
1601 );
1602 continue;
1603 }
1604 };
1605
1606 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
1608 continue;
1609 }
1610
1611 match super::parse::parse_order_status_report(&order, &instrument, account_id, ts_init)
1612 {
1613 Ok(report) => reports.push(report),
1614 Err(e) => {
1615 log::warn!("Failed to parse order {}: {e}", order.id);
1616 }
1617 }
1618 }
1619
1620 Ok(reports)
1621 }
1622
1623 pub async fn request_fill_reports(
1632 &self,
1633 address: &str,
1634 subaccount_number: u32,
1635 account_id: AccountId,
1636 instrument_id: Option<InstrumentId>,
1637 ) -> anyhow::Result<Vec<FillReport>> {
1638 let ts_init = self.generate_ts_init();
1639
1640 let market = instrument_id.map(|id| {
1642 let symbol = id.symbol.to_string();
1643 symbol.trim_end_matches("-PERP").to_string()
1644 });
1645
1646 let fills_response = self
1647 .inner
1648 .get_fills(
1649 address,
1650 subaccount_number,
1651 market.as_deref(),
1652 Some(DYDX_INDEXER_REPORT_LIMIT),
1653 )
1654 .await?;
1655
1656 let mut reports = Vec::new();
1657
1658 for fill in fills_response.fills {
1659 let instrument = match self.get_instrument_by_market(&fill.market) {
1661 Some(inst) => inst,
1662 None => {
1663 log::warn!(
1664 "Skipping fill {}: no cached instrument for market {}",
1665 fill.id,
1666 fill.market
1667 );
1668 continue;
1669 }
1670 };
1671
1672 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
1674 continue;
1675 }
1676
1677 match super::parse::parse_fill_report(&fill, &instrument, account_id, ts_init) {
1678 Ok(report) => reports.push(report),
1679 Err(e) => {
1680 log::warn!("Failed to parse fill {}: {e}", fill.id);
1681 }
1682 }
1683 }
1684
1685 Ok(reports)
1686 }
1687
1688 pub async fn request_position_status_reports(
1697 &self,
1698 address: &str,
1699 subaccount_number: u32,
1700 account_id: AccountId,
1701 instrument_id: Option<InstrumentId>,
1702 ) -> anyhow::Result<Vec<PositionStatusReport>> {
1703 let ts_init = self.generate_ts_init();
1704
1705 let subaccount_response = self
1706 .inner
1707 .get_subaccount(address, subaccount_number)
1708 .await?;
1709
1710 let mut reports = Vec::new();
1711
1712 for (market, position) in subaccount_response.subaccount.open_perpetual_positions {
1713 let instrument = match self.get_instrument_by_market(&market) {
1715 Some(inst) => inst,
1716 None => {
1717 log::warn!("Skipping position: no cached instrument for market {market}");
1718 continue;
1719 }
1720 };
1721
1722 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
1724 continue;
1725 }
1726
1727 match super::parse::parse_position_status_report(
1728 &position,
1729 &instrument,
1730 account_id,
1731 ts_init,
1732 ) {
1733 Ok(report) => reports.push(report),
1734 Err(e) => {
1735 log::warn!("Failed to parse position for {market}: {e}");
1736 }
1737 }
1738 }
1739
1740 Ok(reports)
1741 }
1742
1743 pub async fn request_account_state(
1752 &self,
1753 address: &str,
1754 subaccount_number: u32,
1755 account_id: AccountId,
1756 ) -> anyhow::Result<AccountState> {
1757 let ts_init = self.generate_ts_init();
1758 let subaccount_response = self
1759 .inner
1760 .get_subaccount(address, subaccount_number)
1761 .await?;
1762
1763 let instruments: HashMap<InstrumentId, InstrumentAny> = self
1765 .instrument_cache
1766 .all_instruments()
1767 .into_iter()
1768 .map(|inst| (inst.id(), inst))
1769 .collect();
1770
1771 let oracle_prices = self.instrument_cache.to_oracle_prices_map();
1773
1774 parse_account_state_from_http(
1775 &subaccount_response.subaccount,
1776 account_id,
1777 &instruments,
1778 &oracle_prices,
1779 ts_init,
1780 ts_init,
1781 )
1782 }
1783}
1784
1785#[cfg(test)]
1786mod tests {
1787 use std::sync::{
1788 Arc,
1789 atomic::{AtomicBool, Ordering},
1790 };
1791
1792 use axum::{Router, routing::get};
1793 use nautilus_common::testing::wait_until_async;
1794 use nautilus_model::identifiers::Symbol;
1795 use rstest::rstest;
1796
1797 use super::*;
1798 use crate::{common::consts::DYDX_VENUE, http::error};
1799
1800 #[tokio::test]
1801 async fn test_raw_client_creation() {
1802 let client = DydxRawHttpClient::new(None, 30, None, DydxNetwork::Mainnet, None);
1803 assert!(client.is_ok());
1804
1805 let client = client.unwrap();
1806 assert!(!client.is_testnet());
1807 assert_eq!(client.base_url(), DYDX_HTTP_URL);
1808 }
1809
1810 #[tokio::test]
1811 async fn test_raw_client_testnet() {
1812 let client = DydxRawHttpClient::new(None, 30, None, DydxNetwork::Testnet, None);
1813 assert!(client.is_ok());
1814
1815 let client = client.unwrap();
1816 assert!(client.is_testnet());
1817 assert_eq!(client.base_url(), DYDX_TESTNET_HTTP_URL);
1818 }
1819
1820 #[rstest]
1821 fn test_rest_rate_limiter_shared_per_base_url() {
1822 let shared_a = rest_rate_limiter(DYDX_HTTP_URL);
1823 let shared_b = rest_rate_limiter(DYDX_HTTP_URL);
1824 let isolated = rest_rate_limiter("http://rate-limiter-test.invalid");
1825
1826 assert!(Arc::ptr_eq(&shared_a, &shared_b));
1828 assert!(!Arc::ptr_eq(&shared_a, &isolated));
1830 }
1831
1832 #[tokio::test]
1833 async fn test_domain_client_creation() {
1834 let client = DydxHttpClient::new(None, 30, None, DydxNetwork::Mainnet, None);
1835 assert!(client.is_ok());
1836
1837 let client = client.unwrap();
1838 assert!(!client.is_testnet());
1839 assert_eq!(client.base_url(), DYDX_HTTP_URL);
1840 assert!(!client.is_cache_initialized());
1841 assert_eq!(client.cached_instruments_count(), 0);
1842 }
1843
1844 #[tokio::test]
1845 async fn test_domain_client_testnet() {
1846 let client = DydxHttpClient::new(None, 30, None, DydxNetwork::Testnet, None);
1847 assert!(client.is_ok());
1848
1849 let client = client.unwrap();
1850 assert!(client.is_testnet());
1851 assert_eq!(client.base_url(), DYDX_TESTNET_HTTP_URL);
1852 }
1853
1854 #[tokio::test]
1855 async fn test_domain_client_default() {
1856 let client = DydxHttpClient::default();
1857 assert!(!client.is_testnet());
1858 assert_eq!(client.base_url(), DYDX_HTTP_URL);
1859 assert!(!client.is_cache_initialized());
1860 }
1861
1862 #[tokio::test]
1863 async fn test_domain_client_clone() {
1864 let client = DydxHttpClient::new(None, 30, None, DydxNetwork::Mainnet, None).unwrap();
1865
1866 let cloned = client.clone();
1868 assert!(!cloned.is_cache_initialized());
1869
1870 client.instrument_cache.insert_instruments_only(vec![]);
1871
1872 #[expect(clippy::redundant_clone)]
1874 let cloned_after = client.clone();
1875 assert!(cloned_after.is_cache_initialized());
1876 }
1877
1878 #[rstest]
1879 fn test_domain_client_get_instrument_not_found() {
1880 let client = DydxHttpClient::default();
1881 let instrument_id = InstrumentId::new(Symbol::new("ETH-USD-PERP"), *DYDX_VENUE);
1882 let result = client.get_instrument(&instrument_id);
1883 assert!(result.is_none());
1884 }
1885
1886 #[tokio::test]
1887 async fn test_http_timeout_respects_configuration_and_does_not_block() {
1888 use tokio::net::TcpListener;
1889
1890 let handler_entered = Arc::new(AtomicBool::new(false));
1891 let handler_entered_clone = Arc::clone(&handler_entered);
1892 let router = Router::new()
1893 .route(
1894 "/v4/slow",
1895 get(move || async move {
1896 handler_entered_clone.store(true, Ordering::SeqCst);
1897 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1898 "ok"
1899 }),
1900 )
1901 .route("/health", get(|| async { "ok" }));
1902
1903 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1904 let addr = listener.local_addr().unwrap();
1905
1906 tokio::spawn(async move {
1907 axum::serve(listener, router.into_make_service())
1908 .await
1909 .unwrap();
1910 });
1911
1912 let base_url = format!("http://{addr}");
1913
1914 let ready_url = format!("{base_url}/health");
1918 let probe = HttpClient::builder().build().unwrap();
1919 wait_until_async(
1920 || {
1921 let url = ready_url.clone();
1922 let probe = probe.clone();
1923 async move { probe.get(url, None, None, Some(1), None).await.is_ok() }
1924 },
1925 std::time::Duration::from_secs(5),
1926 )
1927 .await;
1928
1929 let retry_config = RetryConfig {
1932 max_retries: 0,
1933 initial_delay_ms: 1,
1934 max_delay_ms: 1,
1935 backoff_factor: 1.0,
1936 jitter_ms: 0,
1937 operation_timeout_ms: Some(500),
1938 immediate_first: true,
1939 max_elapsed_ms: Some(1_000),
1940 };
1941
1942 let client = DydxRawHttpClient::new(
1945 Some(base_url),
1946 60,
1947 None,
1948 DydxNetwork::Mainnet,
1949 Some(retry_config),
1950 )
1951 .unwrap();
1952
1953 let start = std::time::Instant::now();
1954 let result: Result<serde_json::Value, error::DydxHttpError> =
1955 client.send_request(Method::GET, "/v4/slow", None).await;
1956 let elapsed = start.elapsed();
1957
1958 let expected = RetryError::OperationTimeout { timeout_ms: 500 }.to_string();
1959 assert!(
1960 matches!(
1961 &result,
1962 Err(error::DydxHttpError::HttpClientError(message)) if message == &expected
1963 ),
1964 "Expected operation timeout, received {result:?}"
1965 );
1966 assert!(
1967 handler_entered.load(Ordering::SeqCst),
1968 "Slow route was never entered"
1969 );
1970 assert!(elapsed < std::time::Duration::from_secs(3));
1971 }
1972}