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#[derive(Clone, Debug)]
19pub struct RetryPolicy {
20 pub max_retries: u32,
21 pub initial_delay: Duration,
22 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#[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#[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 pub fn new(api_key: &str, subscription: Subscription) -> Result<Self> {
82 Self::with_config(api_key, subscription, ClientConfig::default())
83 }
84
85 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 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 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 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 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 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}