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}