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}