screenshotfreeapi 1.0.0

Official Rust client for ScreenshotFreeAPI — Screenshot-as-a-Service
Documentation
use std::time::Duration;

use reqwest::{header, Method, Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::Serialize;
use tokio::time::sleep;

use crate::error::{Result, ScreenshotFreeAPIError};

/// Thin wrapper around `reqwest::Client` that adds auth-header injection,
/// uniform error mapping, and a retry/back-off loop.
pub(crate) struct HttpClient {
    inner: reqwest::Client,
    base_url: String,
    api_key: String,
}

/// Envelope expected from the API on error responses.
#[derive(serde::Deserialize, Debug)]
struct ApiError {
    #[serde(default)]
    message: String,
    #[serde(default)]
    error: String,
    #[serde(default)]
    code: Option<String>,
}

impl ApiError {
    fn text(&self) -> String {
        if !self.message.is_empty() {
            self.message.clone()
        } else if !self.error.is_empty() {
            self.error.clone()
        } else {
            "no message".to_owned()
        }
    }
}

impl HttpClient {
    /// Construct a new client pointed at `base_url` and authenticated with `api_key`.
    pub fn new(api_key: String, base_url: String) -> Self {
        let inner = reqwest::Client::builder()
            .timeout(Duration::from_secs(90))
            .user_agent(concat!("screenshotfreeapi-rs/", env!("CARGO_PKG_VERSION")))
            .build()
            .expect("failed to build reqwest client");

        Self { inner, base_url, api_key }
    }

    // -----------------------------------------------------------------------
    // Public verbs
    // -----------------------------------------------------------------------

    /// Execute a GET request. If `jwt` is `Some`, it is used instead of the API key.
    pub async fn get<T: DeserializeOwned>(&self, path: &str, jwt: Option<&str>) -> Result<T> {
        self.dispatch(Method::GET, path, None::<&()>, jwt).await
    }

    /// Execute a POST request. If `jwt` is `Some`, it is used instead of the API key.
    pub async fn post<B: Serialize + Send + Sync, T: DeserializeOwned>(
        &self,
        path: &str,
        body: &B,
        jwt: Option<&str>,
    ) -> Result<T> {
        self.dispatch(Method::POST, path, Some(body), jwt).await
    }

    /// Execute a PUT request.
    pub async fn put<B: Serialize + Send + Sync, T: DeserializeOwned>(
        &self,
        path: &str,
        body: &B,
        jwt: Option<&str>,
    ) -> Result<T> {
        self.dispatch(Method::PUT, path, Some(body), jwt).await
    }

    /// Execute a DELETE request (no body).
    pub async fn delete<T: DeserializeOwned>(&self, path: &str, jwt: Option<&str>) -> Result<T> {
        self.dispatch(Method::DELETE, path, None::<&()>, jwt).await
    }

    /// Execute a PATCH request.
    pub async fn patch<B: Serialize + Send + Sync, T: DeserializeOwned>(
        &self,
        path: &str,
        body: &B,
        jwt: Option<&str>,
    ) -> Result<T> {
        self.dispatch(Method::PATCH, path, Some(body), jwt).await
    }

    // -----------------------------------------------------------------------
    // Retry / dispatch core
    // -----------------------------------------------------------------------

    /// Send an HTTP request with up to 3 retry attempts (exponential back-off
    /// at 1 s / 2 s / 4 s).  On HTTP 429 the `Retry-After` header is honoured
    /// instead of the default back-off.
    async fn dispatch<B: Serialize + Send + Sync, T: DeserializeOwned>(
        &self,
        method: Method,
        path: &str,
        body: Option<&B>,
        jwt: Option<&str>,
    ) -> Result<T> {
        let url = format!("{}{}", self.base_url, path);
        let auth_value = match jwt {
            Some(token) => format!("Bearer {}", token),
            None => format!("Bearer {}", self.api_key),
        };

        const MAX_RETRIES: u32 = 3;
        let backoff_ms: [u64; 3] = [1_000, 2_000, 4_000];

        let mut last_err: Option<ScreenshotFreeAPIError> = None;

        for attempt in 0..=MAX_RETRIES {
            // Build a fresh request each iteration (RequestBuilder is not Clone).
            let mut req = self
                .inner
                .request(method.clone(), &url)
                .header(header::AUTHORIZATION, &auth_value)
                .header(header::ACCEPT, "application/json");

            if let Some(b) = body {
                req = req.json(b);
            }

            let response = match req.send().await {
                Ok(r) => r,
                Err(e) => {
                    // Network-level error — retry if not last attempt.
                    if attempt < MAX_RETRIES {
                        sleep(Duration::from_millis(backoff_ms[attempt as usize])).await;
                        last_err = Some(ScreenshotFreeAPIError::Http(e));
                        continue;
                    }
                    return Err(ScreenshotFreeAPIError::Http(e));
                }
            };

            let status = response.status();

            if status.is_success() {
                return response.json::<T>().await.map_err(ScreenshotFreeAPIError::Http);
            }

            // --- Error response handling ---

            // On 429 handle Retry-After and retry.
            if status == StatusCode::TOO_MANY_REQUESTS {
                let retry_after = Self::retry_after_secs(&response);

                // Check whether this is a quota exceeded vs rate-limit.
                let body_text = response.text().await.unwrap_or_default();
                if body_text.contains("quota") || body_text.contains("Quota") {
                    return Err(ScreenshotFreeAPIError::QuotaExceeded);
                }

                if attempt < MAX_RETRIES {
                    sleep(Duration::from_secs(retry_after)).await;
                    last_err = Some(ScreenshotFreeAPIError::RateLimit { retry_after_seconds: retry_after });
                    continue;
                }
                return Err(ScreenshotFreeAPIError::RateLimit { retry_after_seconds: retry_after });
            }

            // On 5xx server errors, retry with back-off.
            if status.is_server_error() && attempt < MAX_RETRIES {
                sleep(Duration::from_millis(backoff_ms[attempt as usize])).await;
                last_err = Some(ScreenshotFreeAPIError::Unexpected {
                    status: status.as_u16(),
                    message: "server error".into(),
                });
                continue;
            }

            // Map remaining 4xx to typed errors.
            return Err(Self::map_error_response(status, response).await);
        }

        Err(last_err.unwrap_or_else(|| ScreenshotFreeAPIError::Unexpected {
            status: 0,
            message: "exceeded retry budget".into(),
        }))
    }

    // -----------------------------------------------------------------------
    // Helpers
    // -----------------------------------------------------------------------

    fn retry_after_secs(resp: &Response) -> u64 {
        resp.headers()
            .get("retry-after")
            .and_then(|v| v.to_str().ok())
            .and_then(|s| s.parse::<u64>().ok())
            .unwrap_or(5)
    }

    async fn map_error_response(status: StatusCode, resp: Response) -> ScreenshotFreeAPIError {
        let body_text = resp.text().await.unwrap_or_default();
        let api_err: ApiError = serde_json::from_str(&body_text).unwrap_or(ApiError {
            message: body_text.clone(),
            error: String::new(),
            code: None,
        });
        let msg = api_err.text();

        match status.as_u16() {
            400 => ScreenshotFreeAPIError::Validation { message: msg },
            401 => ScreenshotFreeAPIError::Authentication { message: msg },
            402 => ScreenshotFreeAPIError::PaymentRequired { message: msg },
            403 => ScreenshotFreeAPIError::Forbidden { message: msg },
            404 => ScreenshotFreeAPIError::NotFound { message: msg },
            _ => ScreenshotFreeAPIError::Unexpected { status: status.as_u16(), message: msg },
        }
    }
}