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