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 string::urlencoding,
66 time::{AtomicTime, get_atomic_clock_realtime},
67};
68use nautilus_model::{
69 data::{
70 Bar, BarType, BookOrder, FundingRateUpdate, OrderBookDelta, OrderBookDeltas, TradeTick,
71 },
72 enums::{
73 AggregationSource, BarAggregation, BookAction, OrderSide as NautilusOrderSide, PriceType,
74 RecordFlag,
75 },
76 events::AccountState,
77 identifiers::{AccountId, InstrumentId},
78 instruments::{Instrument, InstrumentAny},
79 reports::{FillReport, OrderStatusReport, PositionStatusReport},
80 types::{Price, Quantity},
81};
82use nautilus_network::{
83 http::{HttpClient, Method, create_standard_nautilus_headers},
84 ratelimiter::{RateLimiter, clock::MonotonicClock, quota::Quota},
85 retry::{RetryConfig, RetryError, RetryManager},
86};
87use parking_lot::Mutex;
88use rust_decimal::Decimal;
89use serde::{Deserialize, Serialize, de::DeserializeOwned};
90use tokio_util::sync::CancellationToken;
91use ustr::Ustr;
92
93use super::error::DydxHttpError;
94use crate::{
95 common::{
96 consts::{DYDX_HTTP_URL, DYDX_TESTNET_HTTP_URL},
97 enums::{DydxCandleResolution, DydxNetwork},
98 instrument_cache::InstrumentCache,
99 parse::extract_raw_symbol,
100 },
101 http::parse::{parse_account_state_from_http, parse_instrument_any},
102};
103
104const DYDX_MAX_BARS_PER_REQUEST: u32 = 1_000;
106
107const ENDPOINT_PERPETUAL_MARKETS: &str = "/v4/perpetualMarkets";
109
110const QUERY_MARKET_TYPE_PERPETUAL: &str = "marketType=PERPETUAL";
111const DYDX_INDEXER_REPORT_LIMIT: u32 = 1_000;
112
113fn bar_type_to_resolution(bar_type: &BarType) -> anyhow::Result<DydxCandleResolution> {
114 if bar_type.aggregation_source() != AggregationSource::External {
115 anyhow::bail!(
116 "dYdX only supports EXTERNAL aggregation, was {:?}",
117 bar_type.aggregation_source()
118 );
119 }
120
121 let spec = bar_type.spec();
122 if spec.price_type != PriceType::Last {
123 anyhow::bail!(
124 "dYdX only supports LAST price type, was {:?}",
125 spec.price_type
126 );
127 }
128
129 DydxCandleResolution::from_bar_spec(&spec)
130}
131
132pub static DYDX_REST_QUOTA: LazyLock<Quota> = LazyLock::new(|| {
138 Quota::per_second(NonZeroU32::new(9).expect("non-zero")).expect("valid constant")
139});
140
141type DydxRestRateLimiter = Arc<RateLimiter<Ustr, MonotonicClock>>;
142
143static DYDX_REST_RATE_LIMITERS: LazyLock<Mutex<AHashMap<String, DydxRestRateLimiter>>> =
148 LazyLock::new(|| Mutex::new(AHashMap::new()));
149
150static DYDX_RATE_LIMIT_KEY: LazyLock<Ustr> = LazyLock::new(|| Ustr::from("dydx:rest"));
151
152fn rate_limit_keys() -> Vec<Ustr> {
153 vec![*DYDX_RATE_LIMIT_KEY]
154}
155
156fn rest_rate_limiter(base_url: &str) -> DydxRestRateLimiter {
157 DYDX_REST_RATE_LIMITERS
158 .lock()
159 .entry(base_url.to_string())
160 .or_insert_with(|| Arc::new(RateLimiter::new_with_quota(Some(*DYDX_REST_QUOTA), vec![])))
161 .clone()
162}
163
164#[derive(Debug, Serialize, Deserialize)]
169pub struct DydxResponse<T> {
170 pub data: T,
172}
173
174pub struct DydxRawHttpClient {
184 base_url: String,
185 client: HttpClient,
186 retry_manager: RetryManager<DydxHttpError>,
187 cancellation_token: CancellationToken,
188 network: DydxNetwork,
189}
190
191impl Default for DydxRawHttpClient {
192 fn default() -> Self {
193 Self::new(None, 60, None, DydxNetwork::Mainnet, None)
194 .expect("Failed to create default DydxRawHttpClient")
195 }
196}
197
198impl Debug for DydxRawHttpClient {
199 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200 f.debug_struct(stringify!(DydxRawHttpClient))
201 .field("base_url", &self.base_url)
202 .field("network", &self.network)
203 .finish_non_exhaustive()
204 }
205}
206
207impl DydxRawHttpClient {
208 pub fn cancel_all_requests(&self) {
210 self.cancellation_token.cancel();
211 }
212
213 pub fn cancellation_token(&self) -> &CancellationToken {
215 &self.cancellation_token
216 }
217
218 pub fn new(
227 base_url: Option<String>,
228 timeout_secs: u64,
229 proxy_url: Option<String>,
230 network: DydxNetwork,
231 retry_config: Option<RetryConfig>,
232 ) -> anyhow::Result<Self> {
233 let base_url = match network {
234 DydxNetwork::Testnet => base_url.unwrap_or_else(|| DYDX_TESTNET_HTTP_URL.to_string()),
235 DydxNetwork::Mainnet => base_url.unwrap_or_else(|| DYDX_HTTP_URL.to_string()),
236 };
237
238 let retry_manager = RetryManager::new(retry_config.unwrap_or_default());
239
240 let headers: HashMap<String, String> =
241 create_standard_nautilus_headers().into_iter().collect();
242
243 let client = HttpClient::builder()
244 .headers(headers)
245 .timeout_secs(timeout_secs)
246 .maybe_proxy_url(proxy_url)
247 .rate_limiters(vec![rest_rate_limiter(&base_url)])
248 .build()
249 .map_err(|e| {
250 DydxHttpError::ValidationError(format!("Failed to create HTTP client: {e}"))
251 })?;
252
253 Ok(Self {
254 base_url,
255 client,
256 retry_manager,
257 cancellation_token: CancellationToken::new(),
258 network,
259 })
260 }
261
262 #[must_use]
264 pub const fn is_testnet(&self) -> bool {
265 matches!(self.network, DydxNetwork::Testnet)
266 }
267
268 #[must_use]
270 pub fn base_url(&self) -> &str {
271 &self.base_url
272 }
273
274 pub async fn send_request<T>(
286 &self,
287 method: Method,
288 endpoint: &str,
289 query_params: Option<&str>,
290 ) -> Result<T, DydxHttpError>
291 where
292 T: DeserializeOwned,
293 {
294 let url = if let Some(params) = query_params {
295 format!("{}{endpoint}?{params}", self.base_url)
296 } else {
297 format!("{}{endpoint}", self.base_url)
298 };
299
300 let operation = || async {
301 let request = self
302 .client
303 .request_with_ustr_keys(
304 method.clone(),
305 url.clone(),
306 None,
307 None,
308 None,
309 None,
310 Some(rate_limit_keys()),
311 )
312 .await
313 .map_err(|e| DydxHttpError::HttpClientError(e.to_string()))?;
314
315 if !request.status.is_success() {
316 return Err(DydxHttpError::HttpStatus {
317 status: request.status.as_u16(),
318 message: String::from_utf8_lossy(&request.body).to_string(),
319 });
320 }
321
322 Ok(request)
323 };
324
325 let should_retry = |error: &DydxHttpError| -> bool {
330 match error {
331 DydxHttpError::HttpClientError(_) => true,
332 DydxHttpError::HttpStatus { status, .. } => *status == 429 || *status >= 500,
333 _ => false,
334 }
335 };
336
337 let response = self
338 .retry_manager
339 .invocation(endpoint, operation, should_retry, create_retry_error)
340 .cancellation_token(&self.cancellation_token)
341 .execute()
342 .await?;
343
344 serde_json::from_slice(&response.body).map_err(|e| DydxHttpError::Deserialization {
345 error: e.to_string(),
346 body: String::from_utf8_lossy(&response.body).to_string(),
347 })
348 }
349
350 pub async fn send_post_request<T, B>(
363 &self,
364 endpoint: &str,
365 body: &B,
366 ) -> Result<T, DydxHttpError>
367 where
368 T: DeserializeOwned,
369 B: Serialize,
370 {
371 let url = format!("{}{endpoint}", self.base_url);
372
373 let body_bytes = serde_json::to_vec(body).map_err(|e| DydxHttpError::Serialization {
374 error: e.to_string(),
375 })?;
376
377 let operation = || async {
378 let request = self
379 .client
380 .request_with_ustr_keys(
381 Method::POST,
382 url.clone(),
383 None,
384 None,
385 Some(body_bytes.clone()),
386 None,
387 Some(rate_limit_keys()),
388 )
389 .await
390 .map_err(|e| DydxHttpError::HttpClientError(e.to_string()))?;
391
392 if !request.status.is_success() {
393 return Err(DydxHttpError::HttpStatus {
394 status: request.status.as_u16(),
395 message: String::from_utf8_lossy(&request.body).to_string(),
396 });
397 }
398
399 Ok(request)
400 };
401
402 let should_retry = |error: &DydxHttpError| -> bool {
404 match error {
405 DydxHttpError::HttpClientError(_) => true,
406 DydxHttpError::HttpStatus { status, .. } => *status == 429 || *status >= 500,
407 _ => false,
408 }
409 };
410
411 let response = self
412 .retry_manager
413 .invocation(endpoint, operation, should_retry, create_retry_error)
414 .cancellation_token(&self.cancellation_token)
415 .execute()
416 .await?;
417
418 serde_json::from_slice(&response.body).map_err(|e| DydxHttpError::Deserialization {
419 error: e.to_string(),
420 body: String::from_utf8_lossy(&response.body).to_string(),
421 })
422 }
423
424 pub async fn get_markets(&self) -> Result<super::models::MarketsResponse, DydxHttpError> {
430 self.send_request(Method::GET, ENDPOINT_PERPETUAL_MARKETS, None)
431 .await
432 }
433
434 pub async fn get_market(
442 &self,
443 ticker: &str,
444 ) -> Result<super::models::MarketsResponse, DydxHttpError> {
445 let query = format!("ticker={ticker}");
446 self.send_request(Method::GET, ENDPOINT_PERPETUAL_MARKETS, Some(&query))
447 .await
448 }
449
450 pub async fn get_orderbook(
456 &self,
457 ticker: &str,
458 ) -> Result<super::models::OrderbookResponse, DydxHttpError> {
459 let endpoint = format!("/v4/orderbooks/perpetualMarket/{ticker}");
460 self.send_request(Method::GET, &endpoint, None).await
461 }
462
463 pub async fn get_trades(
469 &self,
470 ticker: &str,
471 limit: Option<u32>,
472 starting_before_or_at_height: Option<u64>,
473 ) -> Result<super::models::TradesResponse, DydxHttpError> {
474 let endpoint = format!("/v4/trades/perpetualMarket/{ticker}");
475 let mut query_parts = Vec::new();
476
477 if let Some(l) = limit {
478 query_parts.push(format!("limit={l}"));
479 }
480
481 if let Some(height) = starting_before_or_at_height {
482 query_parts.push(format!("createdBeforeOrAtHeight={height}"));
483 }
484 let query = if query_parts.is_empty() {
485 None
486 } else {
487 Some(query_parts.join("&"))
488 };
489 self.send_request(Method::GET, &endpoint, query.as_deref())
490 .await
491 }
492
493 pub async fn get_candles(
499 &self,
500 ticker: &str,
501 resolution: DydxCandleResolution,
502 limit: Option<u32>,
503 from_iso: Option<Timestamp>,
504 to_iso: Option<Timestamp>,
505 ) -> Result<super::models::CandlesResponse, DydxHttpError> {
506 let endpoint = format!("/v4/candles/perpetualMarkets/{ticker}");
507 let mut query_parts = vec![format!("resolution={resolution}")];
508
509 if let Some(l) = limit {
510 query_parts.push(format!("limit={l}"));
511 }
512
513 if let Some(from) = from_iso {
514 let from_str = from.display_with_offset(Offset::UTC).to_string();
515 query_parts.push(format!("fromISO={}", urlencoding::encode(&from_str)));
516 }
517
518 if let Some(to) = to_iso {
519 let to_str = to.display_with_offset(Offset::UTC).to_string();
520 query_parts.push(format!("toISO={}", urlencoding::encode(&to_str)));
521 }
522 let query = query_parts.join("&");
523 self.send_request(Method::GET, &endpoint, Some(&query))
524 .await
525 }
526
527 pub async fn get_subaccount(
533 &self,
534 address: &str,
535 subaccount_number: u32,
536 ) -> Result<super::models::SubaccountResponse, DydxHttpError> {
537 let endpoint = format!("/v4/addresses/{address}/subaccountNumber/{subaccount_number}");
538 self.send_request(Method::GET, &endpoint, None).await
539 }
540
541 pub async fn get_fills(
547 &self,
548 address: &str,
549 subaccount_number: u32,
550 market: Option<&str>,
551 limit: Option<u32>,
552 ) -> Result<super::models::FillsResponse, DydxHttpError> {
553 let endpoint = "/v4/fills";
554 let mut query_parts = vec![
555 format!("address={address}"),
556 format!("subaccountNumber={subaccount_number}"),
557 ];
558
559 if let Some(m) = market {
560 query_parts.push(format!("market={m}"));
561 query_parts.push(QUERY_MARKET_TYPE_PERPETUAL.to_string());
562 }
563
564 if let Some(l) = limit {
565 query_parts.push(format!("limit={l}"));
566 }
567 let query = query_parts.join("&");
568 self.send_request(Method::GET, endpoint, Some(&query)).await
569 }
570
571 pub async fn get_orders(
577 &self,
578 address: &str,
579 subaccount_number: u32,
580 market: Option<&str>,
581 limit: Option<u32>,
582 ) -> Result<super::models::OrdersResponse, DydxHttpError> {
583 let endpoint = "/v4/orders";
584 let mut query_parts = vec![
585 format!("address={address}"),
586 format!("subaccountNumber={subaccount_number}"),
587 ];
588
589 if let Some(m) = market {
590 query_parts.push(format!("market={m}"));
591 query_parts.push(QUERY_MARKET_TYPE_PERPETUAL.to_string());
592 }
593
594 if let Some(l) = limit {
595 query_parts.push(format!("limit={l}"));
596 }
597 let query = query_parts.join("&");
598 self.send_request(Method::GET, endpoint, Some(&query)).await
599 }
600
601 pub async fn get_transfers(
607 &self,
608 address: &str,
609 subaccount_number: u32,
610 limit: Option<u32>,
611 ) -> Result<super::models::TransfersResponse, DydxHttpError> {
612 let endpoint = "/v4/transfers";
613 let mut query_parts = vec![
614 format!("address={address}"),
615 format!("subaccountNumber={subaccount_number}"),
616 ];
617
618 if let Some(l) = limit {
619 query_parts.push(format!("limit={l}"));
620 }
621 let query = query_parts.join("&");
622 self.send_request(Method::GET, endpoint, Some(&query)).await
623 }
624
625 pub async fn get_historical_funding(
631 &self,
632 ticker: &str,
633 limit: Option<u32>,
634 effective_before_or_at_height: Option<u64>,
635 effective_before_or_at: Option<Timestamp>,
636 ) -> Result<super::models::HistoricalFundingResponse, DydxHttpError> {
637 let endpoint = format!("/v4/historicalFunding/{ticker}");
638 let mut query_parts = Vec::new();
639
640 if let Some(l) = limit {
641 query_parts.push(format!("limit={l}"));
642 }
643
644 if let Some(height) = effective_before_or_at_height {
645 query_parts.push(format!("effectiveBeforeOrAtHeight={height}"));
646 }
647
648 if let Some(before) = effective_before_or_at {
649 let before_str = before.display_with_offset(Offset::UTC).to_string();
650 query_parts.push(format!(
651 "effectiveBeforeOrAt={}",
652 urlencoding::encode(&before_str)
653 ));
654 }
655
656 let query = if query_parts.is_empty() {
657 None
658 } else {
659 Some(query_parts.join("&"))
660 };
661 self.send_request(Method::GET, &endpoint, query.as_deref())
662 .await
663 }
664
665 pub async fn get_time(&self) -> Result<super::models::TimeResponse, DydxHttpError> {
671 self.send_request(Method::GET, "/v4/time", None).await
672 }
673
674 pub async fn get_height(&self) -> Result<super::models::HeightResponse, DydxHttpError> {
680 self.send_request(Method::GET, "/v4/height", None).await
681 }
682}
683
684#[derive(Debug)]
700#[cfg_attr(
701 feature = "python",
702 pyo3::pyclass(module = "nautilus_trader.adapters.dydx", from_py_object)
703)]
704#[cfg_attr(
705 feature = "python",
706 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.dydx")
707)]
708pub struct DydxHttpClient {
709 pub(crate) inner: Arc<DydxRawHttpClient>,
711 pub(crate) instrument_cache: Arc<InstrumentCache>,
716 clock: &'static AtomicTime,
717}
718
719impl Clone for DydxHttpClient {
720 fn clone(&self) -> Self {
721 Self {
722 inner: self.inner.clone(),
723 instrument_cache: Arc::clone(&self.instrument_cache),
724 clock: self.clock,
725 }
726 }
727}
728
729impl Default for DydxHttpClient {
730 fn default() -> Self {
731 Self::new(None, 60, None, DydxNetwork::Mainnet, None)
732 .expect("Failed to create default DydxHttpClient")
733 }
734}
735
736fn create_retry_error(error: RetryError) -> DydxHttpError {
737 match error {
738 RetryError::Canceled => {
739 DydxHttpError::Canceled("Adapter disconnecting or shutting down".to_string())
740 }
741 error @ RetryError::OperationTimeout { .. } => {
742 DydxHttpError::HttpClientError(error.to_string())
743 }
744 error => DydxHttpError::ValidationError(error.to_string()),
745 }
746}
747
748impl DydxHttpClient {
749 pub fn new(
762 base_url: Option<String>,
763 timeout_secs: u64,
764 proxy_url: Option<String>,
765 network: DydxNetwork,
766 retry_config: Option<RetryConfig>,
767 ) -> anyhow::Result<Self> {
768 Self::new_with_cache(
769 base_url,
770 timeout_secs,
771 proxy_url,
772 network,
773 retry_config,
774 Arc::new(InstrumentCache::new()),
775 )
776 }
777
778 pub fn new_with_cache(
791 base_url: Option<String>,
792 timeout_secs: u64,
793 proxy_url: Option<String>,
794 network: DydxNetwork,
795 retry_config: Option<RetryConfig>,
796 instrument_cache: Arc<InstrumentCache>,
797 ) -> anyhow::Result<Self> {
798 Ok(Self {
799 inner: Arc::new(DydxRawHttpClient::new(
800 base_url,
801 timeout_secs,
802 proxy_url,
803 network,
804 retry_config,
805 )?),
806 instrument_cache,
807 clock: get_atomic_clock_realtime(),
808 })
809 }
810
811 pub async fn request_instruments(
821 &self,
822 symbol: Option<String>,
823 maker_fee: Option<Decimal>,
824 taker_fee: Option<Decimal>,
825 ) -> anyhow::Result<Vec<InstrumentAny>> {
826 let markets_response = self.inner.get_markets().await?;
827 let ts_init = self.generate_ts_init();
828
829 let mut instruments = Vec::new();
830 let mut skipped_inactive = 0;
831
832 for (ticker, market) in markets_response.markets {
833 if let Some(ref sym) = symbol
835 && ticker != *sym
836 {
837 continue;
838 }
839
840 if !super::parse::is_market_active(&market.status) {
841 log::debug!(
842 "Skipping inactive market {ticker} (status: {:?})",
843 market.status
844 );
845 skipped_inactive += 1;
846 continue;
847 }
848
849 match super::parse::parse_instrument_any(&market, maker_fee, taker_fee, ts_init) {
850 Ok(instrument) => {
851 instruments.push(instrument);
852 }
853 Err(e) => {
854 log::error!("Failed to parse instrument {ticker}: {e}");
855 }
856 }
857 }
858
859 if skipped_inactive > 0 {
860 log::debug!(
861 "Parsed {} instruments, skipped {} inactive",
862 instruments.len(),
863 skipped_inactive
864 );
865 } else {
866 log::debug!("Parsed {} instruments", instruments.len());
867 }
868
869 Ok(instruments)
870 }
871
872 pub async fn fetch_and_cache_instruments(&self) -> anyhow::Result<()> {
884 let markets_response = self.inner.get_markets().await?;
886 let ts_init = self.generate_ts_init();
887
888 let mut parsed_instruments = Vec::new();
889 let mut parsed_markets = Vec::new();
890 let mut skipped_inactive = 0;
891
892 for (ticker, market) in markets_response.markets {
893 if !super::parse::is_market_active(&market.status) {
894 log::debug!(
895 "Skipping inactive market {ticker} (status: {:?})",
896 market.status
897 );
898 skipped_inactive += 1;
899 continue;
900 }
901
902 match super::parse::parse_instrument_any(&market, None, None, ts_init) {
903 Ok(instrument) => {
904 parsed_instruments.push(instrument);
905 parsed_markets.push(market);
906 }
907 Err(e) => {
908 log::error!("Failed to parse instrument {ticker}: {e}");
909 }
910 }
911 }
912
913 self.instrument_cache.clear();
915
916 let items: Vec<_> = parsed_instruments.into_iter().zip(parsed_markets).collect();
918
919 if !items.is_empty() {
920 self.instrument_cache.insert_many(items.clone());
921 }
922
923 let count = items.len();
924
925 if skipped_inactive > 0 {
926 log::debug!("Cached {count} instruments, skipped {skipped_inactive} inactive");
927 } else {
928 log::debug!("Cached {count} instruments");
929 }
930
931 Ok(())
932 }
933
934 pub async fn fetch_and_cache_single_instrument(
940 &self,
941 ticker: &str,
942 ) -> anyhow::Result<Option<InstrumentAny>> {
943 let markets_response = self.inner.get_market(ticker).await?;
944 let ts_init = self.generate_ts_init();
945
946 if let Some(market) = markets_response.markets.get(ticker) {
948 if !super::parse::is_market_active(&market.status) {
949 log::debug!(
950 "Skipping inactive market {ticker} (status: {:?})",
951 market.status
952 );
953 return Ok(None);
954 }
955
956 let instrument = parse_instrument_any(market, None, None, ts_init)?;
957 self.instrument_cache
958 .insert(instrument.clone(), market.clone());
959
960 log::debug!("Fetched and cached new instrument: {ticker}");
961 return Ok(Some(instrument));
962 }
963
964 Ok(None)
965 }
966
967 pub fn cache_instruments(&self, instruments: Vec<InstrumentAny>) {
972 self.instrument_cache.insert_instruments_only(instruments);
973 }
974
975 pub fn cache_instrument(&self, instrument: InstrumentAny) {
980 self.instrument_cache.insert_instrument_only(instrument);
981 }
982
983 #[must_use]
985 pub fn get_instrument(&self, instrument_id: &InstrumentId) -> Option<InstrumentAny> {
986 self.instrument_cache.get(instrument_id)
987 }
988
989 #[must_use]
993 pub fn get_instrument_by_clob_id(&self, clob_pair_id: u32) -> Option<InstrumentAny> {
994 self.instrument_cache.get_by_clob_id(clob_pair_id)
995 }
996
997 #[must_use]
1001 pub fn get_instrument_by_market(&self, ticker: &str) -> Option<InstrumentAny> {
1002 self.instrument_cache.get_by_market(ticker)
1003 }
1004
1005 #[must_use]
1014 pub fn get_market_params(
1015 &self,
1016 instrument_id: &InstrumentId,
1017 ) -> Option<super::models::PerpetualMarket> {
1018 self.instrument_cache.get_market_params(instrument_id)
1019 }
1020
1021 pub async fn request_trades(
1030 &self,
1031 symbol: &str,
1032 limit: Option<u32>,
1033 starting_before_or_at_height: Option<u64>,
1034 ) -> anyhow::Result<super::models::TradesResponse> {
1035 self.inner
1036 .get_trades(symbol, limit, starting_before_or_at_height)
1037 .await
1038 .map_err(Into::into)
1039 }
1040
1041 pub async fn request_candles(
1050 &self,
1051 symbol: &str,
1052 resolution: DydxCandleResolution,
1053 limit: Option<u32>,
1054 from_iso: Option<Timestamp>,
1055 to_iso: Option<Timestamp>,
1056 ) -> anyhow::Result<super::models::CandlesResponse> {
1057 self.inner
1058 .get_candles(symbol, resolution, limit, from_iso, to_iso)
1059 .await
1060 .map_err(Into::into)
1061 }
1062
1063 pub async fn request_bars(
1081 &self,
1082 bar_type: BarType,
1083 start: Option<Timestamp>,
1084 end: Option<Timestamp>,
1085 limit: Option<u32>,
1086 timestamp_on_close: bool,
1087 ) -> anyhow::Result<Vec<Bar>> {
1088 let resolution = bar_type_to_resolution(&bar_type)?;
1089 let instrument_id = bar_type.instrument_id();
1090
1091 let instrument = self
1092 .get_instrument(&instrument_id)
1093 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1094
1095 let ticker = extract_raw_symbol(instrument_id.symbol.as_str());
1096 let price_precision = instrument.price_precision();
1097 let size_precision = instrument.size_precision();
1098 let ts_init = self.generate_ts_init();
1099
1100 let mut all_bars: Vec<Bar> = Vec::new();
1101
1102 let spec = bar_type.spec();
1104 let bar_secs: i64 = match spec.aggregation {
1105 BarAggregation::Minute => spec.step.get() as i64 * 60,
1106 BarAggregation::Hour => spec.step.get() as i64 * 3_600,
1107 BarAggregation::Day => spec.step.get() as i64 * 86_400,
1108 _ => anyhow::bail!("Unsupported aggregation: {:?}", spec.aggregation),
1109 };
1110
1111 match (start, end) {
1112 (Some(range_start), Some(range_end)) if range_end > range_start => {
1114 let overall_limit = limit.unwrap_or(u32::MAX);
1115 let mut remaining = overall_limit;
1116 let bars_per_call = DYDX_MAX_BARS_PER_REQUEST.min(remaining);
1117 let chunk_duration =
1118 jiff::SignedDuration::from_secs(bar_secs * bars_per_call as i64);
1119 let mut chunk_start = range_start;
1120
1121 while chunk_start < range_end && remaining > 0 {
1122 let chunk_end = (chunk_start + chunk_duration).min(range_end);
1123 let per_call_limit = remaining.min(DYDX_MAX_BARS_PER_REQUEST);
1124
1125 let response = self
1126 .inner
1127 .get_candles(
1128 ticker,
1129 resolution,
1130 Some(per_call_limit),
1131 Some(chunk_start),
1132 Some(chunk_end),
1133 )
1134 .await?;
1135
1136 let count = response.candles.len() as u32;
1137 if count == 0 {
1138 break;
1139 }
1140
1141 for candle in &response.candles {
1142 match super::parse::parse_bar(
1143 candle,
1144 bar_type,
1145 price_precision,
1146 size_precision,
1147 timestamp_on_close,
1148 ts_init,
1149 ) {
1150 Ok(bar) => all_bars.push(bar),
1151 Err(e) => log::warn!("Failed to parse candle for {instrument_id}: {e}"),
1152 }
1153 }
1154
1155 if remaining <= count {
1156 break;
1157 }
1158 remaining -= count;
1159 chunk_start += chunk_duration;
1160 }
1161 }
1162 _ => {
1164 let req_limit = limit.unwrap_or(DYDX_MAX_BARS_PER_REQUEST);
1165 let response = self
1166 .inner
1167 .get_candles(ticker, resolution, Some(req_limit), None, None)
1168 .await?;
1169
1170 for candle in &response.candles {
1171 match super::parse::parse_bar(
1172 candle,
1173 bar_type,
1174 price_precision,
1175 size_precision,
1176 timestamp_on_close,
1177 ts_init,
1178 ) {
1179 Ok(bar) => all_bars.push(bar),
1180 Err(e) => log::warn!("Failed to parse candle for {instrument_id}: {e}"),
1181 }
1182 }
1183 }
1184 }
1185
1186 let current_time_ns = self.generate_ts_init();
1188 all_bars.retain(|bar| bar.ts_event < current_time_ns);
1189
1190 Ok(all_bars)
1191 }
1192
1193 pub async fn request_trade_ticks(
1211 &self,
1212 instrument_id: InstrumentId,
1213 start: Option<Timestamp>,
1214 end: Option<Timestamp>,
1215 limit: Option<u32>,
1216 ) -> anyhow::Result<Vec<TradeTick>> {
1217 const DYDX_MAX_TRADES_PER_REQUEST: u32 = 1_000;
1218
1219 if let (Some(s), Some(e)) = (start, end) {
1221 anyhow::ensure!(s < e, "start ({s}) must be before end ({e})");
1222 }
1223
1224 let instrument = self
1225 .get_instrument(&instrument_id)
1226 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1227
1228 let ticker = extract_raw_symbol(instrument_id.symbol.as_str());
1229 let price_precision = instrument.price_precision();
1230 let size_precision = instrument.size_precision();
1231 let ts_init = self.generate_ts_init();
1232
1233 let overall_limit = limit.unwrap_or(u32::MAX);
1241 let mut remaining = overall_limit;
1242 let mut cursor_height: Option<u64> = None;
1243 let mut all_trades = Vec::new();
1244 let mut seen_trade_ids: ahash::AHashSet<String> = ahash::AHashSet::new();
1247
1248 loop {
1249 let page_limit = remaining.min(DYDX_MAX_TRADES_PER_REQUEST);
1250 let response = self
1251 .inner
1252 .get_trades(ticker, Some(page_limit), cursor_height)
1253 .await?;
1254
1255 let page_count = response.trades.len() as u32;
1256 if page_count == 0 {
1257 break;
1258 }
1259
1260 let oldest_trade = response.trades.last().unwrap();
1262 let oldest_height = oldest_trade.created_at_height;
1263 let oldest_created_at = oldest_trade.created_at;
1264
1265 let mut new_trades_this_page: usize = 0;
1267 let mut page_before_start = false;
1268
1269 for trade in &response.trades {
1270 if !seen_trade_ids.insert(trade.id.clone()) {
1271 continue;
1273 }
1274
1275 if start.is_some_and(|s| trade.created_at < s) {
1276 page_before_start = true;
1277 continue;
1278 }
1279
1280 if end.is_some_and(|e| trade.created_at > e) {
1281 continue;
1282 }
1283
1284 all_trades.push(super::parse::parse_trade_tick(
1285 trade,
1286 instrument_id,
1287 price_precision,
1288 size_precision,
1289 ts_init,
1290 )?);
1291 new_trades_this_page += 1;
1292 }
1293
1294 if let Some(s) = start
1296 && oldest_created_at < s
1297 {
1298 let _ = page_before_start;
1299 break;
1300 }
1301
1302 let next_cursor = Some(oldest_height.saturating_sub(1));
1310
1311 if oldest_height == 0 && new_trades_this_page == 0 {
1314 break;
1315 }
1316 cursor_height = next_cursor;
1317
1318 remaining = remaining.saturating_sub(new_trades_this_page as u32);
1319
1320 if page_count < page_limit || remaining == 0 {
1322 break;
1323 }
1324 }
1325
1326 all_trades.reverse();
1328
1329 if let Some(lim) = limit {
1331 all_trades.truncate(lim as usize);
1332 }
1333
1334 Ok(all_trades)
1335 }
1336
1337 pub async fn request_funding_rates(
1349 &self,
1350 instrument_id: InstrumentId,
1351 start: Option<Timestamp>,
1352 end: Option<Timestamp>,
1353 limit: Option<u32>,
1354 ) -> anyhow::Result<Vec<FundingRateUpdate>> {
1355 let ticker = extract_raw_symbol(instrument_id.symbol.as_str());
1356 let ts_init = self.generate_ts_init();
1357
1358 let response = self
1359 .inner
1360 .get_historical_funding(ticker, limit, None, end)
1361 .await?;
1362
1363 let mut rates = Vec::with_capacity(response.historical_funding.len());
1364
1365 for entry in &response.historical_funding {
1366 if start.is_some_and(|s| entry.effective_at < s) {
1368 continue;
1369 }
1370
1371 let ts_event =
1372 UnixNanos::from(u64::try_from(entry.effective_at.as_nanosecond()).map_err(
1373 |_| anyhow::anyhow!("Timestamp overflow for {}", entry.effective_at),
1374 )?);
1375
1376 rates.push(FundingRateUpdate::new(
1377 instrument_id,
1378 entry.rate,
1379 Some(60),
1380 None,
1381 ts_event,
1382 ts_init,
1383 ));
1384 }
1385
1386 rates.reverse();
1388
1389 log::debug!("Fetched {} funding rates for {instrument_id}", rates.len(),);
1390
1391 Ok(rates)
1392 }
1393
1394 pub async fn request_orderbook_snapshot(
1405 &self,
1406 instrument_id: InstrumentId,
1407 ) -> anyhow::Result<OrderBookDeltas> {
1408 let instrument = self
1409 .get_instrument(&instrument_id)
1410 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1411
1412 let ticker = extract_raw_symbol(instrument_id.symbol.as_str());
1413 let response = self.inner.get_orderbook(ticker).await?;
1414
1415 let ts_init = self.generate_ts_init();
1416 let snapshot_flag = RecordFlag::F_SNAPSHOT as u8;
1417
1418 let mut deltas = Vec::with_capacity(1 + response.bids.len() + response.asks.len());
1419
1420 if response.bids.is_empty() && response.asks.is_empty() {
1422 let mut clear_delta = OrderBookDelta::clear(instrument_id, 0, ts_init, ts_init);
1423 clear_delta.flags = snapshot_flag | RecordFlag::F_LAST as u8;
1424 deltas.push(clear_delta);
1425 return Ok(OrderBookDeltas::new(instrument_id, deltas));
1426 }
1427
1428 let mut clear_delta = OrderBookDelta::clear(instrument_id, 0, ts_init, ts_init);
1429 clear_delta.flags = snapshot_flag;
1430 deltas.push(clear_delta);
1431
1432 for (i, level) in response.bids.iter().enumerate() {
1433 let is_last = i == response.bids.len() - 1 && response.asks.is_empty();
1434 let flags = if is_last {
1435 snapshot_flag | RecordFlag::F_LAST as u8
1436 } else {
1437 snapshot_flag
1438 };
1439
1440 let order = BookOrder::new(
1441 NautilusOrderSide::Buy,
1442 Price::from_decimal_dp(level.price, instrument.price_precision())?,
1443 Quantity::from_decimal_dp(level.size, instrument.size_precision())?,
1444 0,
1445 );
1446
1447 deltas.push(OrderBookDelta::new(
1448 instrument_id,
1449 BookAction::Add,
1450 order,
1451 flags,
1452 0,
1453 ts_init,
1454 ts_init,
1455 ));
1456 }
1457
1458 for (i, level) in response.asks.iter().enumerate() {
1459 let is_last = i == response.asks.len() - 1;
1460 let flags = if is_last {
1461 snapshot_flag | RecordFlag::F_LAST as u8
1462 } else {
1463 snapshot_flag
1464 };
1465
1466 let order = BookOrder::new(
1467 NautilusOrderSide::Sell,
1468 Price::from_decimal_dp(level.price, instrument.price_precision())?,
1469 Quantity::from_decimal_dp(level.size, instrument.size_precision())?,
1470 0,
1471 );
1472
1473 deltas.push(OrderBookDelta::new(
1474 instrument_id,
1475 BookAction::Add,
1476 order,
1477 flags,
1478 0,
1479 ts_init,
1480 ts_init,
1481 ));
1482 }
1483
1484 Ok(OrderBookDeltas::new(instrument_id, deltas))
1485 }
1486
1487 #[must_use]
1493 pub fn raw_client(&self) -> &Arc<DydxRawHttpClient> {
1494 &self.inner
1495 }
1496
1497 #[must_use]
1499 pub fn is_testnet(&self) -> bool {
1500 self.inner.is_testnet()
1501 }
1502
1503 #[must_use]
1505 pub fn base_url(&self) -> &str {
1506 self.inner.base_url()
1507 }
1508
1509 #[must_use]
1511 pub fn is_cache_initialized(&self) -> bool {
1512 self.instrument_cache.is_initialized()
1513 }
1514
1515 #[must_use]
1517 pub fn cached_instruments_count(&self) -> usize {
1518 self.instrument_cache.len()
1519 }
1520
1521 #[must_use]
1525 pub fn instrument_cache(&self) -> &Arc<InstrumentCache> {
1526 &self.instrument_cache
1527 }
1528
1529 #[must_use]
1533 pub fn all_instruments(&self) -> Vec<InstrumentAny> {
1534 self.instrument_cache.all_instruments()
1535 }
1536
1537 #[must_use]
1539 pub fn all_instrument_ids(&self) -> Vec<InstrumentId> {
1540 self.instrument_cache.all_instrument_ids()
1541 }
1542
1543 fn generate_ts_init(&self) -> UnixNanos {
1544 self.clock.get_time_ns()
1545 }
1546
1547 pub async fn request_order_status_reports(
1556 &self,
1557 address: &str,
1558 subaccount_number: u32,
1559 account_id: AccountId,
1560 instrument_id: Option<InstrumentId>,
1561 ) -> anyhow::Result<Vec<OrderStatusReport>> {
1562 let ts_init = self.generate_ts_init();
1563
1564 let market = instrument_id.map(|id| {
1566 let symbol = id.symbol.to_string();
1567 symbol.trim_end_matches("-PERP").to_string()
1569 });
1570
1571 let orders = self
1572 .inner
1573 .get_orders(
1574 address,
1575 subaccount_number,
1576 market.as_deref(),
1577 Some(DYDX_INDEXER_REPORT_LIMIT),
1578 )
1579 .await?;
1580
1581 let mut reports = Vec::new();
1582
1583 for order in orders {
1584 let instrument = match self.get_instrument_by_clob_id(order.clob_pair_id) {
1586 Some(inst) => inst,
1587 None => {
1588 log::warn!(
1589 "Skipping order {}: no cached instrument for clob_pair_id {}",
1590 order.id,
1591 order.clob_pair_id
1592 );
1593 continue;
1594 }
1595 };
1596
1597 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
1599 continue;
1600 }
1601
1602 match super::parse::parse_order_status_report(&order, &instrument, account_id, ts_init)
1603 {
1604 Ok(report) => reports.push(report),
1605 Err(e) => {
1606 log::warn!("Failed to parse order {}: {e}", order.id);
1607 }
1608 }
1609 }
1610
1611 Ok(reports)
1612 }
1613
1614 pub async fn request_fill_reports(
1623 &self,
1624 address: &str,
1625 subaccount_number: u32,
1626 account_id: AccountId,
1627 instrument_id: Option<InstrumentId>,
1628 ) -> anyhow::Result<Vec<FillReport>> {
1629 let ts_init = self.generate_ts_init();
1630
1631 let market = instrument_id.map(|id| {
1633 let symbol = id.symbol.to_string();
1634 symbol.trim_end_matches("-PERP").to_string()
1635 });
1636
1637 let fills_response = self
1638 .inner
1639 .get_fills(
1640 address,
1641 subaccount_number,
1642 market.as_deref(),
1643 Some(DYDX_INDEXER_REPORT_LIMIT),
1644 )
1645 .await?;
1646
1647 let mut reports = Vec::new();
1648
1649 for fill in fills_response.fills {
1650 let instrument = match self.get_instrument_by_market(&fill.market) {
1652 Some(inst) => inst,
1653 None => {
1654 log::warn!(
1655 "Skipping fill {}: no cached instrument for market {}",
1656 fill.id,
1657 fill.market
1658 );
1659 continue;
1660 }
1661 };
1662
1663 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
1665 continue;
1666 }
1667
1668 match super::parse::parse_fill_report(&fill, &instrument, account_id, ts_init) {
1669 Ok(report) => reports.push(report),
1670 Err(e) => {
1671 log::warn!("Failed to parse fill {}: {e}", fill.id);
1672 }
1673 }
1674 }
1675
1676 Ok(reports)
1677 }
1678
1679 pub async fn request_position_status_reports(
1688 &self,
1689 address: &str,
1690 subaccount_number: u32,
1691 account_id: AccountId,
1692 instrument_id: Option<InstrumentId>,
1693 ) -> anyhow::Result<Vec<PositionStatusReport>> {
1694 let ts_init = self.generate_ts_init();
1695
1696 let subaccount_response = self
1697 .inner
1698 .get_subaccount(address, subaccount_number)
1699 .await?;
1700
1701 let mut reports = Vec::new();
1702
1703 for (market, position) in subaccount_response.subaccount.open_perpetual_positions {
1704 let instrument = match self.get_instrument_by_market(&market) {
1706 Some(inst) => inst,
1707 None => {
1708 log::warn!("Skipping position: no cached instrument for market {market}");
1709 continue;
1710 }
1711 };
1712
1713 if instrument_id.is_some_and(|filter_id| instrument.id() != filter_id) {
1715 continue;
1716 }
1717
1718 match super::parse::parse_position_status_report(
1719 &position,
1720 &instrument,
1721 account_id,
1722 ts_init,
1723 ) {
1724 Ok(report) => reports.push(report),
1725 Err(e) => {
1726 log::warn!("Failed to parse position for {market}: {e}");
1727 }
1728 }
1729 }
1730
1731 Ok(reports)
1732 }
1733
1734 pub async fn request_account_state(
1743 &self,
1744 address: &str,
1745 subaccount_number: u32,
1746 account_id: AccountId,
1747 ) -> anyhow::Result<AccountState> {
1748 let ts_init = self.generate_ts_init();
1749 let subaccount_response = self
1750 .inner
1751 .get_subaccount(address, subaccount_number)
1752 .await?;
1753
1754 let instruments: HashMap<InstrumentId, InstrumentAny> = self
1756 .instrument_cache
1757 .all_instruments()
1758 .into_iter()
1759 .map(|inst| (inst.id(), inst))
1760 .collect();
1761
1762 let oracle_prices = self.instrument_cache.to_oracle_prices_map();
1764
1765 parse_account_state_from_http(
1766 &subaccount_response.subaccount,
1767 account_id,
1768 &instruments,
1769 &oracle_prices,
1770 ts_init,
1771 ts_init,
1772 )
1773 }
1774}
1775
1776#[cfg(test)]
1777mod tests {
1778 use std::sync::{
1779 Arc,
1780 atomic::{AtomicBool, Ordering},
1781 };
1782
1783 use axum::{Router, routing::get};
1784 use nautilus_common::testing::wait_until_async;
1785 use nautilus_model::identifiers::Symbol;
1786 use rstest::rstest;
1787
1788 use super::*;
1789 use crate::{common::consts::DYDX_VENUE, http::error};
1790
1791 #[tokio::test]
1792 async fn test_raw_client_creation() {
1793 let client = DydxRawHttpClient::new(None, 30, None, DydxNetwork::Mainnet, None);
1794 assert!(client.is_ok());
1795
1796 let client = client.unwrap();
1797 assert!(!client.is_testnet());
1798 assert_eq!(client.base_url(), DYDX_HTTP_URL);
1799 }
1800
1801 #[tokio::test]
1802 async fn test_raw_client_testnet() {
1803 let client = DydxRawHttpClient::new(None, 30, None, DydxNetwork::Testnet, None);
1804 assert!(client.is_ok());
1805
1806 let client = client.unwrap();
1807 assert!(client.is_testnet());
1808 assert_eq!(client.base_url(), DYDX_TESTNET_HTTP_URL);
1809 }
1810
1811 #[rstest]
1812 fn test_rest_rate_limiter_shared_per_base_url() {
1813 let shared_a = rest_rate_limiter(DYDX_HTTP_URL);
1814 let shared_b = rest_rate_limiter(DYDX_HTTP_URL);
1815 let isolated = rest_rate_limiter("http://rate-limiter-test.invalid");
1816
1817 assert!(Arc::ptr_eq(&shared_a, &shared_b));
1819 assert!(!Arc::ptr_eq(&shared_a, &isolated));
1821 }
1822
1823 #[tokio::test]
1824 async fn test_domain_client_creation() {
1825 let client = DydxHttpClient::new(None, 30, None, DydxNetwork::Mainnet, None);
1826 assert!(client.is_ok());
1827
1828 let client = client.unwrap();
1829 assert!(!client.is_testnet());
1830 assert_eq!(client.base_url(), DYDX_HTTP_URL);
1831 assert!(!client.is_cache_initialized());
1832 assert_eq!(client.cached_instruments_count(), 0);
1833 }
1834
1835 #[tokio::test]
1836 async fn test_domain_client_testnet() {
1837 let client = DydxHttpClient::new(None, 30, None, DydxNetwork::Testnet, None);
1838 assert!(client.is_ok());
1839
1840 let client = client.unwrap();
1841 assert!(client.is_testnet());
1842 assert_eq!(client.base_url(), DYDX_TESTNET_HTTP_URL);
1843 }
1844
1845 #[tokio::test]
1846 async fn test_domain_client_default() {
1847 let client = DydxHttpClient::default();
1848 assert!(!client.is_testnet());
1849 assert_eq!(client.base_url(), DYDX_HTTP_URL);
1850 assert!(!client.is_cache_initialized());
1851 }
1852
1853 #[tokio::test]
1854 async fn test_domain_client_clone() {
1855 let client = DydxHttpClient::new(None, 30, None, DydxNetwork::Mainnet, None).unwrap();
1856
1857 let cloned = client.clone();
1859 assert!(!cloned.is_cache_initialized());
1860
1861 client.instrument_cache.insert_instruments_only(vec![]);
1862
1863 #[expect(clippy::redundant_clone)]
1865 let cloned_after = client.clone();
1866 assert!(cloned_after.is_cache_initialized());
1867 }
1868
1869 #[rstest]
1870 fn test_domain_client_get_instrument_not_found() {
1871 let client = DydxHttpClient::default();
1872 let instrument_id = InstrumentId::new(Symbol::new("ETH-USD-PERP"), *DYDX_VENUE);
1873 let result = client.get_instrument(&instrument_id);
1874 assert!(result.is_none());
1875 }
1876
1877 #[tokio::test]
1878 async fn test_http_timeout_respects_configuration_and_does_not_block() {
1879 use tokio::net::TcpListener;
1880
1881 let handler_entered = Arc::new(AtomicBool::new(false));
1882 let handler_entered_clone = Arc::clone(&handler_entered);
1883 let router = Router::new()
1884 .route(
1885 "/v4/slow",
1886 get(move || async move {
1887 handler_entered_clone.store(true, Ordering::SeqCst);
1888 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1889 "ok"
1890 }),
1891 )
1892 .route("/health", get(|| async { "ok" }));
1893
1894 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1895 let addr = listener.local_addr().unwrap();
1896
1897 tokio::spawn(async move {
1898 axum::serve(listener, router.into_make_service())
1899 .await
1900 .unwrap();
1901 });
1902
1903 let base_url = format!("http://{addr}");
1904
1905 let ready_url = format!("{base_url}/health");
1909 let probe = HttpClient::builder().build().unwrap();
1910 wait_until_async(
1911 || {
1912 let url = ready_url.clone();
1913 let probe = probe.clone();
1914 async move { probe.get(url, None, None, Some(1), None).await.is_ok() }
1915 },
1916 std::time::Duration::from_secs(5),
1917 )
1918 .await;
1919
1920 let retry_config = RetryConfig {
1923 max_retries: 0,
1924 initial_delay_ms: 1,
1925 max_delay_ms: 1,
1926 backoff_factor: 1.0,
1927 jitter_ms: 0,
1928 operation_timeout_ms: Some(500),
1929 immediate_first: true,
1930 max_elapsed_ms: Some(1_000),
1931 };
1932
1933 let client = DydxRawHttpClient::new(
1936 Some(base_url),
1937 60,
1938 None,
1939 DydxNetwork::Mainnet,
1940 Some(retry_config),
1941 )
1942 .unwrap();
1943
1944 let start = std::time::Instant::now();
1945 let result: Result<serde_json::Value, error::DydxHttpError> =
1946 client.send_request(Method::GET, "/v4/slow", None).await;
1947 let elapsed = start.elapsed();
1948
1949 let expected = RetryError::OperationTimeout { timeout_ms: 500 }.to_string();
1950 assert!(
1951 matches!(
1952 &result,
1953 Err(error::DydxHttpError::HttpClientError(message)) if message == &expected
1954 ),
1955 "Expected operation timeout, received {result:?}"
1956 );
1957 assert!(
1958 handler_entered.load(Ordering::SeqCst),
1959 "Slow route was never entered"
1960 );
1961 assert!(elapsed < std::time::Duration::from_secs(3));
1962 }
1963}