1use std::{future::Future, sync::Arc, time::Duration};
2
3use cow_sdk_core::transport::policy::{
4 AttemptOutcome as RetryOutcome, LimiterKey, RequestRateLimiter, RetryPolicy, RetrySignal,
5 retry_after_from_headers, run_with_retry,
6};
7use cow_sdk_core::{HttpTransport, Redacted, TransportError};
8use http::header::{ACCEPT, CONTENT_TYPE, HeaderMap};
9use serde::de::DeserializeOwned;
10use serde_json::Value;
11use thiserror::Error;
12
13use crate::error::OrderbookError;
14
15pub(crate) type SharedTransport = Arc<dyn HttpTransport + Send + Sync>;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum HttpMethod {
25 Get,
27 Post,
29 Delete,
31 Put,
33}
34
35#[allow(
40 clippy::derive_partial_eq_without_eq,
41 reason = "the `Json(serde_json::Value)` variant cannot implement `Eq` because `serde_json::Value` does not implement `Eq`"
42)]
43#[derive(Debug, Clone, PartialEq)]
44pub enum ResponseBody {
45 Json(Value),
47 Text(String),
49 Empty,
51}
52
53#[derive(Debug, Clone, PartialEq, Error)]
55#[error("{message}")]
56pub struct OrderbookApiError {
57 pub status: u16,
59 pub status_text: Redacted<String>,
61 pub body: Redacted<ResponseBody>,
63 message: Redacted<String>,
64 retry_after: Option<Duration>,
67}
68
69impl OrderbookApiError {
70 #[must_use]
75 pub fn new(status: u16, status_text: impl Into<String>, body: ResponseBody) -> Self {
76 let status_text = status_text.into();
77 let message = match &body {
78 ResponseBody::Json(Value::Object(map)) => map
79 .get("description")
80 .or_else(|| map.get("error"))
81 .and_then(Value::as_str)
82 .map_or_else(|| status_text.clone(), ToOwned::to_owned),
83 ResponseBody::Json(Value::String(text)) => text.clone(),
84 ResponseBody::Text(text) if !text.is_empty() => text.clone(),
85 _ => status_text.clone(),
86 };
87
88 Self {
89 status,
90 status_text: Redacted::new(status_text),
91 body: Redacted::new(body),
92 message: Redacted::new(message),
93 retry_after: None,
94 }
95 }
96
97 #[must_use]
104 pub const fn with_retry_after(mut self, retry_after: Option<Duration>) -> Self {
105 self.retry_after = retry_after;
106 self
107 }
108
109 #[must_use]
112 pub const fn retry_after(&self) -> Option<Duration> {
113 self.retry_after
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct FetchParams {
120 pub path: String,
122 pub method: HttpMethod,
124 pub query: Vec<(String, String)>,
126 pub body: Option<Value>,
128}
129
130impl FetchParams {
131 #[must_use]
133 pub fn new(path: impl Into<String>, method: HttpMethod) -> Self {
134 Self {
135 path: path.into(),
136 method,
137 query: Vec::new(),
138 body: None,
139 }
140 }
141
142 #[must_use]
144 pub fn with_query(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
145 self.query.push((key.into(), value.into()));
146 self
147 }
148
149 #[must_use]
151 pub fn with_body(mut self, body: Value) -> Self {
152 self.body = Some(body);
153 self
154 }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct ResponseEnvelope {
160 pub status: u16,
162 pub status_text: String,
164 pub content_type: Option<String>,
166 pub body: Vec<u8>,
168}
169
170impl ResponseEnvelope {
171 #[must_use]
178 pub fn json(status: u16, value: &Value) -> Self {
179 Self {
180 status,
181 status_text: canonical_status_text(status),
182 content_type: Some("application/json".to_owned()),
183 body: serde_json::to_vec(value).expect("test JSON serialization must succeed"),
186 }
187 }
188
189 #[must_use]
191 pub fn text(status: u16, body: impl Into<String>) -> Self {
192 Self {
193 status,
194 status_text: canonical_status_text(status),
195 content_type: Some("text/plain".to_owned()),
196 body: body.into().into_bytes(),
197 }
198 }
199
200 #[must_use]
202 pub fn empty(status: u16) -> Self {
203 Self {
204 status,
205 status_text: canonical_status_text(status),
206 content_type: None,
207 body: Vec::new(),
208 }
209 }
210
211 fn decoded_body(&self) -> ResponseBody {
212 if self.status == 204 || self.body.is_empty() {
213 return ResponseBody::Empty;
214 }
215
216 let prefer_json = self.content_type.as_deref().is_none_or(|content_type| {
217 content_type
218 .to_ascii_lowercase()
219 .starts_with("application/json")
220 });
221
222 if prefer_json && let Ok(value) = serde_json::from_slice::<Value>(&self.body) {
223 return ResponseBody::Json(value);
224 }
225
226 ResponseBody::Text(String::from_utf8_lossy(&self.body).into_owned())
227 }
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231enum ResponseKind {
232 Json,
233 Text,
234 Empty,
235}
236
237impl ResponseKind {
238 const fn accept_header(self) -> &'static str {
239 match self {
240 Self::Text => "text/plain, application/json",
241 Self::Json | Self::Empty => "application/json",
242 }
243 }
244}
245
246enum AttemptOutcome {
247 Response(ResponseEnvelope),
248 HttpError {
249 response: ResponseEnvelope,
250 headers: Vec<(String, String)>,
251 },
252}
253
254struct RequestExecution<'a> {
255 transport: &'a SharedTransport,
256 base_url: &'a str,
257 params: &'a FetchParams,
258 timeout: Option<Duration>,
259 additional_headers: Option<HeaderMap>,
260}
261
262pub async fn request_json_with_timeout<T>(
269 transport: &SharedTransport,
270 base_url: &str,
271 params: &FetchParams,
272 policy: &RetryPolicy,
273 rate_limiter: &RequestRateLimiter,
274 timeout: Option<Duration>,
275 additional_headers: Option<HeaderMap>,
276) -> Result<T, OrderbookError>
277where
278 T: DeserializeOwned,
279{
280 request_with(
281 RequestExecution {
282 transport,
283 base_url,
284 params,
285 timeout,
286 additional_headers,
287 },
288 policy,
289 rate_limiter,
290 ResponseKind::Json,
291 decode_success_body::<T>,
292 )
293 .await
294}
295
296pub async fn request_text_with_timeout(
303 transport: &SharedTransport,
304 base_url: &str,
305 params: &FetchParams,
306 policy: &RetryPolicy,
307 rate_limiter: &RequestRateLimiter,
308 timeout: Option<Duration>,
309 additional_headers: Option<HeaderMap>,
310) -> Result<String, OrderbookError> {
311 request_with(
312 RequestExecution {
313 transport,
314 base_url,
315 params,
316 timeout,
317 additional_headers,
318 },
319 policy,
320 rate_limiter,
321 ResponseKind::Text,
322 decode_text_body,
323 )
324 .await
325}
326
327pub async fn request_empty_with_timeout(
334 transport: &SharedTransport,
335 base_url: &str,
336 params: &FetchParams,
337 policy: &RetryPolicy,
338 rate_limiter: &RequestRateLimiter,
339 timeout: Option<Duration>,
340 additional_headers: Option<HeaderMap>,
341) -> Result<(), OrderbookError> {
342 request_with(
343 RequestExecution {
344 transport,
345 base_url,
346 params,
347 timeout,
348 additional_headers,
349 },
350 policy,
351 rate_limiter,
352 ResponseKind::Empty,
353 |_| Ok(()),
354 )
355 .await
356}
357
358pub async fn execute_json_with<T, F, Fut>(
365 policy: &RetryPolicy,
366 rate_limiter: &RequestRateLimiter,
367 mut attempt: F,
368) -> Result<T, OrderbookError>
369where
370 T: DeserializeOwned,
371 F: FnMut() -> Fut,
372 Fut: Future<Output = Result<ResponseEnvelope, (cow_sdk_core::TransportErrorClass, String)>>,
373{
374 execute_with(
375 None,
376 policy,
377 rate_limiter,
378 move || {
379 let future = attempt();
380 async move { future.await.map(AttemptOutcome::Response) }
381 },
382 decode_success_body::<T>,
383 )
384 .await
385}
386
387pub async fn execute_empty_with<F, Fut>(
394 policy: &RetryPolicy,
395 rate_limiter: &RequestRateLimiter,
396 mut attempt: F,
397) -> Result<(), OrderbookError>
398where
399 F: FnMut() -> Fut,
400 Fut: Future<Output = Result<ResponseEnvelope, (cow_sdk_core::TransportErrorClass, String)>>,
401{
402 execute_with(
403 None,
404 policy,
405 rate_limiter,
406 move || {
407 let future = attempt();
408 async move { future.await.map(AttemptOutcome::Response) }
409 },
410 |_| Ok(()),
411 )
412 .await
413}
414
415async fn request_with<T, D>(
416 request: RequestExecution<'_>,
417 policy: &RetryPolicy,
418 rate_limiter: &RequestRateLimiter,
419 response_kind: ResponseKind,
420 decode_success: D,
421) -> Result<T, OrderbookError>
422where
423 D: Fn(&ResponseEnvelope) -> Result<T, OrderbookError>,
424{
425 let url = format!("{}{}", request.base_url, request.params.path);
426 let limiter_url = url::Url::parse(&url).map_err(|error| OrderbookError::Transport {
427 class: cow_sdk_core::TransportErrorClass::Builder,
428 detail: Redacted::new(format!(
429 "could not parse request URL for rate limiting: {error}"
430 )),
431 })?;
432 let transport = Arc::clone(request.transport);
433 let params = request.params.clone();
434 let timeout = request.timeout;
435 let additional_headers = request.additional_headers;
436
437 execute_with(
438 Some(&limiter_url),
439 policy,
440 rate_limiter,
441 || {
442 send_request(
443 Arc::clone(&transport),
444 url.clone(),
445 params.clone(),
446 timeout,
447 response_kind,
448 additional_headers.clone(),
449 )
450 },
451 decode_success,
452 )
453 .await
454}
455
456async fn send_request(
457 transport: SharedTransport,
458 url: String,
459 params: FetchParams,
460 timeout: Option<Duration>,
461 response_kind: ResponseKind,
462 additional_headers: Option<HeaderMap>,
463) -> Result<AttemptOutcome, (cow_sdk_core::TransportErrorClass, String)> {
464 let full_url = match append_query_string(&url, ¶ms.query) {
465 Ok(url) => url,
466 Err(message) => {
467 return Err((cow_sdk_core::TransportErrorClass::Builder, message));
468 }
469 };
470
471 let body_string = match params.body.as_ref() {
472 Some(value) => match serde_json::to_string(value) {
473 Ok(body) => body,
474 Err(error) => {
475 return Err((
476 cow_sdk_core::TransportErrorClass::Builder,
477 format!("could not serialize request body: {error}"),
478 ));
479 }
480 },
481 None => String::new(),
482 };
483
484 let header_pairs = request_header_pairs(response_kind, additional_headers);
485
486 let result = match params.method {
487 HttpMethod::Get => transport.get(&full_url, &header_pairs, timeout).await,
488 HttpMethod::Post => {
489 transport
490 .post(&full_url, &body_string, &header_pairs, timeout)
491 .await
492 }
493 HttpMethod::Put => {
494 transport
495 .put(&full_url, &body_string, &header_pairs, timeout)
496 .await
497 }
498 HttpMethod::Delete => {
499 transport
500 .delete(&full_url, &body_string, &header_pairs, timeout)
501 .await
502 }
503 };
504
505 match result {
506 Ok(response) => {
507 let status = response.status();
508 let content_type = response
509 .header(CONTENT_TYPE.as_str())
510 .map(ToOwned::to_owned);
511 let success = (200..300).contains(&status);
512 let headers: Vec<(String, String)> = if success {
513 Vec::new()
514 } else {
515 response
516 .headers()
517 .iter()
518 .map(|(name, value)| (name.clone(), value.as_inner().clone()))
519 .collect()
520 };
521 let envelope = ResponseEnvelope {
522 status,
523 status_text: canonical_status_text(status),
524 content_type,
525 body: response.into_body().into_bytes(),
526 };
527 if success {
528 Ok(AttemptOutcome::Response(envelope))
529 } else {
530 Ok(AttemptOutcome::HttpError {
535 response: envelope,
536 headers,
537 })
538 }
539 }
540 Err(TransportError::HttpStatus {
541 status,
542 headers,
543 body,
544 }) => {
545 let content_type = headers
546 .iter()
547 .find(|(name, _)| name.eq_ignore_ascii_case(CONTENT_TYPE.as_str()))
548 .map(|(_, value)| value.as_inner().clone());
549 Ok(AttemptOutcome::HttpError {
550 response: ResponseEnvelope {
551 status,
552 status_text: canonical_status_text(status),
553 content_type,
554 body: body.into_inner().into_bytes(),
555 },
556 headers: headers
557 .into_iter()
558 .map(|(name, value)| (name, value.into_inner()))
559 .collect(),
560 })
561 }
562 Err(TransportError::Transport { class, detail }) => Err((class, detail.into_inner())),
563 Err(TransportError::Configuration { message }) => Err((
564 cow_sdk_core::TransportErrorClass::Builder,
565 message.into_inner(),
566 )),
567 Err(other) => Err((cow_sdk_core::TransportErrorClass::Other, other.to_string())),
568 }
569}
570
571fn append_query_string(url: &str, query: &[(String, String)]) -> Result<String, String> {
572 if query.is_empty() {
573 return Ok(url.to_owned());
574 }
575 url::Url::parse_with_params(
576 url,
577 query
578 .iter()
579 .map(|(key, value)| (key.as_str(), value.as_str())),
580 )
581 .map(String::from)
582 .map_err(|error| format!("could not encode query parameters: {error}"))
583}
584
585fn request_header_pairs(
586 response_kind: ResponseKind,
587 additional_headers: Option<HeaderMap>,
588) -> Vec<(String, String)> {
589 let mut pairs = Vec::with_capacity(2 + additional_headers.as_ref().map_or(0, HeaderMap::len));
590 pairs.push((ACCEPT.to_string(), response_kind.accept_header().to_owned()));
591 pairs.push((CONTENT_TYPE.to_string(), "application/json".to_owned()));
592 if let Some(extra) = additional_headers {
593 for (name, value) in &extra {
594 let Ok(value_str) = value.to_str() else {
595 continue;
596 };
597 pairs.push((name.as_str().to_owned(), value_str.to_owned()));
598 }
599 }
600 pairs
601}
602
603async fn execute_with<T, F, Fut, D>(
604 limiter_url: Option<&url::Url>,
605 policy: &RetryPolicy,
606 rate_limiter: &RequestRateLimiter,
607 mut attempt: F,
608 decode_success: D,
609) -> Result<T, OrderbookError>
610where
611 F: FnMut() -> Fut,
612 Fut: Future<Output = Result<AttemptOutcome, (cow_sdk_core::TransportErrorClass, String)>>,
613 D: Fn(&ResponseEnvelope) -> Result<T, OrderbookError>,
614{
615 let limiter_key = limiter_url.map_or(LimiterKey::Global, LimiterKey::PerUrl);
621 let response = run_with_retry::<ResponseEnvelope, OrderbookError, _, _>(
622 policy,
623 rate_limiter,
624 limiter_key,
625 |attempt_index| {
626 let future = attempt();
627 async move {
628 #[cfg(feature = "tracing")]
629 record_span_attempts(attempt_index);
630 #[cfg(not(feature = "tracing"))]
631 let _ = attempt_index;
632
633 match future.await {
634 Ok(AttemptOutcome::Response(response))
635 if (200..300).contains(&response.status) =>
636 {
637 #[cfg(feature = "tracing")]
638 record_span_status(response.status);
639 RetryOutcome::Success(response)
640 }
641 Ok(outcome) => {
642 let (response, headers) = match outcome {
643 AttemptOutcome::Response(response) => (response, Vec::new()),
644 AttemptOutcome::HttpError { response, headers } => (response, headers),
645 };
646 #[cfg(feature = "tracing")]
647 record_span_status(response.status);
648 let status = response.status;
649 let body = response.decoded_body();
650 let retry_after = retry_after_from_headers(&headers);
657 let error = OrderbookApiError::new(status, response.status_text, body)
658 .with_retry_after(retry_after);
659 RetryOutcome::Failure {
660 error: error.into(),
661 signal: RetrySignal::HttpStatus { status, headers },
662 }
663 }
664 Err((class, detail)) => RetryOutcome::Failure {
665 error: OrderbookError::Transport {
666 class,
667 detail: Redacted::new(detail),
668 },
669 signal: RetrySignal::Transport { class },
670 },
671 }
672 }
673 },
674 )
675 .await?;
676
677 decode_success(&response)
678}
679
680#[cfg(feature = "tracing")]
681fn record_span_attempts(attempt_index: usize) {
682 let attempts = u64::try_from(attempt_index).unwrap_or(u64::MAX);
683 tracing::Span::current().record("attempts", attempts);
684}
685
686#[cfg(feature = "tracing")]
687fn record_span_status(status: u16) {
688 tracing::Span::current().record("status", u64::from(status));
689}
690
691fn decode_success_body<T>(response: &ResponseEnvelope) -> Result<T, OrderbookError>
692where
693 T: DeserializeOwned,
694{
695 serde_json::from_slice::<T>(&response.body).map_err(OrderbookError::from)
696}
697
698fn decode_text_body(response: &ResponseEnvelope) -> Result<String, OrderbookError> {
699 String::from_utf8(response.body.clone()).map_err(|error| OrderbookError::Transport {
700 class: cow_sdk_core::TransportErrorClass::Decode,
701 detail: Redacted::new(error.to_string()),
702 })
703}
704
705fn canonical_status_text(status: u16) -> String {
706 http::StatusCode::from_u16(status)
707 .ok()
708 .and_then(|status| status.canonical_reason().map(ToOwned::to_owned))
709 .unwrap_or_else(|| "Unknown Status".to_owned())
710}