1use std::{
19 collections::HashMap,
20 future::Future,
21 path::{Path, PathBuf},
22 sync::{
23 LazyLock,
24 atomic::{AtomicU64, Ordering},
25 },
26 time::Duration,
27};
28
29use jiff::Timestamp;
30use nautilus_core::{UUID4, UnixNanos, string::secret::SecretString};
31use nautilus_model::identifiers::InstrumentId;
32use nautilus_network::{
33 backoff::ExponentialBackoff,
34 http::{HttpClient, HttpRedirectPolicy, HttpResponse, Method},
35 retry::{RetryConfig, RetryManager},
36};
37use serde::{Deserialize, Serialize, de::DeserializeOwned};
38use tokio::io::AsyncWriteExt;
39use tokio_util::sync::CancellationToken;
40use url::Url;
41
42use super::{
43 error::Error,
44 models::{Book, Exchange, Ohlcv, Quote, Symbol, Trade},
45 parse::{CoinApiInstrument, parse_instruments_with_precisions, validate_price_precisions},
46 query::{
47 BookQuery, ExchangesQuery, HISTORY_LIMIT_MAX, HistoryQuery, HistoryRange, OhlcvQuery,
48 SymbolsQuery, TradesQuery, provider_time,
49 },
50};
51use crate::common::{
52 consts::COINAPI_NAUTILUS_REFERRER, credential::is_valid_api_key, enums::OhlcvPeriod,
53};
54
55pub const REST_URL: &str = "https://rest.coinapi.io/v1/";
57
58static FLAT_FILES_URL: LazyLock<Url> = LazyLock::new(|| {
59 Url::parse("https://s3.flatfiles.coinapi.io/").expect("valid Flat Files endpoint")
60});
61
62#[derive(Debug, Clone)]
78pub struct CoinApiHttpConfig {
79 pub base_url: String,
81 pub proxy_url: Option<SecretString>,
83 pub max_concurrent_requests: usize,
85 pub credit_budget: u64,
87 pub retry: RetryConfig,
89}
90
91impl Default for CoinApiHttpConfig {
92 fn default() -> Self {
93 Self {
94 base_url: REST_URL.into(),
95 proxy_url: None,
96 max_concurrent_requests: 1,
97 credit_budget: 100,
98 retry: RetryConfig {
99 jitter_ms: 0,
100 max_elapsed_ms: Some(120_000),
101 ..RetryConfig::default()
102 },
103 }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct HistoryPages<T> {
113 pub records: Vec<T>,
114 pub is_truncated: bool,
115}
116
117#[derive(Debug)]
125pub struct CoinApiHttpClient {
126 api_key: SecretString,
127 base_url: Url,
128 client: HttpClient,
129 flat_files_client: HttpClient,
130 concurrency: tokio::sync::Semaphore,
131 ranges: tokio::sync::Mutex<()>,
132 retry: RetryManager<Error>,
133 cancellation: CancellationToken,
134 credits_remaining: AtomicU64,
135}
136
137impl CoinApiHttpClient {
138 pub fn new(api_key: SecretString, config: CoinApiHttpConfig) -> Result<Self, Error> {
145 if !is_valid_api_key(api_key.expose_secret()) {
146 return Err(Error::Validation(
147 "API key must contain visible ASCII characters".into(),
148 ));
149 }
150
151 let mut base_url = Url::parse(&config.base_url)
152 .map_err(|_| Error::Validation("invalid base URL".into()))?;
153
154 if !matches!(base_url.scheme(), "http" | "https")
155 || base_url.host_str().is_none()
156 || !base_url.username().is_empty()
157 || base_url.password().is_some()
158 || base_url.query().is_some()
159 || base_url.fragment().is_some()
160 {
161 return Err(Error::Validation(
162 "base URL must be HTTP(S) without credentials, query, or fragment".into(),
163 ));
164 }
165
166 if !base_url.path().ends_with('/') {
167 base_url.set_path(&format!("{}/", base_url.path()));
168 }
169
170 if config.max_concurrent_requests == 0
171 || config.max_concurrent_requests > tokio::sync::Semaphore::MAX_PERMITS
172 || config.credit_budget == 0
173 || config.retry.max_elapsed_ms.is_none_or(|ms| ms == 0)
174 || config.retry.operation_timeout_ms == Some(0)
175 {
176 return Err(Error::Validation(
177 "concurrency, credits, elapsed budget, and configured timeout must be positive and bounded"
178 .into(),
179 ));
180 }
181
182 ExponentialBackoff::new(
183 Duration::from_millis(config.retry.initial_delay_ms),
184 Duration::from_millis(config.retry.max_delay_ms),
185 config.retry.backoff_factor,
186 config.retry.jitter_ms,
187 config.retry.immediate_first,
188 )
189 .map_err(|e| Error::Validation(e.to_string()))?;
190
191 let client = build_client(&api_key, &config, "application/json")?;
192 let flat_files_client = build_client(&api_key, &config, "application/xml")?;
193 Ok(Self {
194 api_key,
195 base_url,
196 client,
197 flat_files_client,
198 concurrency: tokio::sync::Semaphore::new(config.max_concurrent_requests),
199 ranges: tokio::sync::Mutex::new(()),
200 retry: RetryManager::new(config.retry),
201 cancellation: CancellationToken::new(),
202 credits_remaining: AtomicU64::new(config.credit_budget),
203 })
204 }
205
206 pub fn cancel(&self) {
208 self.cancellation.cancel();
209 }
210
211 #[must_use]
213 pub fn credits_remaining(&self) -> u64 {
214 self.credits_remaining.load(Ordering::Relaxed)
215 }
216
217 pub async fn download_flat_file(
228 &self,
229 bucket: &str,
230 object_key: &str,
231 filepath: &Path,
232 ) -> Result<(), Error> {
233 let path: Vec<_> = std::iter::once(bucket)
234 .chain(object_key.split('/'))
235 .collect();
236 let url = request_url(&FLAT_FILES_URL, &path)?;
237 self.download_file(url, filepath).await
238 }
239
240 pub async fn get_exchanges(&self, query: &ExchangesQuery) -> Result<Vec<Exchange>, Error> {
247 self.get(&["exchanges"], query, 1, |_| 1).await
248 }
249
250 pub async fn get_symbols(
257 &self,
258 exchange_id: &str,
259 query: &SymbolsQuery,
260 ) -> Result<Vec<Symbol>, Error> {
261 self.get(&["symbols", exchange_id, "active"], query, 1, |_| 1)
262 .await
263 }
264
265 pub async fn request_instruments(
277 &self,
278 exchange_id: &str,
279 query: &SymbolsQuery,
280 price_precisions: &HashMap<InstrumentId, u8>,
281 ts_init: UnixNanos,
282 ) -> Result<Vec<CoinApiInstrument>, Error> {
283 validate_price_precisions(price_precisions)?;
284
285 let symbols = self.get_symbols(exchange_id, query).await?;
286 let instruments = parse_instruments_with_precisions(&symbols, price_precisions, ts_init);
287
288 Ok(instruments)
289 }
290
291 pub async fn get_book(&self, symbol_id: &str, query: &BookQuery) -> Result<Book, Error> {
298 self.get_book_at("orderbooks", symbol_id, query).await
299 }
300
301 pub async fn get_book_l3(&self, symbol_id: &str, query: &BookQuery) -> Result<Book, Error> {
308 self.get_book_at("orderbooks3", symbol_id, query).await
309 }
310
311 async fn get_book_at(
312 &self,
313 family: &str,
314 symbol_id: &str,
315 query: &BookQuery,
316 ) -> Result<Book, Error> {
317 if query
318 .limit_levels
319 .is_some_and(|levels| levels == 0 || levels > i32::MAX as u32)
320 {
321 return Err(Error::Validation(
322 "limit_levels must be a positive int32".into(),
323 ));
324 }
325
326 self.get(&[family, symbol_id, "current"], query, 1, |_| 1)
327 .await
328 }
329
330 pub async fn get_quotes(
337 &self,
338 symbol_id: &str,
339 query: &HistoryQuery,
340 ) -> Result<Vec<Quote>, Error> {
341 query.validate()?;
342 let cost = credits(query.limit);
343 self.get(&["quotes", symbol_id, "history"], query, cost, billed)
344 .await
345 }
346
347 pub async fn get_quotes_range(
354 &self,
355 symbol_id: &str,
356 range: &HistoryRange,
357 ) -> Result<HistoryPages<Quote>, Error> {
358 self.get_range(
359 range,
360 1,
361 |quote: &Quote| quote.time_exchange,
362 |page| async move { self.get_quotes(symbol_id, &page).await },
363 )
364 .await
365 }
366
367 pub async fn get_trades(
374 &self,
375 symbol_id: &str,
376 query: &TradesQuery,
377 ) -> Result<Vec<Trade>, Error> {
378 query.range.validate()?;
379 let cost = credits(query.range.limit);
380 self.get(&["trades", symbol_id, "history"], query, cost, billed)
381 .await
382 }
383
384 pub async fn get_trades_range(
391 &self,
392 symbol_id: &str,
393 range: &HistoryRange,
394 ) -> Result<HistoryPages<Trade>, Error> {
395 self.get_range(
396 range,
397 1,
398 |trade: &Trade| trade.time_exchange,
399 |page| async move {
400 let query = TradesQuery {
401 range: page,
402 include_id: None,
403 };
404
405 self.get_trades(symbol_id, &query).await
406 },
407 )
408 .await
409 }
410
411 pub async fn get_ohlcv(
418 &self,
419 symbol_id: &str,
420 query: &OhlcvQuery,
421 ) -> Result<Vec<Ohlcv>, Error> {
422 query.range.validate()?;
423 if query.period_id.as_ref().trim().is_empty() {
424 return Err(Error::Validation("period_id must not be empty".into()));
425 }
426
427 let cost = credits(query.range.limit);
428 self.get(&["ohlcv", symbol_id, "history"], query, cost, billed)
429 .await
430 }
431
432 pub async fn get_ohlcv_range(
440 &self,
441 symbol_id: &str,
442 period_id: OhlcvPeriod,
443 range: &HistoryRange,
444 ) -> Result<HistoryPages<Ohlcv>, Error> {
445 self.get_range(
446 range,
447 2,
448 |ohlcv: &Ohlcv| ohlcv.time_period_start,
449 |page| async move {
450 let query = OhlcvQuery {
451 period_id,
452 range: page,
453 };
454
455 self.get_ohlcv(symbol_id, &query).await
456 },
457 )
458 .await
459 }
460
461 async fn get_range<T, F, Fut>(
463 &self,
464 range: &HistoryRange,
465 min_page_limit: usize,
466 key: fn(&T) -> Timestamp,
467 mut fetch: F,
468 ) -> Result<HistoryPages<T>, Error>
469 where
470 F: FnMut(HistoryQuery) -> Fut,
471 Fut: Future<Output = Result<Vec<T>, Error>>,
472 {
473 if range.time_start < range.time_end
475 && provider_time(range.time_start)? >= provider_time(range.time_end)?
476 {
477 return Ok(HistoryPages {
478 records: Vec::new(),
479 is_truncated: false,
480 });
481 }
482
483 let _range = self.ranges.lock().await;
484 let mut records = Vec::new();
485 let mut cursor = range.time_start;
486 loop {
487 let remaining = range.limit.map(|limit| limit.get() - records.len());
488 let affordable =
489 usize::try_from(self.credits_remaining().saturating_mul(100)).unwrap_or(usize::MAX);
490 let page_limit = remaining
491 .unwrap_or(usize::MAX)
492 .max(min_page_limit)
493 .min(HISTORY_LIMIT_MAX as usize)
494 .min(affordable);
495
496 if page_limit == 0 {
497 if records.is_empty() {
498 return Err(Error::CreditBudget);
499 }
500
501 return Ok(HistoryPages {
502 records,
503 is_truncated: true,
504 });
505 }
506
507 let page = fetch(HistoryQuery {
508 time_start: cursor,
509 time_end: range.time_end,
510 limit: page_limit as u32,
511 })
512 .await;
513
514 let mut page = match page {
516 Ok(page) => page,
517 Err(Error::CreditBudget) => continue,
518 Err(e) => return Err(e),
519 };
520
521 if page.windows(2).any(|pair| key(&pair[1]) < key(&pair[0])) {
522 return Err(Error::Response(
523 "history page is not in ascending time order".into(),
524 ));
525 }
526
527 let is_full = page.len() >= page_limit;
528 let reaches_end = page
529 .last()
530 .is_some_and(|record| key(record) >= range.time_end);
531
532 page.retain(|record| (cursor..range.time_end).contains(&key(record)));
534 if let Some(remaining) = remaining {
535 page.truncate(remaining);
536 }
537
538 if !is_full || reaches_end || page.is_empty() || remaining == Some(page.len()) {
540 records.extend(page);
541 return Ok(HistoryPages {
542 records,
543 is_truncated: false,
544 });
545 }
546
547 let last = key(&page[page.len() - 1]);
548 let boundary = page.partition_point(|record| key(record) < last);
549
550 if boundary == 0 {
551 if page.len() < page_limit && last > cursor {
553 cursor = last;
554 continue;
555 }
556
557 return Err(Error::Response(format!(
558 "{page_limit} records share timestamp {last}, so paging cannot advance"
559 )));
560 }
561
562 page.truncate(boundary);
563 records.extend(page);
564 cursor = last;
565 }
566 }
567
568 async fn get<T: DeserializeOwned, Q: Serialize>(
571 &self,
572 path: &[&str],
573 query: &Q,
574 cost: u64,
575 billed: fn(&T) -> u64,
576 ) -> Result<T, Error> {
577 let url = request_url(&self.base_url, path)?;
578
579 self.retry
580 .invocation(
581 "CoinAPI GET",
582 || async {
583 let _permit = self
584 .concurrency
585 .acquire()
586 .await
587 .map_err(|_| Error::Validation("request admission closed".into()))?;
588 self.credits_remaining
589 .try_update(Ordering::Relaxed, Ordering::Relaxed, |remaining| {
590 remaining.checked_sub(cost)
591 })
592 .map_err(|_| Error::CreditBudget)?;
593
594 let response = self
595 .client
596 .request_with_params(
597 Method::GET,
598 url.to_string(),
599 Some(query),
600 None,
601 None,
602 None,
603 None,
604 )
605 .await?;
606 let value = self.decode(&response)?;
607 self.credits_remaining
608 .fetch_add(cost.saturating_sub(billed(&value)), Ordering::Relaxed);
609 Ok(value)
610 },
611 Error::is_retryable,
612 Error::Retry,
613 )
614 .retry_delay(&Error::retry_delay)
615 .cancellation_token(&self.cancellation)
616 .execute()
617 .await
618 }
619
620 async fn download_file(&self, url: Url, filepath: &Path) -> Result<(), Error> {
621 self.retry
622 .invocation(
623 "CoinAPI Flat Files GET",
624 || async {
625 let _permit = self
626 .concurrency
627 .acquire()
628 .await
629 .map_err(|_| Error::Validation("request admission closed".into()))?;
630 let (partial, mut file) = PartialDownload::create(filepath).await?;
631 let mut response = self.flat_files_client.get_stream(url.to_string()).await?;
632 if !response.status().is_success() {
633 return Err(Error::Http {
634 status: response.status().as_u16(),
635 retry_after_secs: response
636 .headers()
637 .get("retry-after")
638 .and_then(|value| value.to_str().ok())
639 .and_then(|value| value.parse().ok()),
640 });
641 }
642
643 while let Some(chunk) = response.chunk().await? {
644 file.write_all(&chunk).await?;
645 }
646
647 file.flush().await?;
648 file.sync_all().await?;
649 drop(file);
650 tokio::fs::rename(&partial.path, filepath).await?;
651 Ok(())
652 },
653 Error::is_retryable,
654 Error::Retry,
655 )
656 .retry_delay(&Error::retry_delay)
657 .cancellation_token(&self.cancellation)
658 .execute()
659 .await
660 }
661
662 fn decode<T: DeserializeOwned>(&self, response: &HttpResponse) -> Result<T, Error> {
663 for (key, value) in &response.headers {
664 if key.starts_with("x-credits-") || key == "x-ratelimit-request-cost" {
665 log::debug!("CoinAPI usage {key}={value}");
666 }
667 }
668
669 if !response.status.is_success() {
670 let status = response.status.as_u16();
671 let retry_after_secs = response
672 .headers
673 .get("retry-after")
674 .and_then(|value| value.parse().ok());
675 return Err(match serde_json::from_slice::<VenueError>(&response.body) {
676 Ok(error) => Error::Venue {
677 status,
678 message: error
679 .error
680 .replace(self.api_key.expose_secret(), "<redacted>"),
681 retry_after_secs,
682 },
683 Err(_) => Error::Http {
684 status,
685 retry_after_secs,
686 },
687 });
688 }
689
690 serde_json::from_slice(&response.body).map_err(Error::Parse)
691 }
692}
693
694#[derive(Debug)]
695struct PartialDownload {
696 path: PathBuf,
697}
698
699impl PartialDownload {
700 async fn create(filepath: &Path) -> Result<(Self, tokio::fs::File), Error> {
701 let mut name = filepath
702 .file_name()
703 .ok_or_else(|| Error::Validation("download destination must name a file".into()))?
704 .to_os_string();
705 name.push(format!(".{}.part", UUID4::new()));
706 let path = filepath.with_file_name(name);
707 if let Some(parent) = filepath
708 .parent()
709 .filter(|path| !path.as_os_str().is_empty())
710 {
711 tokio::fs::create_dir_all(parent).await?;
712 }
713
714 let file = std::fs::OpenOptions::new()
715 .write(true)
716 .create_new(true)
717 .open(&path)?;
718 Ok((Self { path }, tokio::fs::File::from_std(file)))
719 }
720}
721
722impl Drop for PartialDownload {
723 fn drop(&mut self) {
724 let _ = std::fs::remove_file(&self.path);
725 }
726}
727
728fn build_client(
729 api_key: &SecretString,
730 config: &CoinApiHttpConfig,
731 accept: &str,
732) -> Result<HttpClient, Error> {
733 HttpClient::builder()
734 .headers(HashMap::from([
735 ("X-CoinAPI-Key".into(), api_key.expose_secret().to_owned()),
736 ("X-Referrer".into(), COINAPI_NAUTILUS_REFERRER.into()),
737 ("Accept".into(), accept.to_owned()),
738 ]))
739 .redirect_policy(HttpRedirectPolicy::Reject)
740 .use_system_proxy(false)
741 .maybe_proxy_url(
742 config
743 .proxy_url
744 .as_ref()
745 .map(|url| url.expose_secret().to_owned()),
746 )
747 .header_keys(vec![
748 "retry-after".into(),
749 "x-credits-used".into(),
750 "x-credits-remaining".into(),
751 "x-credits-limit".into(),
752 "x-ratelimit-request-cost".into(),
753 ])
754 .build()
755 .map_err(Error::from)
756}
757
758fn request_url(base: &Url, path: &[&str]) -> Result<Url, Error> {
759 if path
760 .iter()
761 .any(|part| part.trim().is_empty() || matches!(*part, "." | ".."))
762 {
763 return Err(Error::Validation(
764 "path identifiers must not be empty or dot segments".into(),
765 ));
766 }
767
768 let mut url = base.clone();
769 url.path_segments_mut()
770 .map_err(|()| Error::Validation("base URL cannot contain path segments".into()))?
771 .pop_if_empty()
772 .extend(path);
773 Ok(url)
774}
775
776#[derive(Deserialize)]
777struct VenueError {
778 error: String,
779}
780
781fn credits(points: u32) -> u64 {
782 u64::from(points.div_ceil(100))
783}
784
785#[expect(
787 clippy::ptr_arg,
788 reason = "`get` passes the decoded response type by reference"
789)]
790fn billed<T>(records: &Vec<T>) -> u64 {
791 credits(u32::try_from(records.len()).unwrap_or(u32::MAX)).max(1)
792}
793
794#[cfg(test)]
795#[cfg(not(all(feature = "simulation", madsim)))]
796mod tests {
797 use std::sync::{
798 Arc,
799 atomic::{AtomicUsize, Ordering},
800 };
801
802 use axum::{
803 Router,
804 http::{StatusCode, header::LOCATION},
805 routing::get,
806 serve,
807 };
808 use nautilus_network::retry::RetryError;
809 use parking_lot::Mutex;
810 use rstest::rstest;
811 use tempfile::TempDir;
812 use tokio::{
813 io::{AsyncReadExt, AsyncWriteExt},
814 net::{TcpListener, TcpStream},
815 sync::Notify,
816 };
817
818 use super::*;
819
820 #[rstest]
821 #[case::nested(vec!["coinapi", "T-TRADES", "D-20261001", "name+symbol.csv.gz"], "/coinapi/T-TRADES/D-20261001/name+symbol.csv.gz")]
822 #[case::reserved(vec!["coinapi", "data ?#.csv.gz"], "/coinapi/data%20%3F%23.csv.gz")]
823 fn file_url_preserves_object_segments(#[case] path: Vec<&str>, #[case] expected_path: &str) {
824 let url = request_url(&FLAT_FILES_URL, &path).unwrap();
825
826 assert_eq!(url.scheme(), "https");
827 assert_eq!(url.host_str(), Some("s3.flatfiles.coinapi.io"));
828 assert_eq!(url.path(), expected_path);
829 assert_eq!(url.query(), None);
830 assert_eq!(url.fragment(), None);
831 }
832
833 #[rstest]
834 #[case(vec!["", "data.csv.gz"])]
835 #[case(vec!["coinapi", ""])]
836 #[case(vec!["coinapi", "a", "..", "data.csv.gz"])]
837 #[case(vec!["coinapi", ".", "data.csv.gz"])]
838 fn file_url_rejects_empty_and_dot_segments(#[case] path: Vec<&str>) {
839 let error = request_url(&FLAT_FILES_URL, &path).unwrap_err();
840
841 assert!(
842 matches!(error, Error::Validation(message) if message == "path identifiers must not be empty or dot segments")
843 );
844 }
845
846 #[rstest]
847 #[case::status(false)]
848 #[case::truncated_body(true)]
849 #[tokio::test]
850 async fn download_retries_with_headers_and_preserves_wire_bytes(#[case] truncated: bool) {
851 let directory = TempDir::new().unwrap();
852 let filepath = directory.path().join("nested").join("export.csv.gz");
853 let requests = Arc::new(Mutex::new(Vec::new()));
854 let captured = Arc::clone(&requests);
855 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
856 let addr = listener.local_addr().unwrap();
857 let body: &[u8] = b"\x1f\x8b\x08\x00opaque-compressed-data\x00";
858
859 let peer = tokio::spawn(async move {
860 for attempt in 0..2 {
861 let (mut socket, _) = listener.accept().await.unwrap();
862 let request = read_request(&mut socket).await;
863 captured.lock().push(request);
864
865 if attempt == 0 {
866 let response: &[u8] = if truncated {
867 b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\nshort"
868 } else {
869 b"HTTP/1.1 503 Service Unavailable\r\nRetry-After: 0\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
870 };
871
872 socket.write_all(response).await.unwrap();
873 } else {
874 let head = format!(
875 "HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
876 body.len()
877 );
878 socket.write_all(head.as_bytes()).await.unwrap();
879 socket.write_all(body).await.unwrap();
880 }
881
882 socket.shutdown().await.unwrap();
883 }
884 });
885
886 let client = CoinApiHttpClient::new("test-api-key".into(), config()).unwrap();
887 client
888 .download_file(
889 Url::parse(&format!("http://{addr}/archive.csv.gz")).unwrap(),
890 &filepath,
891 )
892 .await
893 .unwrap();
894 peer.await.unwrap();
895
896 let requests = requests.lock();
897 assert_eq!(requests.len(), 2);
898
899 for request in requests.iter() {
900 assert_eq!(request.lines().next(), Some("GET /archive.csv.gz HTTP/1.1"));
901
902 for (name, expected) in [
903 ("x-coinapi-key", "test-api-key"),
904 ("x-referrer", "nautilus"),
905 ("accept", "application/xml"),
906 ] {
907 let values: Vec<_> = request
908 .lines()
909 .filter_map(|line| line.split_once(':'))
910 .filter(|(key, _)| key.eq_ignore_ascii_case(name))
911 .map(|(_, value)| value.trim())
912 .collect();
913 assert_eq!(values, vec![expected]);
914 }
915 }
916
917 assert_eq!(std::fs::read(&filepath).unwrap(), body);
918 assert_eq!(client.credits_remaining(), 7);
919 assert_eq!(
920 std::fs::read_dir(filepath.parent().unwrap())
921 .unwrap()
922 .count(),
923 1
924 );
925 assert!(!format!("{client:?}").contains("test-api-key"));
926 }
927
928 #[rstest]
929 #[case::redirect(StatusCode::FOUND)]
930 #[case::denied(StatusCode::FORBIDDEN)]
931 #[tokio::test]
932 async fn failed_download_preserves_destination_and_rejects_redirects(
933 #[case] status: StatusCode,
934 ) {
935 let directory = TempDir::new().unwrap();
936 let filepath = directory.path().join("export.csv.gz");
937 std::fs::write(&filepath, b"existing export").unwrap();
938 let target_calls = Arc::new(AtomicUsize::new(0));
939 let counter = Arc::clone(&target_calls);
940 let target = Router::new().route(
941 "/target",
942 get(move || {
943 counter.fetch_add(1, Ordering::SeqCst);
944 async { "must not receive credentials" }
945 }),
946 );
947
948 let target_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
949 let target_addr = target_listener.local_addr().unwrap();
950 let target_server = tokio::spawn(async move {
951 serve(target_listener, target).await.unwrap();
952 });
953
954 let source_calls = Arc::new(AtomicUsize::new(0));
955 let counter = Arc::clone(&source_calls);
956 let source = Router::new().route(
957 "/archive",
958 get(move || {
959 counter.fetch_add(1, Ordering::SeqCst);
960 async move { (status, [(LOCATION, format!("http://{target_addr}/target"))]) }
961 }),
962 );
963
964 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
965 let addr = listener.local_addr().unwrap();
966 let source_server = tokio::spawn(async move { serve(listener, source).await.unwrap() });
967 let client = CoinApiHttpClient::new("test-api-key".into(), config()).unwrap();
968 let error = client
969 .download_file(
970 Url::parse(&format!("http://{addr}/archive")).unwrap(),
971 &filepath,
972 )
973 .await
974 .unwrap_err();
975 source_server.abort();
976 target_server.abort();
977 let _ = source_server.await;
978 let _ = target_server.await;
979
980 let Error::Http {
981 status: actual_status,
982 retry_after_secs,
983 } = error
984 else {
985 panic!("expected HTTP status error, was {error:?}")
986 };
987
988 assert_eq!((actual_status, retry_after_secs), (status.as_u16(), None));
989 assert_eq!(source_calls.load(Ordering::SeqCst), 1);
990 assert_eq!(target_calls.load(Ordering::SeqCst), 0);
991 assert_eq!(std::fs::read(&filepath).unwrap(), b"existing export");
992 assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
993 assert_eq!(client.credits_remaining(), 7);
994 }
995
996 #[cfg(unix)]
997 #[rstest]
998 fn final_write_failure_preserves_destination() {
999 const CHILD: &str = "NAUTILUS_COINAPI_WRITE_LIMIT_CHILD";
1000
1001 if let Some(filepath) = std::env::var_os(CHILD) {
1002 tokio::runtime::Builder::new_current_thread()
1003 .enable_all()
1004 .build()
1005 .unwrap()
1006 .block_on(async {
1007 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1008 let addr = listener.local_addr().unwrap();
1009 let peer = tokio::spawn(async move {
1010 let (mut socket, _) = listener.accept().await.unwrap();
1011 read_request(&mut socket).await;
1012 socket
1013 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1\r\nConnection: close\r\n\r\nx")
1014 .await
1015 .unwrap();
1016 socket.shutdown().await.unwrap();
1017 });
1018
1019 let client = CoinApiHttpClient::new("test-api-key".into(), config()).unwrap();
1020 let result = client
1021 .download_file(
1022 Url::parse(&format!("http://{addr}/archive")).unwrap(),
1023 Path::new(&filepath),
1024 )
1025 .await;
1026 peer.await.unwrap();
1027
1028 assert!(
1029 matches!(result, Err(Error::Io(ref error)) if error.kind() == std::io::ErrorKind::FileTooLarge),
1030 "expected final write failure, was {result:?}"
1031 );
1032 });
1033
1034 return;
1035 }
1036
1037 let directory = TempDir::new().unwrap();
1038 let filepath = directory.path().join("export.csv.gz");
1039 std::fs::write(&filepath, b"existing export").unwrap();
1040
1041 let output = std::process::Command::new("bash")
1043 .args([
1044 "-c",
1045 "trap '' XFSZ; ulimit -f 0; exec \"$@\"",
1046 "coinapi-write-limit",
1047 ])
1048 .arg(std::env::current_exe().unwrap())
1049 .args([
1050 "--exact",
1051 "http::client::tests::final_write_failure_preserves_destination",
1052 "--nocapture",
1053 ])
1054 .env(CHILD, &filepath)
1055 .output()
1056 .unwrap();
1057
1058 assert!(
1059 output.status.success(),
1060 "{}\n{}",
1061 String::from_utf8_lossy(&output.stdout),
1062 String::from_utf8_lossy(&output.stderr)
1063 );
1064 assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
1065 assert_eq!(std::fs::read(&filepath).unwrap(), b"existing export");
1066 assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
1067 }
1068
1069 #[rstest]
1070 #[case::timeout(false)]
1071 #[case::canceled(true)]
1072 #[tokio::test]
1073 async fn interrupted_download_removes_partial_file(#[case] cancel: bool) {
1074 let directory = TempDir::new().unwrap();
1075 let filepath = directory.path().join("export.csv.gz");
1076 let destination = filepath.clone();
1077 let started = Arc::new(Notify::new());
1078 let signal = Arc::clone(&started);
1079 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1080 let addr = listener.local_addr().unwrap();
1081 let peer = tokio::spawn(async move {
1082 let (mut socket, _) = listener.accept().await.unwrap();
1083 read_request(&mut socket).await;
1084 socket
1085 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nx")
1086 .await
1087 .unwrap();
1088 signal.notify_one();
1089 std::future::pending::<()>().await;
1090 });
1091
1092 let mut config = config();
1093 config.retry.max_retries = 0;
1094 config.retry.operation_timeout_ms = Some(if cancel { 2000 } else { 100 });
1095 let client = Arc::new(CoinApiHttpClient::new("test-api-key".into(), config).unwrap());
1096 let transfer_client = Arc::clone(&client);
1097 let transfer = tokio::spawn(async move {
1098 transfer_client
1099 .download_file(
1100 Url::parse(&format!("http://{addr}/archive")).unwrap(),
1101 &destination,
1102 )
1103 .await
1104 });
1105
1106 if cancel {
1107 tokio::time::timeout(Duration::from_secs(2), started.notified())
1108 .await
1109 .unwrap();
1110 client.cancel();
1111 }
1112
1113 let error = transfer.await.unwrap().unwrap_err();
1114 peer.abort();
1115 let _ = peer.await;
1116
1117 let Error::Retry(actual) = error else {
1118 panic!("expected retry control error, was {error:?}")
1119 };
1120
1121 let expected = if cancel {
1122 RetryError::Canceled
1123 } else {
1124 RetryError::OperationTimeout { timeout_ms: 100 }
1125 };
1126
1127 assert_eq!(actual, expected);
1128 assert!(!filepath.exists());
1129 assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0);
1130 assert_eq!(client.credits_remaining(), 7);
1131 }
1132
1133 fn config() -> CoinApiHttpConfig {
1134 CoinApiHttpConfig {
1135 credit_budget: 7,
1136 retry: RetryConfig {
1137 max_retries: 1,
1138 initial_delay_ms: 1,
1139 max_delay_ms: 1,
1140 jitter_ms: 0,
1141 operation_timeout_ms: Some(500),
1142 max_elapsed_ms: Some(2000),
1143 ..RetryConfig::default()
1144 },
1145 ..CoinApiHttpConfig::default()
1146 }
1147 }
1148
1149 async fn read_request(socket: &mut TcpStream) -> String {
1150 let mut request = Vec::new();
1151 while !request.ends_with(b"\r\n\r\n") {
1152 request.push(socket.read_u8().await.unwrap());
1153 }
1154
1155 String::from_utf8(request).unwrap()
1156 }
1157}