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