Skip to main content

alpaca_data/
client.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use crate::{
5    auth::Auth,
6    corporate_actions::CorporateActionsClient,
7    crypto::CryptoClient,
8    env,
9    error::Error,
10    news::NewsClient,
11    options::OptionsClient,
12    stocks::StocksClient,
13    transport::{
14        http::HttpClient,
15        observer::{ObserverHandle, TransportObserver},
16        rate_limit::RateLimiter,
17        retry::RetryConfig,
18    },
19};
20
21const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
22
23/// Root async client for Alpaca Market Data HTTP APIs.
24///
25/// Build a client once, then obtain resource clients with [`Self::stocks`],
26/// [`Self::options`], [`Self::crypto`], [`Self::news`], and
27/// [`Self::corporate_actions`].
28///
29/// # Examples
30///
31/// ```no_run
32/// use alpaca_data::Client;
33///
34/// let client = Client::builder().build()?;
35/// # let _ = client;
36/// # Ok::<(), alpaca_data::Error>(())
37/// ```
38#[derive(Clone, Debug)]
39pub struct Client {
40    pub(crate) inner: Arc<Inner>,
41}
42
43#[allow(dead_code)]
44#[derive(Debug)]
45pub(crate) struct Inner {
46    pub(crate) auth: Auth,
47    pub(crate) base_url: String,
48    pub(crate) timeout: Option<Duration>,
49    pub(crate) retry_config: RetryConfig,
50    pub(crate) max_in_flight: Option<usize>,
51    pub(crate) http: HttpClient,
52}
53
54#[derive(Clone, Debug)]
55pub struct ClientBuilder {
56    api_key: Option<String>,
57    secret_key: Option<String>,
58    base_url: Option<String>,
59    timeout: Option<Duration>,
60    reqwest_client: Option<reqwest::Client>,
61    observer: Option<ObserverHandle>,
62    retry_config: RetryConfig,
63    max_in_flight: Option<usize>,
64}
65
66impl Client {
67    /// Builds a client with default runtime settings and no credentials.
68    ///
69    /// This is useful for the currently implemented public crypto endpoints.
70    pub fn new() -> Self {
71        Self::builder()
72            .build()
73            .expect("client builder is infallible during bootstrap")
74    }
75
76    /// Starts a [`ClientBuilder`] for explicit runtime configuration.
77    pub fn builder() -> ClientBuilder {
78        ClientBuilder::default()
79    }
80
81    /// Returns the stocks resource client.
82    pub fn stocks(&self) -> StocksClient {
83        StocksClient::new(self.inner.clone())
84    }
85
86    /// Returns the options resource client.
87    pub fn options(&self) -> OptionsClient {
88        OptionsClient::new(self.inner.clone())
89    }
90
91    /// Returns the crypto resource client.
92    pub fn crypto(&self) -> CryptoClient {
93        CryptoClient::new(self.inner.clone())
94    }
95
96    /// Returns the news resource client.
97    pub fn news(&self) -> NewsClient {
98        NewsClient::new(self.inner.clone())
99    }
100
101    /// Returns the corporate actions resource client.
102    pub fn corporate_actions(&self) -> CorporateActionsClient {
103        CorporateActionsClient::new(self.inner.clone())
104    }
105
106    pub(crate) fn from_parts(
107        auth: Auth,
108        base_url: String,
109        timeout: Option<Duration>,
110        reqwest_client: Option<reqwest::Client>,
111        observer: Option<ObserverHandle>,
112        retry_config: RetryConfig,
113        max_in_flight: Option<usize>,
114    ) -> Result<Self, Error> {
115        let http = match reqwest_client {
116            Some(client) => HttpClient::with_client(
117                client,
118                observer,
119                retry_config.clone(),
120                RateLimiter::new(max_in_flight),
121            ),
122            None => HttpClient::from_timeout(
123                timeout.unwrap_or(DEFAULT_TIMEOUT),
124                observer,
125                retry_config.clone(),
126                RateLimiter::new(max_in_flight),
127            )?,
128        };
129
130        Ok(Self {
131            inner: Arc::new(Inner {
132                auth,
133                base_url,
134                timeout,
135                retry_config,
136                max_in_flight,
137                http,
138            }),
139        })
140    }
141}
142
143impl Default for ClientBuilder {
144    fn default() -> Self {
145        Self {
146            api_key: None,
147            secret_key: None,
148            base_url: None,
149            timeout: None,
150            reqwest_client: None,
151            observer: None,
152            retry_config: RetryConfig::default(),
153            max_in_flight: None,
154        }
155    }
156}
157
158impl ClientBuilder {
159    /// Sets the Alpaca API key.
160    pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
161        self.api_key = Some(api_key.into());
162        self
163    }
164
165    /// Sets the Alpaca API secret key.
166    pub fn secret_key(mut self, secret_key: impl Into<String>) -> Self {
167        self.secret_key = Some(secret_key.into());
168        self
169    }
170
171    /// Overrides the default data API base URL.
172    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
173        self.base_url = Some(base_url.into());
174        self
175    }
176
177    /// Sets the request timeout for the internally constructed `reqwest::Client`.
178    ///
179    /// Building fails if `reqwest_client(...)` is also used because the injected
180    /// client owns its own timeout configuration.
181    pub fn timeout(mut self, timeout: Duration) -> Self {
182        self.timeout = Some(timeout);
183        self
184    }
185
186    /// Injects a preconfigured `reqwest::Client` for advanced transport tuning.
187    ///
188    /// The injected client owns reqwest-level behavior such as connection
189    /// pooling, proxy behavior, default headers, and timeout settings. Build
190    /// validation rejects conflicting builder settings such as `timeout(...)`.
191    pub fn reqwest_client(mut self, reqwest_client: reqwest::Client) -> Self {
192        self.reqwest_client = Some(reqwest_client);
193        self
194    }
195
196    /// Registers an immutable observer for successful transport responses.
197    ///
198    /// Observers receive endpoint metadata only. They cannot change request
199    /// execution or response shaping.
200    pub fn observer(mut self, observer: Arc<dyn TransportObserver>) -> Self {
201        self.observer = Some(ObserverHandle::new(observer));
202        self
203    }
204
205    /// Sets the retry budget for the shared HTTP transport.
206    pub fn max_retries(mut self, max_retries: u32) -> Self {
207        self.retry_config.max_retries = max_retries;
208        self
209    }
210
211    /// Enables or disables automatic retries on HTTP 429 responses.
212    pub fn retry_on_429(mut self, retry_on_429: bool) -> Self {
213        self.retry_config.retry_on_429 = retry_on_429;
214        self
215    }
216
217    /// Enables or disables honoring the `Retry-After` response header.
218    pub fn respect_retry_after(mut self, respect_retry_after: bool) -> Self {
219        self.retry_config.respect_retry_after = respect_retry_after;
220        self
221    }
222
223    /// Sets the base retry backoff used by the shared HTTP transport.
224    pub fn base_backoff(mut self, base_backoff: Duration) -> Self {
225        self.retry_config.base_backoff = base_backoff;
226        self
227    }
228
229    /// Sets the maximum retry backoff used by the shared HTTP transport.
230    pub fn max_backoff(mut self, max_backoff: Duration) -> Self {
231        self.retry_config.max_backoff = max_backoff;
232        self
233    }
234
235    /// Sets an optional jitter window applied to retry waits.
236    pub fn retry_jitter(mut self, retry_jitter: Duration) -> Self {
237        self.retry_config.jitter = Some(retry_jitter);
238        self
239    }
240
241    /// Sets an optional total retry time budget for a request.
242    pub fn total_retry_budget(mut self, total_retry_budget: Duration) -> Self {
243        self.retry_config.total_retry_budget = Some(total_retry_budget);
244        self
245    }
246
247    /// Loads credentials from `APCA_API_KEY_ID` and `APCA_API_SECRET_KEY`.
248    ///
249    /// If both variables are unset, the builder is left unchanged. If only one
250    /// side is set, this returns [`Error::InvalidConfiguration`].
251    pub fn credentials_from_env(self) -> Result<Self, Error> {
252        self.credentials_from_env_names(env::DEFAULT_API_KEY_ENV, env::DEFAULT_SECRET_KEY_ENV)
253    }
254
255    /// Loads credentials from the provided environment variable names.
256    ///
257    /// If both variables are unset, the builder is left unchanged. If only one
258    /// side is set, this returns [`Error::InvalidConfiguration`].
259    pub fn credentials_from_env_names(
260        mut self,
261        api_key_var: &str,
262        secret_key_var: &str,
263    ) -> Result<Self, Error> {
264        if let Some((api_key, secret_key)) =
265            env::credentials_from_env_names(api_key_var, secret_key_var)?
266        {
267            self.api_key = Some(api_key);
268            self.secret_key = Some(secret_key);
269        }
270
271        Ok(self)
272    }
273
274    /// Sets the maximum number of concurrent in-flight requests.
275    pub fn max_in_flight(mut self, max_in_flight: usize) -> Self {
276        self.max_in_flight = Some(max_in_flight);
277        self
278    }
279
280    /// Validates configuration and builds a [`Client`].
281    ///
282    /// Credentials must be provided as a pair or omitted as a pair.
283    pub fn build(self) -> Result<Client, Error> {
284        if self.retry_config.max_backoff < self.retry_config.base_backoff {
285            return Err(Error::InvalidConfiguration(
286                "max_backoff must be greater than or equal to base_backoff".into(),
287            ));
288        }
289
290        if self.reqwest_client.is_some() && self.timeout.is_some() {
291            return Err(Error::InvalidConfiguration(
292                "reqwest_client owns timeout configuration; remove timeout(...) or configure timeout on the injected reqwest::Client".into(),
293            ));
294        }
295
296        let auth = Auth::new(self.api_key, self.secret_key)?;
297        let base_url = self
298            .base_url
299            .unwrap_or_else(|| "https://data.alpaca.markets".to_string());
300
301        Client::from_parts(
302            auth,
303            base_url,
304            self.timeout,
305            self.reqwest_client,
306            self.observer,
307            self.retry_config,
308            self.max_in_flight,
309        )
310    }
311}