Skip to main content

rustrade_data/exchange/alpaca/
rest.rs

1//! Shared Alpaca REST transport.
2//!
3//! A single authenticated [`reqwest::Client`] with rate-limit retry and JSON deserialisation,
4//! shared by every Alpaca REST surface (options contracts/snapshots, corporate actions, …) so the
5//! authentication, retry policy, and error type live in one place instead of being copied per
6//! endpoint family.
7//!
8//! # Authentication
9//!
10//! Requires `ALPACA_API_KEY` and `ALPACA_SECRET_KEY` (and the optional `ALPACA_PAPER`) environment
11//! variables when constructed via [`AlpacaRestClient::from_env`], or explicit credentials via
12//! [`AlpacaRestClient::new`]. Credentials are sent as the `APCA-API-KEY-ID` / `APCA-API-SECRET-KEY`
13//! headers on every request.
14
15use reqwest::header::{HeaderMap, HeaderValue};
16use std::{env, time::Duration};
17use thiserror::Error;
18use tracing::{debug, warn};
19
20/// Base URL for Alpaca Broker API (trading + options contracts).
21const BROKER_API_BASE: &str = "https://api.alpaca.markets";
22
23/// Base URL for Alpaca Data API (market data, corporate actions, options snapshots).
24const DATA_API_BASE: &str = "https://data.alpaca.markets";
25
26/// Paper trading base URL.
27const PAPER_API_BASE: &str = "https://paper-api.alpaca.markets";
28
29/// Default timeout for REST requests.
30const REQUEST_TIMEOUT_SECS: u64 = 30;
31
32/// Maximum retry attempts for rate-limited requests.
33const MAX_RETRY_ATTEMPTS: u32 = 3;
34
35/// Default delay when rate-limited (if no `Retry-After` header).
36const DEFAULT_RATE_LIMIT_DELAY_SECS: u64 = 60;
37
38/// Errors from Alpaca REST API operations.
39///
40/// Shared across every Alpaca REST surface (options, corporate actions, …); the variants are
41/// transport/protocol concerns (auth, HTTP, API status, rate limiting, deserialisation) common to
42/// all of them, so there is no endpoint-specific error type to keep in sync.
43#[non_exhaustive]
44#[derive(Debug, Error)]
45pub enum AlpacaRestError {
46    /// A required environment variable is not set (see [`AlpacaRestClient::from_env`]).
47    #[error("environment variable error: {0}")]
48    EnvVar(String),
49
50    /// A supplied credential cannot be encoded as an HTTP header value (e.g. non-ASCII bytes).
51    #[error("invalid credential: {0}")]
52    InvalidCredential(String),
53
54    /// HTTP client error.
55    #[error("HTTP error: {0}")]
56    Http(#[from] reqwest::Error),
57
58    /// API returned an error response.
59    #[error("API error ({status}): {message}")]
60    Api { status: u16, message: String },
61
62    /// Rate limit exceeded after retries.
63    #[error("rate limit exceeded after {attempts} attempts")]
64    RateLimitExceeded { attempts: u32 },
65
66    /// Invalid response format.
67    #[error("invalid response: {0}")]
68    InvalidResponse(String),
69
70    /// The request could not be cloned for a retry — `reqwest` returns `None` from `try_clone`
71    /// only for streaming request bodies. Never occurs for the GET/query-string requests this
72    /// client issues today, but returned (not panicked) so a future streaming-body caller of the
73    /// shared retry helper recovers instead of aborting the task.
74    #[error("request body is not cloneable; cannot retry (streaming bodies are unsupported)")]
75    NotCloneable,
76
77    /// A paginated fetch exceeded the maximum number of pages before the API reported the end of
78    /// the result set.
79    ///
80    /// Returned as a terminal error on the affected fetch. For a market-data client a silent
81    /// truncation is indistinguishable from a genuinely small result set, so pagination fails
82    /// loudly rather than returning a partial result. Hitting this limit indicates either an
83    /// unexpectedly large query or a server that never signals the final page — inspect the query
84    /// before retrying.
85    #[error("pagination exceeded the {limit}-page safety limit (attempted page {pages})")]
86    PaginationLimitExceeded { pages: usize, limit: usize },
87
88    /// A paginated fetch received a `next_page_token` it had already used as a cursor.
89    ///
90    /// Returned as a terminal error on the affected fetch. A repeated token would otherwise
91    /// re-fetch the same pages until the page cap ran out; detecting the cycle turns silent
92    /// non-termination into an observable failure after a single wasted round-trip. The token is
93    /// server-supplied, so the retained value is truncated to a bounded diagnostic prefix (real
94    /// Alpaca tokens are far shorter than the bound and are retained whole).
95    #[error("pagination cycle detected: page_token {page_token:?} was already used")]
96    CyclicPagination { page_token: String },
97}
98
99/// Authenticated Alpaca REST client.
100///
101/// Holds the configured [`reqwest::Client`] (with auth headers + timeout) plus the broker and data
102/// API base URLs. Endpoint-family wrappers (`AlpacaOptionsClient`, the corporate-action source, …)
103/// build requests against these bases and drive them through a shared retrying request helper.
104#[derive(Clone)]
105pub struct AlpacaRestClient {
106    http: reqwest::Client,
107    broker_base: String,
108    data_base: String,
109}
110
111impl std::fmt::Debug for AlpacaRestClient {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        // Deliberately omits the `reqwest::Client` (and therefore its auth headers) so credentials
114        // never leak through `Debug`.
115        f.debug_struct("AlpacaRestClient")
116            .field("broker_base", &self.broker_base)
117            .field("data_base", &self.data_base)
118            .finish_non_exhaustive()
119    }
120}
121
122impl AlpacaRestClient {
123    /// Create a new client with explicit credentials.
124    ///
125    /// # Arguments
126    ///
127    /// * `api_key` - Alpaca API key
128    /// * `api_secret` - Alpaca API secret
129    /// * `paper` - Use the paper trading broker endpoint if true
130    ///
131    /// # Errors
132    ///
133    /// Returns an error if the HTTP client cannot be built (e.g. non-ASCII credentials that cannot
134    /// be encoded as header values).
135    pub fn new(api_key: &str, api_secret: &str, paper: bool) -> Result<Self, AlpacaRestError> {
136        let mut headers = HeaderMap::new();
137        headers.insert(
138            "APCA-API-KEY-ID",
139            HeaderValue::from_str(api_key)
140                .map_err(|e| AlpacaRestError::InvalidCredential(format!("invalid API key: {e}")))?,
141        );
142        headers.insert(
143            "APCA-API-SECRET-KEY",
144            HeaderValue::from_str(api_secret).map_err(|e| {
145                AlpacaRestError::InvalidCredential(format!("invalid API secret: {e}"))
146            })?,
147        );
148
149        let http = reqwest::Client::builder()
150            .default_headers(headers)
151            .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
152            .build()?;
153
154        let broker_base = if paper {
155            PAPER_API_BASE.to_string()
156        } else {
157            BROKER_API_BASE.to_string()
158        };
159
160        Ok(Self {
161            http,
162            broker_base,
163            data_base: DATA_API_BASE.to_string(),
164        })
165    }
166
167    /// Create a client from environment variables.
168    ///
169    /// Reads:
170    /// - `ALPACA_API_KEY` - API key (required)
171    /// - `ALPACA_SECRET_KEY` - API secret (required)
172    /// - `ALPACA_PAPER` - Set to "true" for paper trading (optional, defaults to false)
173    ///
174    /// # Errors
175    ///
176    /// Returns an error if either required environment variable is missing or the client cannot be
177    /// built.
178    pub fn from_env() -> Result<Self, AlpacaRestError> {
179        let api_key = env::var("ALPACA_API_KEY")
180            .map_err(|e| AlpacaRestError::EnvVar(format!("ALPACA_API_KEY: {e}")))?;
181        let api_secret = env::var("ALPACA_SECRET_KEY")
182            .map_err(|e| AlpacaRestError::EnvVar(format!("ALPACA_SECRET_KEY: {e}")))?;
183        let paper = env::var("ALPACA_PAPER")
184            .map(|v| v.eq_ignore_ascii_case("true"))
185            .unwrap_or(false);
186
187        Self::new(&api_key, &api_secret, paper)
188    }
189
190    /// Override both API base URLs (broker and data).
191    ///
192    /// Points the client at API-compatible endpoints other than the production Alpaca hosts —
193    /// a local mock server in tests, or a proxy/gateway. Credentials, timeouts and retry
194    /// behaviour are unchanged.
195    ///
196    /// Infallible by design: unlike a `next_url`-following client, this client only ever
197    /// requests URLs it builds itself from these bases (pagination advances via an opaque
198    /// `page_token` query parameter, never a server-supplied URL), so there is no trusted
199    /// origin to derive — and nothing to fail on — at construction.
200    #[must_use]
201    pub fn with_base_urls(
202        mut self,
203        broker_base: impl Into<String>,
204        data_base: impl Into<String>,
205    ) -> Self {
206        self.broker_base = broker_base.into();
207        self.data_base = data_base.into();
208        self
209    }
210
211    /// The broker API base URL (`https://api.alpaca.markets`, or the paper endpoint).
212    pub(crate) fn broker_base(&self) -> &str {
213        &self.broker_base
214    }
215
216    /// The data API base URL (`https://data.alpaca.markets`).
217    pub(crate) fn data_base(&self) -> &str {
218        &self.data_base
219    }
220
221    /// Begin a `GET` request against `url`, pre-authenticated with the client's default headers.
222    pub(crate) fn get(&self, url: &str) -> reqwest::RequestBuilder {
223        self.http.get(url)
224    }
225
226    /// Execute a request, retrying on HTTP 429 (rate limited), then deserialise the JSON body.
227    ///
228    /// The request must be cloneable (true for the query-string GET requests used here) so it can
229    /// be re-sent across retries.
230    ///
231    /// # Errors
232    /// Returns [`AlpacaRestError::NotCloneable`] if the request carries a streaming body (`reqwest`
233    /// cannot clone it for a retry). This never happens for the GET requests this client issues, but
234    /// is surfaced as a recoverable `Err` — consistent with every other failure mode of this shared
235    /// helper — rather than a panic, so a future streaming-body caller can handle it.
236    pub(crate) async fn request_with_retry<T>(
237        &self,
238        request: reqwest::RequestBuilder,
239    ) -> Result<T, AlpacaRestError>
240    where
241        T: serde::de::DeserializeOwned,
242    {
243        let mut attempts = 0u32;
244
245        loop {
246            attempts += 1;
247
248            // `try_clone` returns `None` only for streaming request bodies. Every current caller
249            // passes a GET/query-string request (no body), so this always clones — but return a
250            // typed `Err` rather than panic: this shared retry helper already surfaces every other
251            // failure (rate limit, HTTP status, deserialisation) as an `AlpacaRestError`, and the
252            // function returns `Result`, so an added error branch is free and lets a future
253            // streaming-body caller recover instead of aborting the task.
254            let response = match request.try_clone() {
255                Some(request) => request.send().await?,
256                None => return Err(AlpacaRestError::NotCloneable),
257            };
258
259            let status = response.status();
260
261            if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
262                if attempts >= MAX_RETRY_ATTEMPTS {
263                    return Err(AlpacaRestError::RateLimitExceeded { attempts });
264                }
265
266                let delay = parse_retry_after(response.headers())
267                    .unwrap_or(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
268
269                warn!(
270                    attempt = attempts,
271                    delay_secs = delay.as_secs(),
272                    "Alpaca rate limited, retrying"
273                );
274                tokio::time::sleep(delay).await;
275                continue;
276            }
277
278            if !status.is_success() {
279                let message = response.text().await.unwrap_or_default();
280                return Err(AlpacaRestError::Api {
281                    status: status.as_u16(),
282                    message,
283                });
284            }
285
286            let body = response.text().await?;
287            debug!(len = body.len(), "Alpaca response received");
288
289            return serde_json::from_str(&body)
290                .map_err(|e| AlpacaRestError::InvalidResponse(format!("JSON parse error: {e}")));
291        }
292    }
293}
294
295/// Parse the `Retry-After` header (delay in seconds).
296///
297/// `x-ratelimit-reset` is intentionally not used as a fallback because it carries a Unix epoch
298/// timestamp, not a duration; mis-interpreting it would produce sleeps measured in years.
299fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
300    headers
301        .get("retry-after")
302        .and_then(|v| v.to_str().ok())
303        .and_then(|s| s.parse::<u64>().ok())
304        .map(Duration::from_secs)
305}
306
307#[cfg(test)]
308#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
309mod tests {
310    use super::AlpacaRestClient;
311
312    #[test]
313    fn client_debug_hides_credentials() {
314        let client = AlpacaRestClient::new("test-key-id", "test-secret-value", true)
315            .expect("client construction with ASCII credentials should succeed");
316        let debug_str = format!("{client:?}");
317
318        assert!(!debug_str.contains("test-key-id"));
319        assert!(!debug_str.contains("test-secret-value"));
320    }
321}