Skip to main content

nautilus_coinapi/http/
client.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! CoinAPI REST and Flat Files transport with bounded retries and local admission limits.
17
18use 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
55/// Primary CoinAPI Market Data REST endpoint, including its version prefix.
56pub 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/// Transport configuration for the raw HTTP client.
63///
64/// # Credit Allowance
65///
66/// `credit_budget` is shared by REST requests on this instance:
67///
68/// - Each attempt reserves ceil(requested limit / 100) credits, or one credit
69///   when the request has no record limit.
70/// - A successful historical page keeps ceil(returned records / 100) credits,
71///   with a minimum of one, and refunds the rest of its reservation.
72/// - Failed or canceled attempts keep their full reservation.
73///
74/// This is a local safety limit, not the provider's billing ledger, and it does
75/// not coordinate with other clients. Flat Files downloads use separate provider billing and do
76/// not consume this REST allowance. Both services share the concurrency and retry controls.
77#[derive(Debug, Clone)]
78pub struct CoinApiHttpConfig {
79    /// Base URL including the version prefix; accepts local simulated peers.
80    pub base_url: String,
81    /// Optional explicit proxy URL; ambient system proxies remain disabled.
82    pub proxy_url: Option<SecretString>,
83    /// Maximum simultaneous requests for this client instance.
84    pub max_concurrent_requests: usize,
85    /// Lifetime credit allowance for this instance; see the struct docs for accounting.
86    pub credit_budget: u64,
87    /// Retry policy; the elapsed budget and any configured attempt timeout must be positive.
88    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/// Records gathered from one or more historical pages, in ascending time order.
108///
109/// `is_truncated` reports that the credit budget stopped paging before the window or limit was
110/// exhausted.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct HistoryPages<T> {
113    pub records: Vec<T>,
114    pub is_truncated: bool,
115}
116
117/// Authenticated, data-only CoinAPI HTTP client.
118///
119/// REST requests return raw protocol models. The single-page historical methods fetch exactly one
120/// page; callers must not infer completeness from a full page. The range methods page through a
121/// whole window.
122/// Range requests run serially so each sizes its pages after earlier ranges settle their credits.
123/// Flat Files downloads stream raw bytes to disk without requiring instrument definitions.
124#[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    /// Creates a client using an explicit secret and endpoint configuration.
139    ///
140    /// # Errors
141    ///
142    /// Returns an error if the credentials, endpoint, limits, or transport configuration are
143    /// invalid.
144    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    /// Cancels pending and future requests on this instance.
207    pub fn cancel(&self) {
208        self.cancellation.cancel();
209    }
210
211    /// Returns the remaining conservative local credit allowance.
212    #[must_use]
213    pub fn credits_remaining(&self) -> u64 {
214        self.credits_remaining.load(Ordering::Relaxed)
215    }
216
217    /// Downloads one Flat Files object, replacing the destination only after the stream succeeds.
218    ///
219    /// Authentication is automatic. The destination's parent directories are created as needed.
220    /// Downloads share this client's retry, cancellation, proxy, and concurrency controls, and
221    /// reject redirects. Flat Files usage does not consume the local REST credit allowance.
222    ///
223    /// # Errors
224    ///
225    /// Returns an error if the bucket or object key contains empty or dot segments, the request
226    /// fails, retry controls stop the download, or a file operation fails.
227    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    /// Retrieves exchange metadata with optional filtering.
241    ///
242    /// # Errors
243    ///
244    /// Returns an error if the request fails, the credit budget is exhausted, the transport or HTTP
245    /// request fails, or decoding fails.
246    pub async fn get_exchanges(&self, query: &ExchangesQuery) -> Result<Vec<Exchange>, Error> {
247        self.get(&["exchanges"], query, 1, |_| 1).await
248    }
249
250    /// Retrieves active symbols for one exchange.
251    ///
252    /// # Errors
253    ///
254    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
255    /// request fails, or decoding fails.
256    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    /// Loads native instruments from the active symbols of one exchange.
266    ///
267    /// Only spot, perpetual, and futures symbols are parsed. Symbols of those types that cannot
268    /// be mapped to a native instrument are logged and skipped. An instrument listed in
269    /// `price_precisions` takes that price precision instead of the CoinAPI price resolution.
270    ///
271    /// # Errors
272    ///
273    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
274    /// request fails, or decoding fails. Validation rejects a price precision beyond the Nautilus
275    /// maximum.
276    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    /// Retrieves a current L2 market-by-price book for one symbol.
292    ///
293    /// # Errors
294    ///
295    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
296    /// request fails, or decoding fails.
297    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    /// Retrieves a current L3 market-by-order book for one symbol.
302    ///
303    /// # Errors
304    ///
305    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
306    /// request fails, or decoding fails.
307    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    /// Retrieves one historical quote page with an explicit window and limit.
331    ///
332    /// # Errors
333    ///
334    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
335    /// request fails, or decoding fails.
336    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    /// Retrieves historical quotes across a window, ordered and paged by `time_exchange`.
348    ///
349    /// # Errors
350    ///
351    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
352    /// request fails, decoding fails, or response ordering fails.
353    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    /// Retrieves one historical trade page with an explicit window and limit.
368    ///
369    /// # Errors
370    ///
371    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
372    /// request fails, or decoding fails.
373    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    /// Retrieves historical trades across a window, ordered and paged by `time_exchange`.
385    ///
386    /// # Errors
387    ///
388    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
389    /// request fails, decoding fails, or response ordering fails.
390    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    /// Retrieves one historical OHLCV page for the requested period.
412    ///
413    /// # Errors
414    ///
415    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
416    /// request fails, or decoding fails.
417    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    /// Retrieves OHLCV periods starting within a window, ordered and paged by
433    /// `time_period_start`.
434    ///
435    /// # Errors
436    ///
437    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
438    /// request fails, decoding fails, or response ordering fails.
439    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    // Refetch the trailing timestamp group of a full page to avoid splitting equal-time records
462    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        // A window between two 100 ns grid points holds no CoinAPI records
474        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            // Another request or a failed attempt spent credits after this page was sized
515            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            // CoinAPI can substitute nearby OHLCV periods for an empty window
533            page.retain(|record| (cursor..range.time_end).contains(&key(record)));
534            if let Some(remaining) = remaining {
535                page.truncate(remaining);
536            }
537
538            // A full page emptied by filtering held only fallback periods outside the window
539            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                // Filtering a leading period can leave one timestamp ahead of the cursor
552                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    // Returns the unbilled part of the reservation before the permit is released, so the next
569    // admitted request sees the settled budget
570    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// CoinAPI bills each 100 returned records, and at least one credit per request
786#[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        // Isolate the zero-byte file limit and ignored SIGXFSZ from parallel tests
1042        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}