use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use crate::error::{ApiError, ApiErrorBody, ApifyClientError, ApifyClientResult};
const RATE_LIMIT_EXCEEDED_STATUS_CODE: u16 = 429;
const MIN_SERVER_ERROR_STATUS_CODE: u16 = 500;
const MAX_SUCCESS_STATUS_CODE: u16 = 300;
const BACKOFF_FACTOR: u32 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpMethod {
Get,
Post,
Put,
Delete,
Head,
}
impl HttpMethod {
pub fn as_str(&self) -> &'static str {
match self {
HttpMethod::Get => "GET",
HttpMethod::Post => "POST",
HttpMethod::Put => "PUT",
HttpMethod::Delete => "DELETE",
HttpMethod::Head => "HEAD",
}
}
}
#[derive(Debug, Clone)]
pub struct HttpRequest {
pub method: HttpMethod,
pub url: String,
pub headers: HashMap<String, String>,
pub body: Option<Vec<u8>>,
pub timeout: Duration,
}
#[derive(Debug, Clone)]
pub struct HttpResponse {
pub status: u16,
pub headers: HashMap<String, String>,
pub body: Vec<u8>,
}
impl HttpResponse {
pub fn header(&self, name: &str) -> Option<&str> {
let lower = name.to_ascii_lowercase();
self.headers
.iter()
.find(|(k, _)| k.to_ascii_lowercase() == lower)
.map(|(_, v)| v.as_str())
}
}
#[async_trait]
pub trait HttpBackend: Send + Sync + std::fmt::Debug {
async fn send(&self, request: HttpRequest) -> ApifyClientResult<HttpResponse>;
}
#[derive(Debug, Clone)]
pub struct ReqwestBackend {
client: reqwest::Client,
}
impl ReqwestBackend {
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
}
}
pub fn with_client(client: reqwest::Client) -> Self {
Self { client }
}
}
impl Default for ReqwestBackend {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl HttpBackend for ReqwestBackend {
async fn send(&self, request: HttpRequest) -> ApifyClientResult<HttpResponse> {
let method = match request.method {
HttpMethod::Get => reqwest::Method::GET,
HttpMethod::Post => reqwest::Method::POST,
HttpMethod::Put => reqwest::Method::PUT,
HttpMethod::Delete => reqwest::Method::DELETE,
HttpMethod::Head => reqwest::Method::HEAD,
};
let mut builder = self
.client
.request(method, &request.url)
.timeout(request.timeout);
for (key, value) in &request.headers {
builder = builder.header(key, value);
}
if let Some(body) = request.body {
builder = builder.body(body);
}
let response = builder.send().await?;
let status = response.status().as_u16();
let mut headers = HashMap::new();
for (name, value) in response.headers().iter() {
if let Ok(v) = value.to_str() {
headers.insert(name.as_str().to_string(), v.to_string());
}
}
let body = response.bytes().await?.to_vec();
Ok(HttpResponse {
status,
headers,
body,
})
}
}
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_retries: u32,
pub min_delay_between_retries: Duration,
pub timeout: Duration,
}
#[derive(Debug, Clone)]
pub struct HttpClient {
backend: Arc<dyn HttpBackend>,
token: Option<String>,
user_agent: String,
retry: RetryConfig,
}
impl HttpClient {
pub(crate) fn new(
backend: Arc<dyn HttpBackend>,
token: Option<String>,
user_agent: String,
retry: RetryConfig,
) -> Self {
Self {
backend,
token,
user_agent,
retry,
}
}
pub async fn call(&self, mut request: HttpRequest) -> ApifyClientResult<HttpResponse> {
request
.headers
.insert("User-Agent".to_string(), self.user_agent.clone());
if let Some(token) = &self.token {
request
.headers
.insert("Authorization".to_string(), format!("Bearer {token}"));
}
let method_str = request.method.as_str().to_string();
let path = extract_path(&request.url);
let base_timeout = request.timeout;
let mut delay = self.retry.min_delay_between_retries;
let max_attempts = self.retry.max_retries + 1;
for attempt in 1..=max_attempts {
let mut attempt_request = request.clone();
attempt_request.timeout = self.attempt_timeout(base_timeout, attempt);
let outcome = match self.backend.send(attempt_request).await {
Ok(response) => {
if response.status < MAX_SUCCESS_STATUS_CODE {
return Ok(response);
}
let api_error = build_api_error(&response, attempt, &method_str, &path);
let retryable = is_status_retryable(response.status);
(ApifyClientError::from(api_error), retryable)
}
Err(err) => {
let retryable = is_error_retryable(&err);
(err, retryable)
}
};
let (error, retryable) = outcome;
if !retryable || attempt == max_attempts {
return Err(error);
}
sleep(randomized_delay(delay)).await;
delay = (delay * BACKOFF_FACTOR).min(self.retry.timeout);
}
Err(ApifyClientError::InvalidResponse(
"request failed without a recorded error".to_string(),
))
}
fn attempt_timeout(&self, base: Duration, attempt: u32) -> Duration {
let scaled = base.saturating_mul(2u32.saturating_pow(attempt.saturating_sub(1)));
scaled.min(self.retry.timeout)
}
pub(crate) fn user_agent(&self) -> &str {
&self.user_agent
}
pub(crate) fn stream_credentials(&self) -> (Option<String>, String) {
(self.token.clone(), self.user_agent.clone())
}
}
fn extract_path(url: &str) -> Option<String> {
let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
after_scheme
.find('/')
.map(|idx| after_scheme[idx..].to_string())
}
fn is_status_retryable(status: u16) -> bool {
status == RATE_LIMIT_EXCEEDED_STATUS_CODE || status >= MIN_SERVER_ERROR_STATUS_CODE
}
fn is_error_retryable(err: &ApifyClientError) -> bool {
matches!(err, ApifyClientError::Http(_) | ApifyClientError::Timeout)
}
fn build_api_error(
response: &HttpResponse,
attempt: u32,
method: &str,
path: &Option<String>,
) -> ApiError {
let parsed: Option<ApiErrorBody> = serde_json::from_slice(&response.body).ok();
let (error_type, message, data) = match parsed {
Some(body) => (
body.error.error_type,
body.error
.message
.unwrap_or_else(|| format!("Unexpected error with status {}", response.status)),
body.error.data,
),
None => {
let raw = String::from_utf8_lossy(&response.body);
let message = if raw.trim().is_empty() {
format!("Unexpected error with status {}", response.status)
} else {
format!("Unexpected error: {raw}")
};
(None, message, None)
}
};
ApiError {
status_code: response.status,
error_type,
message,
attempt,
http_method: Some(method.to_string()),
path: path.clone(),
data,
}
}
fn randomized_delay(delay: Duration) -> Duration {
let base = delay.as_millis() as u64;
if base == 0 {
return delay;
}
let extra = next_jitter() % base;
Duration::from_millis(base + extra)
}
fn next_jitter() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static STATE: AtomicU64 = AtomicU64::new(0);
let mut current = STATE.load(Ordering::Relaxed);
if current == 0 {
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x9E3779B97F4A7C15)
| 1;
let _ = STATE.compare_exchange(0, seed, Ordering::Relaxed, Ordering::Relaxed);
current = STATE.load(Ordering::Relaxed);
}
let next = current.wrapping_add(0x9E3779B97F4A7C15);
STATE.store(next, Ordering::Relaxed);
let mut z = next;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
z ^ (z >> 31)
}
pub(crate) async fn sleep_public(duration: Duration) {
sleep(duration).await;
}
async fn sleep(duration: Duration) {
tokio::time::sleep(duration).await;
}