1use std::{
25 fmt::Debug,
26 sync::{
27 Arc,
28 atomic::{AtomicU64, Ordering},
29 },
30};
31
32use ahash::AHashMap;
33use alloy::signers::local::PrivateKeySigner;
34use nautilus_network::{
35 http::{HttpClient, HttpClientError, HttpResponse},
36 ratelimiter::clock::MonotonicClock,
37 retry::{RetryConfig, RetryManager},
38};
39use serde::{Serialize, de::DeserializeOwned};
40use serde_json::Value;
41use ustr::Ustr;
42
43use crate::{
44 common::{
45 consts::{HEADER_LYRA_SIGNATURE, HEADER_LYRA_TIMESTAMP, HEADER_LYRA_WALLET, HTTP_TIMEOUT},
46 enums::DeriveInstrumentType,
47 rate_limit::{self, DeriveRateLimiter, FixedWindowLimiter},
48 retry::{http_retry_config, should_retry_http_error},
49 },
50 http::{
51 error::{DeriveHttpError, Result},
52 models::{
53 DeriveCancelByLabelResult, DeriveEmptyResult, DeriveInstrument, DeriveOpenOrdersResult,
54 DeriveOrder, DeriveOrderResult, DeriveOrdersResult, DerivePositionsResult,
55 DerivePublicCandle, DerivePublicFundingRateHistoryResult, DerivePublicTradesResult,
56 DeriveReplaceOutcome, DeriveReplaceResult, DeriveSubaccount, DeriveTickerSnapshot,
57 DeriveTickersResult, DeriveTradesResult, JsonRpcResponse,
58 },
59 query::{
60 DeriveCancelAllParams, DeriveCancelByLabelParams, DeriveCancelParams,
61 DeriveGetOpenOrdersParams, DeriveGetOrderHistoryParams, DeriveGetOrderParams,
62 DeriveGetPositionsParams, DeriveGetSubaccountParams, DeriveGetTradeHistoryParams,
63 DeriveGetTriggerOrdersParams, DeriveOrderParams, DeriveReplaceParams,
64 },
65 },
66 signing::auth::{AuthHeaders, build_rest_auth_headers},
67};
68
69#[derive(Clone)]
74pub struct DeriveCredentials {
75 pub wallet_address: String,
77 pub signer: PrivateKeySigner,
79}
80
81impl DeriveCredentials {
82 pub fn new(wallet_address: impl Into<String>, session_key_hex: &str) -> Result<Self> {
89 let signer: PrivateKeySigner = session_key_hex
90 .parse()
91 .map_err(|e| DeriveHttpError::decode(format!("invalid session key: {e}")))?;
92 Ok(Self {
93 wallet_address: wallet_address.into(),
94 signer,
95 })
96 }
97}
98
99impl Debug for DeriveCredentials {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 f.debug_struct(stringify!(DeriveCredentials))
102 .field("wallet_address", &self.wallet_address)
103 .field("signer", &"***redacted***")
104 .finish()
105 }
106}
107
108#[derive(Debug, Clone)]
116pub struct DeriveHttpClient {
117 client: HttpClient,
118 base_url: String,
119 credentials: Option<DeriveCredentials>,
120 next_id: Arc<AtomicU64>,
121 timeout_secs: u64,
122 retry_manager: Arc<RetryManager<DeriveHttpError>>,
123 rate_limiter: Arc<DeriveRateLimiter>,
124}
125
126impl DeriveHttpClient {
127 pub fn new(
136 base_url: impl Into<String>,
137 timeout_secs: Option<u64>,
138 proxy_url: Option<String>,
139 retry_config: Option<RetryConfig>,
140 ) -> Result<Self> {
141 let timeout_secs = timeout_secs.unwrap_or_else(|| HTTP_TIMEOUT.as_secs());
142 let (client, rate_limiter) = build_client(timeout_secs, proxy_url)?;
143 let retry_config = retry_config.unwrap_or_else(|| http_retry_config(3, 100, 5_000));
144 Ok(Self {
145 client,
146 base_url: trim_trailing_slash(base_url.into()),
147 credentials: None,
148 next_id: Arc::new(AtomicU64::new(1)),
149 timeout_secs,
150 retry_manager: Arc::new(RetryManager::new(retry_config)),
151 rate_limiter,
152 })
153 }
154
155 pub fn with_credentials(
162 base_url: impl Into<String>,
163 credentials: DeriveCredentials,
164 timeout_secs: Option<u64>,
165 proxy_url: Option<String>,
166 retry_config: Option<RetryConfig>,
167 ) -> Result<Self> {
168 let mut client = Self::new(base_url, timeout_secs, proxy_url, retry_config)?;
169 client.credentials = Some(credentials);
170 Ok(client)
171 }
172
173 #[must_use]
175 pub fn base_url(&self) -> &str {
176 &self.base_url
177 }
178
179 #[must_use]
181 pub fn has_credentials(&self) -> bool {
182 self.credentials.is_some()
183 }
184
185 fn next_id(&self) -> u64 {
187 self.next_id.fetch_add(1, Ordering::Relaxed)
188 }
189
190 pub async fn send_public<P, R>(&self, method: &str, params: &P) -> Result<R>
199 where
200 P: Serialize + ?Sized,
201 R: DeserializeOwned,
202 {
203 let id = self.next_id();
204 self.dispatch(method, params, id, false, true, None).await
205 }
206
207 pub async fn send_private<P, R>(&self, method: &str, params: &P) -> Result<R>
219 where
220 P: Serialize + ?Sized,
221 R: DeserializeOwned,
222 {
223 if self.credentials.is_none() {
224 return Err(DeriveHttpError::MissingCredentials {
225 method: method.to_owned(),
226 });
227 }
228 let id = self.next_id();
229 self.dispatch(method, params, id, true, true, None).await
230 }
231
232 pub async fn send_private_once<P, R>(&self, method: &str, params: &P) -> Result<R>
256 where
257 P: Serialize + ?Sized,
258 R: DeserializeOwned,
259 {
260 if self.credentials.is_none() {
261 return Err(DeriveHttpError::MissingCredentials {
262 method: method.to_owned(),
263 });
264 }
265 let id = self.next_id();
266 self.dispatch(method, params, id, true, false, None).await
267 }
268
269 async fn send_private_write<P, R>(
272 &self,
273 method: &str,
274 params: &P,
275 instrument_name: Ustr,
276 ) -> Result<R>
277 where
278 P: Serialize + ?Sized,
279 R: DeserializeOwned,
280 {
281 if self.credentials.is_none() {
282 return Err(DeriveHttpError::MissingCredentials {
283 method: method.to_owned(),
284 });
285 }
286 let id = self.next_id();
287 self.dispatch(method, params, id, true, false, Some(instrument_name))
288 .await
289 }
290
291 pub async fn get_instruments(
300 &self,
301 currency: &str,
302 instrument_type: DeriveInstrumentType,
303 expired: bool,
304 ) -> Result<Vec<DeriveInstrument>> {
305 let params = serde_json::json!({
306 "currency": currency,
307 "instrument_type": instrument_type,
308 "expired": expired,
309 });
310 self.send_public("public/get_instruments", ¶ms).await
311 }
312
313 pub async fn get_instrument(&self, instrument_name: &str) -> Result<DeriveInstrument> {
323 let params = serde_json::json!({
324 "instrument_name": instrument_name,
325 });
326 self.send_public("public/get_instrument", ¶ms).await
327 }
328
329 pub async fn get_trade_history(
339 &self,
340 instrument_name: &str,
341 from_timestamp: Option<i64>,
342 to_timestamp: Option<i64>,
343 page: u32,
344 page_size: u32,
345 ) -> Result<DerivePublicTradesResult> {
346 let mut params = serde_json::Map::new();
347 params.insert("instrument_name".to_string(), instrument_name.into());
348 params.insert("page".to_string(), page.into());
349 params.insert("page_size".to_string(), page_size.into());
350 if let Some(from) = from_timestamp {
351 params.insert("from_timestamp".to_string(), from.into());
352 }
353
354 if let Some(to) = to_timestamp {
355 params.insert("to_timestamp".to_string(), to.into());
356 }
357
358 self.send_public("public/get_trade_history", &Value::Object(params))
359 .await
360 }
361
362 pub async fn get_funding_rate_history(
371 &self,
372 instrument_name: &str,
373 start_timestamp: Option<i64>,
374 end_timestamp: Option<i64>,
375 period: Option<u32>,
376 ) -> Result<DerivePublicFundingRateHistoryResult> {
377 let mut params = serde_json::Map::new();
378 params.insert("instrument_name".to_string(), instrument_name.into());
379 if let Some(start) = start_timestamp {
380 params.insert("start_timestamp".to_string(), start.into());
381 }
382
383 if let Some(end) = end_timestamp {
384 params.insert("end_timestamp".to_string(), end.into());
385 }
386
387 if let Some(period) = period {
388 params.insert("period".to_string(), period.into());
389 }
390
391 self.send_public("public/get_funding_rate_history", &Value::Object(params))
392 .await
393 }
394
395 pub async fn get_candles(
407 &self,
408 instrument_name: &str,
409 start_timestamp: i64,
410 end_timestamp: i64,
411 period: u32,
412 ) -> Result<Vec<DerivePublicCandle>> {
413 let params = serde_json::json!({
414 "instrument_name": instrument_name,
415 "start_timestamp": start_timestamp,
416 "end_timestamp": end_timestamp,
417 "period": period,
418 });
419 self.send_public("public/get_tradingview_chart_data", ¶ms)
420 .await
421 }
422
423 pub async fn get_tickers(
433 &self,
434 instrument_type: DeriveInstrumentType,
435 currency: Option<&str>,
436 expiry_date: Option<&str>,
437 ) -> Result<DeriveTickersResult> {
438 let mut params = serde_json::Map::new();
439 params.insert(
440 "instrument_type".to_string(),
441 serde_json::to_value(instrument_type).map_err(DeriveHttpError::from)?,
442 );
443
444 if let Some(currency) = currency {
445 params.insert("currency".to_string(), currency.into());
446 }
447
448 if let Some(expiry_date) = expiry_date {
449 params.insert("expiry_date".to_string(), expiry_date.into());
450 }
451
452 self.send_public("public/get_tickers", &Value::Object(params))
453 .await
454 }
455
456 pub async fn get_ticker(&self, instrument_name: &str) -> Result<DeriveTickerSnapshot> {
467 let request = ticker_request(instrument_name)?;
468 let result = self
469 .get_tickers(
470 request.instrument_type,
471 Some(request.currency),
472 request.expiry_date,
473 )
474 .await?;
475 let mut ticker = result
476 .tickers
477 .get(instrument_name)
478 .cloned()
479 .ok_or_else(|| {
480 DeriveHttpError::decode(format!(
481 "missing ticker `{instrument_name}` in public/get_tickers response"
482 ))
483 })?;
484 ticker.instrument_name = instrument_name.into();
485 Ok(ticker)
486 }
487
488 pub async fn submit_order(&self, params: &DeriveOrderParams) -> Result<DeriveOrder> {
497 let result: DeriveOrderResult = self
498 .send_private_write("private/order", params, params.instrument_name)
499 .await?;
500 Ok(result.order)
501 }
502
503 pub async fn cancel_order(&self, params: &DeriveCancelParams) -> Result<DeriveEmptyResult> {
510 self.send_private_write("private/cancel", params, params.instrument_name)
511 .await
512 }
513
514 pub async fn cancel_all(&self, params: &DeriveCancelAllParams) -> Result<DeriveEmptyResult> {
522 self.send_private_once("private/cancel_all", params).await
523 }
524
525 pub async fn cancel_by_label(
532 &self,
533 params: &DeriveCancelByLabelParams,
534 ) -> Result<DeriveCancelByLabelResult> {
535 self.send_private_once("private/cancel_by_label", params)
536 .await
537 }
538
539 pub async fn replace_order(
549 &self,
550 params: &DeriveReplaceParams,
551 ) -> Result<DeriveReplaceOutcome> {
552 let result: DeriveReplaceResult = self
553 .send_private_write("private/replace", params, params.order.instrument_name)
554 .await?;
555 result
556 .into_outcome(¶ms.order_id_to_cancel, ¶ms.order.label)
557 .map_err(DeriveHttpError::decode)
558 }
559
560 pub async fn get_subaccount(
568 &self,
569 params: &DeriveGetSubaccountParams,
570 ) -> Result<DeriveSubaccount> {
571 self.send_private("private/get_subaccount", params).await
572 }
573
574 pub async fn get_open_orders(
581 &self,
582 params: &DeriveGetOpenOrdersParams,
583 ) -> Result<DeriveOpenOrdersResult> {
584 self.send_private("private/get_open_orders", params).await
585 }
586
587 pub async fn get_trigger_orders(
594 &self,
595 params: &DeriveGetTriggerOrdersParams,
596 ) -> Result<DeriveOpenOrdersResult> {
597 self.send_private("private/get_trigger_orders", params)
598 .await
599 }
600
601 pub async fn get_order(&self, params: &DeriveGetOrderParams) -> Result<DeriveOrder> {
608 self.send_private("private/get_order", params).await
609 }
610
611 pub async fn get_order_history(
622 &self,
623 params: &DeriveGetOrderHistoryParams,
624 ) -> Result<DeriveOrdersResult> {
625 self.send_private("private/get_order_history", params).await
626 }
627
628 pub async fn get_private_trade_history(
635 &self,
636 params: &DeriveGetTradeHistoryParams,
637 ) -> Result<DeriveTradesResult> {
638 self.send_private("private/get_trade_history", params).await
639 }
640
641 pub async fn get_positions(
648 &self,
649 params: &DeriveGetPositionsParams,
650 ) -> Result<DerivePositionsResult> {
651 self.send_private("private/get_positions", params).await
652 }
653
654 async fn dispatch<P, R>(
655 &self,
656 method: &str,
657 params: &P,
658 id: u64,
659 authenticate: bool,
660 retry: bool,
661 instrument_name: Option<Ustr>,
662 ) -> Result<R>
663 where
664 P: Serialize + ?Sized,
665 R: DeserializeOwned,
666 {
667 let url = format!("{}/{}", self.base_url, method.trim_start_matches('/'));
668 let body_value = serde_json::to_value(params).map_err(DeriveHttpError::from)?;
669 let body = serde_json::to_vec(&body_value).map_err(DeriveHttpError::from)?;
670
671 let rate_class = rate_limit::rate_class_for_method(method);
672
673 let attempt = || async {
679 self.rate_limiter
680 .await_class_ready(rate_class, instrument_name.as_ref())
681 .await;
682
683 let mut headers: AHashMap<String, String> = AHashMap::with_capacity(4);
684 headers.insert("Content-Type".to_string(), "application/json".to_string());
685
686 if authenticate {
687 let auth = self.build_auth_headers(method)?;
688 headers.insert(HEADER_LYRA_WALLET.to_string(), auth.wallet);
689 headers.insert(HEADER_LYRA_TIMESTAMP.to_string(), auth.timestamp);
690 headers.insert(HEADER_LYRA_SIGNATURE.to_string(), auth.signature);
691 }
692
693 let response = self
694 .client
695 .post(
696 url.clone(),
697 None,
698 Some(headers.into_iter().collect()),
699 Some(body.clone()),
700 Some(self.timeout_secs),
701 None,
702 )
703 .await
704 .map_err(DeriveHttpError::from)?;
705
706 decode_envelope(method, id, response)
707 };
708
709 if retry {
710 self.retry_manager
711 .execute_with_retry(method, attempt, should_retry_http_error, |e| {
712 DeriveHttpError::transport(e.to_string())
713 })
714 .await
715 } else {
716 attempt().await
717 }
718 }
719
720 fn build_auth_headers(&self, method: &str) -> Result<AuthHeaders> {
721 let credentials =
722 self.credentials
723 .as_ref()
724 .ok_or_else(|| DeriveHttpError::MissingCredentials {
725 method: method.to_owned(),
726 })?;
727 let auth = build_rest_auth_headers(&credentials.wallet_address, &credentials.signer)?;
728 Ok(auth)
729 }
730}
731
732#[derive(Debug, Clone, Copy)]
733struct TickerRequest<'a> {
734 instrument_type: DeriveInstrumentType,
735 currency: &'a str,
736 expiry_date: Option<&'a str>,
737}
738
739fn ticker_request(instrument_name: &str) -> Result<TickerRequest<'_>> {
740 let Some((currency, suffix)) = instrument_name.split_once('-') else {
741 return Err(DeriveHttpError::decode(format!(
742 "invalid Derive instrument name `{instrument_name}`"
743 )));
744 };
745
746 if suffix == "PERP" {
747 return Ok(TickerRequest {
748 instrument_type: DeriveInstrumentType::Perp,
749 currency,
750 expiry_date: None,
751 });
752 }
753
754 let mut parts = suffix.split('-');
755 let Some(expiry_date) = parts.next() else {
756 return Ok(TickerRequest {
757 instrument_type: DeriveInstrumentType::Erc20,
758 currency,
759 expiry_date: None,
760 });
761 };
762 let has_option_tail = parts.clone().count() == 2;
763 if expiry_date.len() == 8 && expiry_date.chars().all(|c| c.is_ascii_digit()) && has_option_tail
764 {
765 return Ok(TickerRequest {
766 instrument_type: DeriveInstrumentType::Option,
767 currency,
768 expiry_date: Some(expiry_date),
769 });
770 }
771
772 Ok(TickerRequest {
773 instrument_type: DeriveInstrumentType::Erc20,
774 currency,
775 expiry_date: None,
776 })
777}
778
779fn build_client(
780 timeout_secs: u64,
781 proxy_url: Option<String>,
782) -> std::result::Result<(HttpClient, Arc<DeriveRateLimiter>), HttpClientError> {
783 let rate_limiter = Arc::new(FixedWindowLimiter::new(
787 rate_limit::FixedWindowLimits::rest(None, None),
788 MonotonicClock {},
789 ));
790 let client = HttpClient::builder()
794 .timeout_secs(timeout_secs)
795 .maybe_proxy_url(proxy_url)
796 .rate_limiters(Vec::new())
797 .build()?;
798 Ok((client, rate_limiter))
799}
800
801fn trim_trailing_slash(url: String) -> String {
802 if url.ends_with('/') {
803 url.trim_end_matches('/').to_string()
804 } else {
805 url
806 }
807}
808
809fn decode_envelope<R: DeserializeOwned>(
810 method: &str,
811 request_id: u64,
812 response: HttpResponse,
813) -> Result<R> {
814 let status = response.status.as_u16();
815 let is_success_status = (200..300).contains(&status);
816 let body = response.body;
817
818 let envelope: JsonRpcResponse<R> = match serde_json::from_slice(&body) {
819 Ok(env) => env,
820 Err(e) => {
821 if !is_success_status {
822 let text = String::from_utf8_lossy(&body).into_owned();
823 return Err(DeriveHttpError::http(status, truncate(text, 512)));
824 }
825 return Err(DeriveHttpError::decode(format!(
826 "failed to decode `{method}` response: {e}",
827 )));
828 }
829 };
830
831 if let Some(err) = envelope.error {
832 return Err(DeriveHttpError::JsonRpc {
833 code: err.code,
834 message: err.message,
835 data: err.data,
836 });
837 }
838
839 if !is_success_status {
844 let text = String::from_utf8_lossy(&body).into_owned();
845 return Err(DeriveHttpError::http(status, truncate(text, 512)));
846 }
847
848 if let Some(echoed) = envelope.id
849 && echoed != request_id
850 {
851 log::debug!(
852 "derive: id mismatch for `{method}` (sent={request_id}, recv={echoed}); accepting result",
853 );
854 }
855
856 envelope
857 .result
858 .ok_or_else(|| DeriveHttpError::MissingResult {
859 method: method.to_owned(),
860 })
861}
862
863fn truncate(s: String, max: usize) -> String {
864 if s.len() <= max {
865 return s;
866 }
867 let mut cutoff = max;
868 while cutoff > 0 && !s.is_char_boundary(cutoff) {
869 cutoff -= 1;
870 }
871 let mut out = String::with_capacity(cutoff + 3);
872 out.push_str(&s[..cutoff]);
873 out.push_str("...");
874 out
875}
876
877#[cfg(test)]
878mod tests {
879 use std::collections::HashMap;
880
881 use nautilus_network::http::{HttpStatus, StatusCode};
882 use rstest::rstest;
883
884 use super::*;
885
886 const SESSION_KEY_HEX: &str =
887 "0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd";
888 const TEST_WALLET: &str = "0x000000000000000000000000000000000000aaaa";
889
890 fn test_client() -> DeriveHttpClient {
891 DeriveHttpClient::new("https://api.example/", None, None, None).expect("client builds")
892 }
893
894 fn test_response(status: u16, body: &serde_json::Value) -> HttpResponse {
895 let status_code = StatusCode::from_u16(status).unwrap();
896 HttpResponse {
897 status: HttpStatus::new(status_code),
898 headers: HashMap::new(),
899 body: serde_json::to_vec(body).unwrap().into(),
900 }
901 }
902
903 #[rstest]
904 fn test_credentials_debug_redacts_signer() {
905 let creds = DeriveCredentials::new(TEST_WALLET, SESSION_KEY_HEX).unwrap();
906 let dbg = format!("{creds:?}");
907 assert!(dbg.contains("***redacted***"));
908 assert!(dbg.contains(TEST_WALLET));
909 assert!(!dbg.contains(SESSION_KEY_HEX));
910 }
911
912 #[rstest]
913 fn test_credentials_rejects_invalid_session_key() {
914 let err = DeriveCredentials::new(TEST_WALLET, "not-hex").expect_err("must reject");
915 match err {
916 DeriveHttpError::Decode(msg) => assert!(msg.contains("invalid session key")),
917 other => panic!("expected Decode, was {other:?}"),
918 }
919 }
920
921 #[rstest]
922 fn test_base_url_trims_trailing_slash() {
923 let client = test_client();
924 assert_eq!(client.base_url(), "https://api.example");
925 }
926
927 #[rstest]
928 fn test_new_has_no_credentials() {
929 assert!(!test_client().has_credentials());
930 }
931
932 #[rstest]
933 fn test_with_credentials_sets_creds() {
934 let creds = DeriveCredentials::new(TEST_WALLET, SESSION_KEY_HEX).unwrap();
935 let client =
936 DeriveHttpClient::with_credentials("https://api.example", creds, None, None, None)
937 .unwrap();
938 assert!(client.has_credentials());
939 }
940
941 #[rstest]
942 fn test_next_id_increments_monotonically() {
943 let client = test_client();
944 let a = client.next_id();
945 let b = client.next_id();
946 let c = client.next_id();
947 assert_eq!(b, a + 1);
948 assert_eq!(c, b + 1);
949 }
950
951 #[rstest]
952 fn test_decode_envelope_returns_result() {
953 let resp = test_response(200, &serde_json::json!({"id": 1, "result": {"ok": true}}));
954 let value: Value = decode_envelope("public/get_instruments", 1, resp).unwrap();
955 assert_eq!(value["ok"], true);
956 }
957
958 #[rstest]
959 fn test_decode_envelope_accepts_null_empty_result() {
960 let resp = test_response(200, &serde_json::json!({"id": 1, "result": null}));
961 let result: DeriveEmptyResult = decode_envelope("private/cancel", 1, resp).unwrap();
962 assert_eq!(result, DeriveEmptyResult {});
963 }
964
965 #[rstest]
966 fn test_decode_envelope_propagates_jsonrpc_error() {
967 let resp = test_response(
968 200,
969 &serde_json::json!({
970 "id": 1,
971 "error": {"code": -32601, "message": "Method not found"}
972 }),
973 );
974 let err: DeriveHttpError = decode_envelope::<Value>("public/missing", 1, resp).unwrap_err();
975 match err {
976 DeriveHttpError::JsonRpc { code, message, .. } => {
977 assert_eq!(code, -32601);
978 assert_eq!(message, "Method not found");
979 }
980 other => panic!("expected JsonRpc, was {other:?}"),
981 }
982 }
983
984 #[rstest]
985 fn test_decode_envelope_flags_missing_result() {
986 let resp = test_response(200, &serde_json::json!({"id": 1}));
987 let err = decode_envelope::<Value>("public/get_instruments", 1, resp).unwrap_err();
988 assert!(matches!(err, DeriveHttpError::MissingResult { .. }));
989 }
990
991 #[rstest]
992 fn test_decode_envelope_flags_non_2xx_with_unparsable_body() {
993 let status_code = StatusCode::from_u16(503).unwrap();
994 let response = HttpResponse {
995 status: HttpStatus::new(status_code),
996 headers: HashMap::new(),
997 body: bytes::Bytes::from_static(b"<html>upstream down</html>"),
998 };
999 let err = decode_envelope::<Value>("public/get_instruments", 1, response).unwrap_err();
1000 match err {
1001 DeriveHttpError::Http { status, message } => {
1002 assert_eq!(status, 503);
1003 assert!(message.contains("upstream down"));
1004 }
1005 other => panic!("expected Http, was {other:?}"),
1006 }
1007 }
1008
1009 #[rstest]
1010 fn test_decode_envelope_flags_non_2xx_with_non_envelope_json() {
1011 let resp = test_response(401, &serde_json::json!({"message": "Unauthorized"}));
1014 let err = decode_envelope::<Value>("private/order", 1, resp).unwrap_err();
1015 match err {
1016 DeriveHttpError::Http { status, message } => {
1017 assert_eq!(status, 401);
1018 assert!(message.contains("Unauthorized"));
1019 }
1020 other => panic!("expected Http, was {other:?}"),
1021 }
1022 }
1023
1024 #[rstest]
1025 fn test_decode_envelope_prefers_jsonrpc_error_over_http_status() {
1026 let status_code = StatusCode::from_u16(400).unwrap();
1029 let body = serde_json::json!({
1030 "id": 1,
1031 "error": {"code": -32602, "message": "Invalid params"},
1032 });
1033 let response = HttpResponse {
1034 status: HttpStatus::new(status_code),
1035 headers: HashMap::new(),
1036 body: serde_json::to_vec(&body).unwrap().into(),
1037 };
1038 let err = decode_envelope::<Value>("private/order", 1, response).unwrap_err();
1039 assert!(matches!(err, DeriveHttpError::JsonRpc { code: -32602, .. }));
1040 }
1041
1042 #[rstest]
1043 fn test_truncate_handles_multi_byte_char_at_boundary() {
1044 let s = "ΩΩΩΩΩΩΩΩΩΩ".to_string();
1047 assert_eq!(s.len(), 20);
1048 let out = truncate(s, 5);
1049 assert!(out.ends_with("..."));
1050 let prefix = out.trim_end_matches("...");
1051 assert!(prefix.is_char_boundary(prefix.len()));
1052 assert!(prefix.chars().all(|c| c == 'Ω'));
1053 }
1054
1055 #[rstest]
1056 fn test_truncate_returns_input_when_under_limit() {
1057 let s = "short".to_string();
1058 assert_eq!(truncate(s, 16), "short");
1059 }
1060
1061 #[rstest]
1062 fn test_decode_envelope_non_2xx_body_with_non_ascii_does_not_panic() {
1063 let glyph = "Ω";
1066 let body = glyph.repeat(600);
1067 let status_code = StatusCode::from_u16(503).unwrap();
1068 let response = HttpResponse {
1069 status: HttpStatus::new(status_code),
1070 headers: HashMap::new(),
1071 body: body.into_bytes().into(),
1072 };
1073 let err = decode_envelope::<Value>("public/get_instruments", 1, response).unwrap_err();
1074 assert!(matches!(err, DeriveHttpError::Http { status: 503, .. }));
1075 }
1076
1077 #[rstest]
1078 fn test_decode_envelope_accepts_id_mismatch() {
1079 let resp = test_response(200, &serde_json::json!({"id": 99, "result": "ok"}));
1080 let value: Value = decode_envelope("public/get_instruments", 1, resp).unwrap();
1081 assert_eq!(value, serde_json::json!("ok"));
1082 }
1083
1084 #[tokio::test]
1085 async fn test_send_private_without_credentials_errors() {
1086 let client = test_client();
1087 let err = client
1088 .send_private::<_, Value>("private/order", &serde_json::json!({}))
1089 .await
1090 .expect_err("must require credentials");
1091
1092 match err {
1093 DeriveHttpError::MissingCredentials { method } => {
1094 assert_eq!(method, "private/order");
1095 }
1096 other => panic!("expected MissingCredentials, was {other:?}"),
1097 }
1098 }
1099}