use reqwest::header::{HeaderMap, HeaderValue};
use std::{env, time::Duration};
use thiserror::Error;
use tracing::{debug, warn};
const BROKER_API_BASE: &str = "https://api.alpaca.markets";
const DATA_API_BASE: &str = "https://data.alpaca.markets";
const PAPER_API_BASE: &str = "https://paper-api.alpaca.markets";
const REQUEST_TIMEOUT_SECS: u64 = 30;
const MAX_RETRY_ATTEMPTS: u32 = 3;
const DEFAULT_RATE_LIMIT_DELAY_SECS: u64 = 60;
#[non_exhaustive]
#[derive(Debug, Error)]
pub enum AlpacaRestError {
#[error("environment variable error: {0}")]
EnvVar(String),
#[error("invalid credential: {0}")]
InvalidCredential(String),
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("API error ({status}): {message}")]
Api { status: u16, message: String },
#[error("rate limit exceeded after {attempts} attempts")]
RateLimitExceeded { attempts: u32 },
#[error("invalid response: {0}")]
InvalidResponse(String),
#[error("request body is not cloneable; cannot retry (streaming bodies are unsupported)")]
NotCloneable,
#[error("pagination exceeded the {limit}-page safety limit (attempted page {pages})")]
PaginationLimitExceeded { pages: usize, limit: usize },
#[error("pagination cycle detected: page_token {page_token:?} was already used")]
CyclicPagination { page_token: String },
}
#[derive(Clone)]
pub struct AlpacaRestClient {
http: reqwest::Client,
broker_base: String,
data_base: String,
}
impl std::fmt::Debug for AlpacaRestClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AlpacaRestClient")
.field("broker_base", &self.broker_base)
.field("data_base", &self.data_base)
.finish_non_exhaustive()
}
}
impl AlpacaRestClient {
pub fn new(api_key: &str, api_secret: &str, paper: bool) -> Result<Self, AlpacaRestError> {
let mut headers = HeaderMap::new();
headers.insert(
"APCA-API-KEY-ID",
HeaderValue::from_str(api_key)
.map_err(|e| AlpacaRestError::InvalidCredential(format!("invalid API key: {e}")))?,
);
headers.insert(
"APCA-API-SECRET-KEY",
HeaderValue::from_str(api_secret).map_err(|e| {
AlpacaRestError::InvalidCredential(format!("invalid API secret: {e}"))
})?,
);
let http = reqwest::Client::builder()
.default_headers(headers)
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
.build()?;
let broker_base = if paper {
PAPER_API_BASE.to_string()
} else {
BROKER_API_BASE.to_string()
};
Ok(Self {
http,
broker_base,
data_base: DATA_API_BASE.to_string(),
})
}
pub fn from_env() -> Result<Self, AlpacaRestError> {
let api_key = env::var("ALPACA_API_KEY")
.map_err(|e| AlpacaRestError::EnvVar(format!("ALPACA_API_KEY: {e}")))?;
let api_secret = env::var("ALPACA_SECRET_KEY")
.map_err(|e| AlpacaRestError::EnvVar(format!("ALPACA_SECRET_KEY: {e}")))?;
let paper = env::var("ALPACA_PAPER")
.map(|v| v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
Self::new(&api_key, &api_secret, paper)
}
#[must_use]
pub fn with_base_urls(
mut self,
broker_base: impl Into<String>,
data_base: impl Into<String>,
) -> Self {
self.broker_base = broker_base.into();
self.data_base = data_base.into();
self
}
pub(crate) fn broker_base(&self) -> &str {
&self.broker_base
}
pub(crate) fn data_base(&self) -> &str {
&self.data_base
}
pub(crate) fn get(&self, url: &str) -> reqwest::RequestBuilder {
self.http.get(url)
}
pub(crate) async fn request_with_retry<T>(
&self,
request: reqwest::RequestBuilder,
) -> Result<T, AlpacaRestError>
where
T: serde::de::DeserializeOwned,
{
let mut attempts = 0u32;
loop {
attempts += 1;
let response = match request.try_clone() {
Some(request) => request.send().await?,
None => return Err(AlpacaRestError::NotCloneable),
};
let status = response.status();
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
if attempts >= MAX_RETRY_ATTEMPTS {
return Err(AlpacaRestError::RateLimitExceeded { attempts });
}
let delay = parse_rate_limit_delay(response.headers())
.unwrap_or(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
warn!(
attempt = attempts,
delay_secs = delay.as_secs(),
"Alpaca rate limited, retrying"
);
tokio::time::sleep(delay).await;
continue;
}
if !status.is_success() {
let message = response.text().await.unwrap_or_default();
return Err(AlpacaRestError::Api {
status: status.as_u16(),
message,
});
}
let body = response.text().await?;
debug!(len = body.len(), "Alpaca response received");
return serde_json::from_str(&body)
.map_err(|e| AlpacaRestError::InvalidResponse(format!("JSON parse error: {e}")));
}
}
}
fn parse_rate_limit_delay(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
let header = |name: &str| {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.trim().parse::<u64>().ok())
};
let seconds = match header("retry-after") {
Some(seconds) => seconds,
None => {
let reset = header("x-ratelimit-reset")?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
reset.saturating_sub(now).max(1)
}
};
Some(Duration::from_secs(
seconds.min(DEFAULT_RATE_LIMIT_DELAY_SECS),
))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
use super::{AlpacaRestClient, DEFAULT_RATE_LIMIT_DELAY_SECS, parse_rate_limit_delay};
use reqwest::header::HeaderMap;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
fn headers(pairs: &[(&'static str, String)]) -> HeaderMap {
let mut headers = HeaderMap::new();
for (name, value) in pairs {
headers.insert(*name, value.parse().unwrap());
}
headers
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[test]
fn a_rate_limit_delay_prefers_retry_after_then_reads_the_reset_time() {
let both = headers(&[
("retry-after", "7".to_string()),
("x-ratelimit-reset", (now() + 30).to_string()),
]);
assert_eq!(parse_rate_limit_delay(&both), Some(Duration::from_secs(7)));
let reset =
parse_rate_limit_delay(&headers(&[("x-ratelimit-reset", (now() + 30).to_string())]))
.unwrap();
assert!(
(Duration::from_secs(29)..=Duration::from_secs(30)).contains(&reset),
"{reset:?}"
);
let past = headers(&[("x-ratelimit-reset", (now() - 5).to_string())]);
assert_eq!(parse_rate_limit_delay(&past), Some(Duration::from_secs(1)));
assert_eq!(parse_rate_limit_delay(&HeaderMap::new()), None);
let malformed = headers(&[("retry-after", "soon".to_string())]);
assert_eq!(parse_rate_limit_delay(&malformed), None);
let window = Some(Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS));
for name in ["retry-after", "x-ratelimit-reset"] {
let huge = headers(&[(name, u64::MAX.to_string())]);
assert_eq!(parse_rate_limit_delay(&huge), window, "{name}");
}
}
#[test]
fn client_debug_hides_credentials() {
let client = AlpacaRestClient::new("test-key-id", "test-secret-value", true)
.expect("client construction with ASCII credentials should succeed");
let debug_str = format!("{client:?}");
assert!(!debug_str.contains("test-key-id"));
assert!(!debug_str.contains("test-secret-value"));
}
}