Skip to main content

fmp_rust_sdk/
client.rs

1use std::{
2    fmt,
3    sync::Arc,
4    time::{Duration, SystemTime},
5};
6
7use reqwest::{
8    StatusCode,
9    header::{HeaderMap, HeaderValue, RETRY_AFTER},
10};
11use serde::{Deserialize, Serialize, de::DeserializeOwned};
12use url::Url;
13
14use crate::{ApiErrorKind, Error, RequestContext, Result, Subscription, error::JsonDecodeError};
15
16/// Bounded retries for transient transport errors, HTTP 408/429, and HTTP 5xx.
17/// Authentication, access restrictions, and invalid schemas are never retried.
18#[derive(Clone, Debug)]
19pub struct RetryPolicy {
20    pub max_retries: u32,
21    pub initial_delay: Duration,
22    /// Maximum exponential delay and maximum acceptable server Retry-After.
23    /// A larger Retry-After returns the provider error immediately.
24    pub max_delay: Duration,
25}
26
27impl Default for RetryPolicy {
28    fn default() -> Self {
29        Self {
30            max_retries: 2,
31            initial_delay: Duration::from_millis(250),
32            max_delay: Duration::from_secs(30),
33        }
34    }
35}
36
37/// HTTP transport configuration. Override the base URL for a gateway or a local
38/// protocol test; unencrypted HTTP is accepted only on loopback hosts.
39#[derive(Clone, Debug)]
40pub struct ClientConfig {
41    pub base_url: Url,
42    pub timeout: Duration,
43    pub retry: RetryPolicy,
44}
45
46impl Default for ClientConfig {
47    fn default() -> Self {
48        Self {
49            base_url: Url::parse("https://financialmodelingprep.com/stable/")
50                .expect("the built-in FMP URL is valid"),
51            timeout: Duration::from_secs(30),
52            retry: RetryPolicy::default(),
53        }
54    }
55}
56
57/// Reusable, asynchronous FMP connector. Clones share the connection pool.
58/// API keys travel in a sensitive header, and redirects are rejected.
59#[derive(Clone)]
60pub struct Client {
61    http: reqwest::Client,
62    api_key: Arc<str>,
63    subscription: Subscription,
64    config: ClientConfig,
65}
66
67impl fmt::Debug for Client {
68    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
69        formatter
70            .debug_struct("Client")
71            .field("api_key", &"[REDACTED]")
72            .field("subscription", &self.subscription)
73            .field("config", &self.config)
74            .finish_non_exhaustive()
75    }
76}
77
78impl Client {
79    /// Creates a client for the Stable API with a 30-second timeout and two
80    /// transient-error retries. The supplied subscription is user configuration.
81    pub fn new(api_key: &str, subscription: Subscription) -> Result<Self> {
82        Self::with_config(api_key, subscription, ClientConfig::default())
83    }
84
85    /// Creates a client with explicit transport settings.
86    pub fn with_config(
87        api_key: &str,
88        subscription: Subscription,
89        config: ClientConfig,
90    ) -> Result<Self> {
91        validate_config(&config)?;
92        if api_key.trim().is_empty() {
93            return Err(configuration("the FMP API key must not be empty"));
94        }
95        let mut key = HeaderValue::from_str(api_key).map_err(|_| {
96            configuration("the FMP API key contains invalid HTTP header characters")
97        })?;
98        key.set_sensitive(true);
99        let mut headers = HeaderMap::new();
100        headers.insert("apikey", key);
101        let http = reqwest::Client::builder()
102            .default_headers(headers)
103            .timeout(config.timeout)
104            .redirect(reqwest::redirect::Policy::none())
105            .retry(reqwest::retry::never())
106            .user_agent(concat!(
107                env!("CARGO_PKG_NAME"),
108                "/",
109                env!("CARGO_PKG_VERSION")
110            ))
111            .build()
112            .map_err(|error| {
113                configuration(&format!(
114                    "cannot initialize HTTP transport: {}",
115                    error.without_url()
116                ))
117            })?;
118        Ok(Self {
119            http,
120            api_key: Arc::from(api_key),
121            subscription,
122            config,
123        })
124    }
125
126    /// Returns the configured plan and cluster grants. They are not server-verified.
127    pub const fn subscription(&self) -> &Subscription {
128        &self.subscription
129    }
130
131    #[cfg(feature = "websocket")]
132    pub(crate) fn api_key(&self) -> &str {
133        &self.api_key
134    }
135
136    /// Requests any Stable JSON endpoint and validates its response against `T`.
137    /// Define a `Deserialize` model for endpoints without a named SDK wrapper.
138    /// Paths must be relative (e.g. `"quote"`); serialize query parameters through
139    /// `params`, never append them to the path. An API error inside HTTP 200 is
140    /// returned as an error even if `T` could deserialize that object.
141    pub async fn get<T, Q>(&self, path: &str, params: &Q) -> Result<T>
142    where
143        T: DeserializeOwned,
144        Q: Serialize + ?Sized,
145    {
146        let response = self.execute(path, params).await?;
147        serde_json::from_slice(&response.body).map_err(|source| Error::Decode {
148            request: response.request,
149            status: response.status,
150            body: self.redact(&String::from_utf8_lossy(&response.body)),
151            source: JsonDecodeError {
152                message: self.redact(&source.to_string()),
153                line: source.line(),
154                column: source.column(),
155            },
156        })
157    }
158
159    /// Requests a text endpoint, including FMP CSV bulk datasets. Provider errors
160    /// are still checked before returning the UTF-8 text.
161    pub async fn get_text<Q>(&self, path: &str, params: &Q) -> Result<String>
162    where
163        Q: Serialize + ?Sized,
164    {
165        let response = self.execute(path, params).await?;
166        String::from_utf8(response.body).map_err(|source| Error::Encoding {
167            request: response.request,
168            status: response.status,
169            source: source.utf8_error(),
170        })
171    }
172
173    /// Requests a binary endpoint, such as financial reports in XLSX format.
174    pub async fn get_bytes<Q>(&self, path: &str, params: &Q) -> Result<Vec<u8>>
175    where
176        Q: Serialize + ?Sized,
177    {
178        Ok(self.execute(path, params).await?.body)
179    }
180
181    async fn execute<Q>(&self, path: &str, params: &Q) -> Result<ResponseData>
182    where
183        Q: Serialize + ?Sized,
184    {
185        validate_path(path)?;
186        let url =
187            self.config.base_url.join(path).map_err(|error| {
188                configuration(&format!("invalid endpoint path {path:?}: {error}"))
189            })?;
190        let context = RequestContext {
191            url: self.redact(url.as_str()),
192        };
193        let request =
194            self.http
195                .get(url)
196                .query(params)
197                .build()
198                .map_err(|source| Error::Request {
199                    request: context,
200                    source: source.without_url(),
201                })?;
202        if request
203            .url()
204            .query_pairs()
205            .any(|(name, _)| is_credential_param(&name))
206        {
207            return Err(configuration(
208                "query parameters must not contain an API key or authorization; the client's API key is sent in a header",
209            ));
210        }
211        let context = RequestContext {
212            url: self.redact(request.url().as_str()),
213        };
214        for attempt in 0..=self.config.retry.max_retries {
215            let outgoing = request
216                .try_clone()
217                .ok_or_else(|| configuration("the GET request cannot be cloned for retry"))?;
218            let result = self.send(outgoing, &context).await;
219            match result {
220                Ok(response) => return Ok(response),
221                Err(error) => {
222                    let delay = retry_delay(&error, attempt, &self.config.retry);
223                    if let Some(delay) = delay {
224                        tracing::warn!(request = %context, attempt = attempt + 1, delay_ms = delay.as_millis(), error = %error, "FMP request failed; retrying");
225                        tokio::time::sleep(delay).await;
226                    } else {
227                        return Err(error);
228                    }
229                }
230            }
231        }
232        unreachable!("the final retry always returns its result")
233    }
234
235    async fn send(
236        &self,
237        request: reqwest::Request,
238        context: &RequestContext,
239    ) -> Result<ResponseData> {
240        let response = self
241            .http
242            .execute(request)
243            .await
244            .map_err(|source| Error::Transport {
245                request: context.clone(),
246                source: source.without_url(),
247            })?;
248        let status = response.status();
249        let retry_after = parse_retry_after(response.headers(), SystemTime::now());
250        let body = response
251            .bytes()
252            .await
253            .map_err(|source| Error::ResponseBody {
254                request: context.clone(),
255                status: status.as_u16(),
256                source: source.without_url(),
257            })?
258            .to_vec();
259        let retry_after = retry_after.map_err(|reason| Error::ResponseHeader {
260            request: context.clone(),
261            status: status.as_u16(),
262            header: "Retry-After",
263            reason: self.redact(&reason),
264            body: self.redact(&String::from_utf8_lossy(&body)),
265        })?;
266        if !status.is_success() {
267            return Err(self.api_failure(
268                context,
269                status,
270                &body,
271                &String::from_utf8_lossy(&body),
272                retry_after,
273            ));
274        }
275        let provider_error = parse_provider_error(&body).map_err(|source| Error::Decode {
276            request: context.clone(),
277            status: status.as_u16(),
278            body: self.redact(&String::from_utf8_lossy(&body)),
279            source: JsonDecodeError {
280                message: self.redact(&source.to_string()),
281                line: source.line(),
282                column: source.column(),
283            },
284        })?;
285        if let Some(provider_error) = provider_error {
286            return Err(self.api_failure(
287                context,
288                status,
289                &body,
290                &provider_error.message,
291                retry_after,
292            ));
293        }
294        Ok(ResponseData {
295            request: context.clone(),
296            status: status.as_u16(),
297            body,
298        })
299    }
300
301    fn redact(&self, value: &str) -> String {
302        crate::credentials::redact(value, &self.api_key)
303    }
304
305    fn api_failure(
306        &self,
307        request: &RequestContext,
308        status: StatusCode,
309        body: &[u8],
310        message: &str,
311        retry_after: Option<Duration>,
312    ) -> Error {
313        Error::Api {
314            request: request.clone(),
315            kind: classify_error(status, message),
316            status: status.as_u16(),
317            body: self.redact(&String::from_utf8_lossy(body)),
318            retry_after,
319        }
320    }
321}
322
323struct ResponseData {
324    request: RequestContext,
325    status: u16,
326    body: Vec<u8>,
327}
328
329#[derive(Deserialize)]
330struct ProviderError {
331    #[serde(rename = "Error Message", alias = "error", alias = "Error")]
332    message: String,
333}
334
335#[derive(Deserialize)]
336struct ErrorEnvelopeMarker {
337    #[serde(
338        rename = "Error Message",
339        alias = "error",
340        alias = "Error",
341        default,
342        deserialize_with = "present_error_field"
343    )]
344    present: bool,
345}
346
347fn present_error_field<'de, D>(deserializer: D) -> std::result::Result<bool, D::Error>
348where
349    D: serde::Deserializer<'de>,
350{
351    // Only the field's presence is inspected here; the next pass validates its
352    // required String schema and reports null/numeric/malformed errors explicitly.
353    serde::de::IgnoredAny::deserialize(deserializer)?;
354    Ok(true)
355}
356
357fn parse_provider_error(
358    body: &[u8],
359) -> std::result::Result<Option<ProviderError>, serde_json::Error> {
360    if body.iter().find(|byte| !byte.is_ascii_whitespace()) != Some(&b'{') {
361        return Ok(None);
362    }
363    let marker: ErrorEnvelopeMarker = serde_json::from_slice(body)?;
364    if marker.present {
365        serde_json::from_slice(body).map(Some)
366    } else {
367        Ok(None)
368    }
369}
370
371fn configuration(reason: &str) -> Error {
372    Error::Configuration {
373        reason: reason.to_owned(),
374    }
375}
376
377fn validate_config(config: &ClientConfig) -> Result<()> {
378    let url = &config.base_url;
379    let loopback = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"));
380    if url.scheme() != "https" && !(url.scheme() == "http" && loopback) {
381        return Err(configuration(
382            "base_url must use HTTPS, or HTTP on a loopback host",
383        ));
384    }
385    if !url.username().is_empty()
386        || url.password().is_some()
387        || url.query().is_some()
388        || url.fragment().is_some()
389        || !url.path().ends_with('/')
390    {
391        return Err(configuration(
392            "base_url must end with '/' and must not contain credentials, query parameters, or a fragment",
393        ));
394    }
395    if config.timeout.is_zero() {
396        return Err(configuration("request timeout must be greater than zero"));
397    }
398    if config.retry.max_retries > 10 {
399        return Err(configuration("max_retries must be at most 10"));
400    }
401    if config.retry.max_retries > 0
402        && (config.retry.initial_delay.is_zero()
403            || config.retry.max_delay < config.retry.initial_delay)
404    {
405        return Err(configuration(
406            "retry delays must be nonzero and max_delay must be at least initial_delay",
407        ));
408    }
409    Ok(())
410}
411
412fn validate_path(path: &str) -> Result<()> {
413    if path.is_empty()
414        || path
415            .split('/')
416            .any(|part| part.is_empty() || part == "." || part == "..")
417        || !path
418            .bytes()
419            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_'))
420    {
421        return Err(configuration(
422            "endpoint path must contain only relative nonempty alphanumeric, '-', or '_' segments; put query parameters in params",
423        ));
424    }
425    Ok(())
426}
427
428fn is_credential_param(name: &str) -> bool {
429    let normalized = name.to_ascii_lowercase().replace(['_', '-'], "");
430    matches!(
431        normalized.as_str(),
432        "apikey" | "authorization" | "accesskey" | "accesstoken"
433    )
434}
435
436fn classify_error(status: StatusCode, message: &str) -> ApiErrorKind {
437    let lower = message.to_ascii_lowercase();
438    if status == StatusCode::UNAUTHORIZED
439        || lower.contains("invalid api key")
440        || lower.contains("invalid apikey")
441    {
442        ApiErrorKind::Authentication
443    } else if status == StatusCode::TOO_MANY_REQUESTS
444        || lower.contains("limit reach")
445        || lower.contains("rate limit")
446        || lower.contains("bandwidth limit")
447    {
448        ApiErrorKind::RateLimited
449    } else if lower.contains("premium query")
450        || lower.contains("restricted endpoint")
451        || lower.contains("upgrade your")
452        || lower.contains("subscription required")
453        || lower.contains("not available under your current subscription")
454        || lower.contains("not available for your subscription")
455    {
456        ApiErrorKind::PlanRestricted
457    } else if !status.is_success() {
458        ApiErrorKind::HttpStatus
459    } else {
460        ApiErrorKind::Provider
461    }
462}
463
464fn parse_retry_after(
465    headers: &HeaderMap,
466    now: SystemTime,
467) -> std::result::Result<Option<Duration>, String> {
468    let Some(header) = headers.get(RETRY_AFTER) else {
469        return Ok(None);
470    };
471    let text = header
472        .to_str()
473        .map_err(|error| format!("invalid header encoding: {error}"))?;
474    if let Ok(seconds) = text.parse::<u64>() {
475        return Ok(Some(Duration::from_secs(seconds)));
476    }
477    let date = httpdate::parse_http_date(text)
478        .map_err(|error| format!("invalid value {text:?}: {error}"))?;
479    Ok(Some(date.duration_since(now).unwrap_or(Duration::ZERO)))
480}
481
482fn retry_delay(error: &Error, attempt: u32, policy: &RetryPolicy) -> Option<Duration> {
483    if attempt >= policy.max_retries {
484        return None;
485    }
486    let retry_after = match error {
487        Error::Api {
488            kind: ApiErrorKind::Authentication | ApiErrorKind::PlanRestricted,
489            ..
490        } => return None,
491        Error::Transport { source, .. } if source.is_connect() || source.is_timeout() => None,
492        Error::ResponseBody { .. } => None,
493        Error::Api {
494            status,
495            kind,
496            retry_after,
497            ..
498        } if *status == 408
499            || *kind == ApiErrorKind::RateLimited
500            || (500..=599).contains(status) =>
501        {
502            *retry_after
503        }
504        _ => return None,
505    };
506    match retry_after {
507        Some(delay) if delay <= policy.max_delay => Some(delay),
508        Some(_) => None,
509        None => Some(
510            policy
511                .initial_delay
512                .saturating_mul(2_u32.saturating_pow(attempt))
513                .min(policy.max_delay),
514        ),
515    }
516}