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 maximum number of retry attempts for one request.
206    ///
207    /// This applies to server-error retries by default. HTTP 429 retries only
208    /// participate when [`Self::retry_on_429`] is enabled.
209    pub fn max_retries(mut self, max_retries: u32) -> Self {
210        self.retry_config.max_retries = max_retries;
211        self
212    }
213
214    /// Enables or disables automatic retries on HTTP 429 responses.
215    ///
216    /// This setting is disabled by default and affects only 429 responses. It
217    /// does not automatically enable honoring `Retry-After`.
218    pub fn retry_on_429(mut self, retry_on_429: bool) -> Self {
219        self.retry_config.retry_on_429 = retry_on_429;
220        self
221    }
222
223    /// Enables or disables honoring the `Retry-After` response header.
224    ///
225    /// This setting only participates when 429 retries are enabled with
226    /// [`Self::retry_on_429`]. If a 429 response omits `Retry-After`, the
227    /// transport falls back to the configured backoff schedule.
228    pub fn respect_retry_after(mut self, respect_retry_after: bool) -> Self {
229        self.retry_config.respect_retry_after = respect_retry_after;
230        self
231    }
232
233    /// Sets the base retry backoff used by the shared HTTP transport.
234    pub fn base_backoff(mut self, base_backoff: Duration) -> Self {
235        self.retry_config.base_backoff = base_backoff;
236        self
237    }
238
239    /// Sets the maximum retry backoff used by the shared HTTP transport.
240    pub fn max_backoff(mut self, max_backoff: Duration) -> Self {
241        self.retry_config.max_backoff = max_backoff;
242        self
243    }
244
245    /// Adds a bounded random delay on top of each computed retry wait.
246    ///
247    /// Jitter helps concurrent callers avoid retrying in lockstep. The
248    /// transport keeps the added delay within the configured retry budget and
249    /// maximum backoff constraints.
250    pub fn retry_jitter(mut self, retry_jitter: Duration) -> Self {
251        self.retry_config.jitter = Some(retry_jitter);
252        self
253    }
254
255    /// Sets an optional elapsed-time budget for one request's retry loop.
256    ///
257    /// The transport subtracts the request's retry-loop elapsed time from this
258    /// budget before each retry decision. The remaining budget then caps each
259    /// scheduled retry wait, including waits derived from `Retry-After` and
260    /// waits with jitter enabled.
261    pub fn total_retry_budget(mut self, total_retry_budget: Duration) -> Self {
262        self.retry_config.total_retry_budget = Some(total_retry_budget);
263        self
264    }
265
266    /// Loads credentials from `APCA_API_KEY_ID` and `APCA_API_SECRET_KEY`.
267    ///
268    /// If both variables are unset, the builder is left unchanged. If only one
269    /// side is set, this returns [`Error::InvalidConfiguration`].
270    pub fn credentials_from_env(self) -> Result<Self, Error> {
271        self.credentials_from_env_names(env::DEFAULT_API_KEY_ENV, env::DEFAULT_SECRET_KEY_ENV)
272    }
273
274    /// Loads credentials from the provided environment variable names.
275    ///
276    /// If both variables are unset, the builder is left unchanged. If only one
277    /// side is set, this returns [`Error::InvalidConfiguration`].
278    pub fn credentials_from_env_names(
279        mut self,
280        api_key_var: &str,
281        secret_key_var: &str,
282    ) -> Result<Self, Error> {
283        if let Some((api_key, secret_key)) =
284            env::credentials_from_env_names(api_key_var, secret_key_var)?
285        {
286            self.api_key = Some(api_key);
287            self.secret_key = Some(secret_key);
288        }
289
290        Ok(self)
291    }
292
293    /// Sets the maximum number of concurrent in-flight requests.
294    pub fn max_in_flight(mut self, max_in_flight: usize) -> Self {
295        self.max_in_flight = Some(max_in_flight);
296        self
297    }
298
299    /// Validates configuration and builds a [`Client`].
300    ///
301    /// Credentials must be provided as a pair or omitted as a pair.
302    pub fn build(self) -> Result<Client, Error> {
303        if self.retry_config.max_backoff < self.retry_config.base_backoff {
304            return Err(Error::InvalidConfiguration(
305                "max_backoff must be greater than or equal to base_backoff".into(),
306            ));
307        }
308
309        if self.reqwest_client.is_some() && self.timeout.is_some() {
310            return Err(Error::InvalidConfiguration(
311                "reqwest_client owns timeout configuration; remove timeout(...) or configure timeout on the injected reqwest::Client".into(),
312            ));
313        }
314
315        let auth = Auth::new(self.api_key, self.secret_key)?;
316        let base_url = self
317            .base_url
318            .unwrap_or_else(|| "https://data.alpaca.markets".to_string());
319
320        Client::from_parts(
321            auth,
322            base_url,
323            self.timeout,
324            self.reqwest_client,
325            self.observer,
326            self.retry_config,
327            self.max_in_flight,
328        )
329    }
330}