use std::time::Duration;
use reqwest::{Client, RequestBuilder, StatusCode};
use serde::de::DeserializeOwned;
use tracing::{debug, instrument, warn};
use url::Url;
use crate::{
config::ClientConfig,
error::{FlightRadarError, Result},
};
#[derive(Debug, Clone)]
pub struct FlightRadarClient {
http: Client,
config: ClientConfig,
}
impl FlightRadarClient {
pub fn new(config: ClientConfig) -> Result<Self> {
config.validate()?;
let http = Client::builder()
.cookie_store(true)
.user_agent(config.user_agent.clone())
.timeout(config.timeout)
.build()?;
Ok(Self { http, config })
}
pub fn config(&self) -> &ClientConfig {
&self.config
}
#[instrument(skip(self), fields(path = path))]
pub async fn get_json<T>(&self, path: &str, query: &[(&str, String)]) -> Result<T>
where
T: DeserializeOwned,
{
let url = self.build_url(path)?;
self.execute_json_with_retry(|| self.http.get(url.clone()).query(query))
.await
}
#[instrument(skip(self, form_data), fields(path = path))]
pub async fn post_form_json<T>(&self, path: &str, form_data: &[(&str, String)]) -> Result<T>
where
T: DeserializeOwned,
{
let url = self.build_url(path)?;
self.execute_json_with_retry(|| self.http.post(url.clone()).form(form_data))
.await
}
#[instrument(skip(self), fields(path = path))]
pub async fn get_text(&self, path: &str, query: &[(&str, String)]) -> Result<String> {
let url = self.build_url(path)?;
let response = self
.execute_response_with_retry(|| self.http.get(url.clone()).query(query))
.await?;
Ok(response.text().await?)
}
#[instrument(skip(self), fields(path = path))]
pub async fn get_bytes(&self, path: &str, query: &[(&str, String)]) -> Result<Vec<u8>> {
let url = self.build_url(path)?;
let response = self
.execute_response_with_retry(|| self.http.get(url.clone()).query(query))
.await?;
Ok(response.bytes().await?.to_vec())
}
async fn execute_json_with_retry<T, F>(&self, build_request: F) -> Result<T>
where
T: DeserializeOwned,
F: Fn() -> RequestBuilder,
{
let response = self.execute_response_with_retry(build_request).await?;
Ok(response.json::<T>().await?)
}
async fn execute_response_with_retry<F>(&self, build_request: F) -> Result<reqwest::Response>
where
F: Fn() -> RequestBuilder,
{
let mut delay = Duration::from_millis(100);
for attempt in 0..=self.config.retry_attempts {
let response = build_request().send().await?;
let status = response.status();
if status == StatusCode::from_u16(520).expect("520 is a valid status code") {
let body = response.text().await.unwrap_or_default();
return Err(FlightRadarError::Cloudflare {
status: status.as_u16(),
body,
});
}
if status.is_success() {
return Ok(response);
}
if status.is_server_error() && attempt < self.config.retry_attempts {
warn!(
status = status.as_u16(),
attempt, "request failed, retrying with backoff"
);
tokio::time::sleep(delay).await;
delay = delay.saturating_mul(2);
continue;
}
let body = response.text().await.unwrap_or_default();
return Err(FlightRadarError::UnexpectedStatus {
status: status.as_u16(),
body,
});
}
debug!("request ended without a deterministic branch");
Err(FlightRadarError::InvalidInput("unexpected retry flow"))
}
fn build_url(&self, path: &str) -> Result<Url> {
let normalized = path.trim_start_matches('/');
Ok(self.config.base_url.join(normalized)?)
}
}