flightradarapi 0.1.0

A modern async Rust SDK for the FlightRadar24 API
Documentation
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},
};

/// Low-level HTTP transport client.
///
/// This type handles retries, status mapping, JSON/text/binary decoding,
/// and URL joining. Most users should prefer [`FlightRadarApi`](crate::FlightRadarApi).
#[derive(Debug, Clone)]
pub struct FlightRadarClient {
    http: Client,
    config: ClientConfig,
}

impl FlightRadarClient {
    /// Create a new low-level client from validated configuration.
    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 })
    }

    /// Read active client configuration.
    pub fn config(&self) -> &ClientConfig {
        &self.config
    }

    /// Execute a GET request and deserialize JSON into `T`.
    #[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
    }

    /// Execute a POST form request and deserialize JSON into `T`.
    #[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
    }

    /// Execute a GET request and return plain text body.
    #[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?)
    }

    /// Execute a GET request and return binary body.
    #[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)?)
    }
}