Skip to main content

apify_client/
http_client.rs

1//! The HTTP layer of the client.
2//!
3//! The [`HttpBackend`] trait defines the minimal contract for sending a single HTTP
4//! request and receiving a response. It is the *replaceable component* of the client:
5//! the default implementation [`ReqwestBackend`] uses [`reqwest`], but a custom
6//! backend (e.g. for testing or for a different runtime) can be plugged in via
7//! [`ApifyClientBuilder::http_backend`](crate::ApifyClientBuilder::http_backend).
8//!
9//! [`HttpClient`] wraps a backend and adds the cross-cutting concerns shared by every
10//! endpoint: authentication, the `User-Agent` header, query-parameter serialization,
11//! timeouts and retries with exponential backoff (mirroring the JavaScript and Python
12//! reference clients).
13
14use std::collections::HashMap;
15use std::sync::Arc;
16use std::time::Duration;
17
18use async_trait::async_trait;
19
20use crate::error::{ApiError, ApiErrorBody, ApifyClientError, ApifyClientResult};
21
22/// HTTP status code returned by the API when the per-resource rate limit is exceeded.
23const RATE_LIMIT_EXCEEDED_STATUS_CODE: u16 = 429;
24/// Statuses `>= 500` are considered retryable internal server errors.
25const MIN_SERVER_ERROR_STATUS_CODE: u16 = 500;
26/// Responses with status `< 300` are treated as success.
27const MAX_SUCCESS_STATUS_CODE: u16 = 300;
28/// Multiplier applied to the inter-retry delay after each attempt (exponential backoff).
29/// Matches the reference client's `async-retry` default factor of 2.
30const BACKOFF_FACTOR: u32 = 2;
31
32/// HTTP method of a request.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum HttpMethod {
35    /// HTTP `GET`.
36    Get,
37    /// HTTP `POST`.
38    Post,
39    /// HTTP `PUT`.
40    Put,
41    /// HTTP `DELETE`.
42    Delete,
43    /// HTTP `HEAD`.
44    Head,
45}
46
47impl HttpMethod {
48    /// Returns the uppercase string representation, e.g. `"GET"`.
49    pub fn as_str(&self) -> &'static str {
50        match self {
51            HttpMethod::Get => "GET",
52            HttpMethod::Post => "POST",
53            HttpMethod::Put => "PUT",
54            HttpMethod::Delete => "DELETE",
55            HttpMethod::Head => "HEAD",
56        }
57    }
58}
59
60/// A fully-resolved HTTP request, ready to be sent by an [`HttpBackend`].
61///
62/// All cross-cutting concerns (auth header, user agent, retry policy) are applied by
63/// [`HttpClient`] before the request reaches the backend.
64#[derive(Debug, Clone)]
65pub struct HttpRequest {
66    /// The HTTP method.
67    pub method: HttpMethod,
68    /// The fully-qualified request URL (including query string).
69    pub url: String,
70    /// Request headers.
71    pub headers: HashMap<String, String>,
72    /// Raw request body bytes (already serialized).
73    pub body: Option<Vec<u8>>,
74    /// Per-request timeout. The backend should abort the request after this duration.
75    pub timeout: Duration,
76}
77
78/// An HTTP response returned by an [`HttpBackend`].
79#[derive(Debug, Clone)]
80pub struct HttpResponse {
81    /// HTTP status code.
82    pub status: u16,
83    /// Response headers.
84    pub headers: HashMap<String, String>,
85    /// Raw response body bytes.
86    pub body: Vec<u8>,
87}
88
89impl HttpResponse {
90    /// Returns the value of a response header (case-insensitive lookup).
91    pub fn header(&self, name: &str) -> Option<&str> {
92        let lower = name.to_ascii_lowercase();
93        self.headers
94            .iter()
95            .find(|(k, _)| k.to_ascii_lowercase() == lower)
96            .map(|(_, v)| v.as_str())
97    }
98}
99
100/// The replaceable transport contract.
101///
102/// Implementors are responsible only for sending a single request and returning the
103/// raw response. Retries, authentication and serialization are handled by
104/// [`HttpClient`], so a backend only needs to perform one network round-trip.
105#[async_trait]
106pub trait HttpBackend: Send + Sync + std::fmt::Debug {
107    /// Sends a single HTTP request and returns the response.
108    ///
109    /// Network-level failures (connection refused, DNS, timeout) should be returned as
110    /// [`ApifyClientError::Http`] or [`ApifyClientError::Timeout`]. A non-2xx HTTP
111    /// status is *not* an error at this layer — return it as a normal [`HttpResponse`].
112    async fn send(&self, request: HttpRequest) -> ApifyClientResult<HttpResponse>;
113}
114
115/// The default [`HttpBackend`] implementation, backed by [`reqwest`].
116#[derive(Debug, Clone)]
117pub struct ReqwestBackend {
118    client: reqwest::Client,
119}
120
121impl ReqwestBackend {
122    /// Creates a new backend with a default `reqwest::Client`.
123    pub fn new() -> Self {
124        Self {
125            client: reqwest::Client::new(),
126        }
127    }
128
129    /// Creates a backend wrapping a caller-provided `reqwest::Client`.
130    ///
131    /// Useful for sharing a connection pool or customizing proxy/TLS settings.
132    pub fn with_client(client: reqwest::Client) -> Self {
133        Self { client }
134    }
135}
136
137impl Default for ReqwestBackend {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143#[async_trait]
144impl HttpBackend for ReqwestBackend {
145    async fn send(&self, request: HttpRequest) -> ApifyClientResult<HttpResponse> {
146        let method = match request.method {
147            HttpMethod::Get => reqwest::Method::GET,
148            HttpMethod::Post => reqwest::Method::POST,
149            HttpMethod::Put => reqwest::Method::PUT,
150            HttpMethod::Delete => reqwest::Method::DELETE,
151            HttpMethod::Head => reqwest::Method::HEAD,
152        };
153
154        let mut builder = self
155            .client
156            .request(method, &request.url)
157            .timeout(request.timeout);
158
159        for (key, value) in &request.headers {
160            builder = builder.header(key, value);
161        }
162        if let Some(body) = request.body {
163            builder = builder.body(body);
164        }
165
166        let response = builder.send().await?;
167        let status = response.status().as_u16();
168
169        let mut headers = HashMap::new();
170        for (name, value) in response.headers().iter() {
171            if let Ok(v) = value.to_str() {
172                headers.insert(name.as_str().to_string(), v.to_string());
173            }
174        }
175
176        let body = response.bytes().await?.to_vec();
177        Ok(HttpResponse {
178            status,
179            headers,
180            body,
181        })
182    }
183}
184
185/// Configuration for the retry/timeout behaviour of the [`HttpClient`].
186#[derive(Debug, Clone)]
187pub struct RetryConfig {
188    /// Maximum number of *retries* (i.e. the request is attempted up to `max_retries + 1` times).
189    pub max_retries: u32,
190    /// Minimum delay between retries; doubled on each subsequent retry (exponential backoff).
191    pub min_delay_between_retries: Duration,
192    /// Overall per-request timeout budget. Each attempt's timeout grows but is capped here.
193    pub timeout: Duration,
194}
195
196/// The orchestrating HTTP client shared by every resource client.
197///
198/// It owns the [`HttpBackend`], the optional API token, and the retry/timeout policy.
199/// It is cheap to clone (everything is reference-counted) so each resource client can
200/// hold its own handle.
201#[derive(Debug, Clone)]
202pub struct HttpClient {
203    backend: Arc<dyn HttpBackend>,
204    token: Option<String>,
205    user_agent: String,
206    retry: RetryConfig,
207}
208
209impl HttpClient {
210    pub(crate) fn new(
211        backend: Arc<dyn HttpBackend>,
212        token: Option<String>,
213        user_agent: String,
214        retry: RetryConfig,
215    ) -> Self {
216        Self {
217            backend,
218            token,
219            user_agent,
220            retry,
221        }
222    }
223
224    /// Sends `request` with authentication, the user-agent header, and the retry policy
225    /// applied. Returns the first successful response, or the final error.
226    pub async fn call(&self, mut request: HttpRequest) -> ApifyClientResult<HttpResponse> {
227        // Inject auth + user-agent headers shared by every endpoint.
228        request
229            .headers
230            .insert("User-Agent".to_string(), self.user_agent.clone());
231        if let Some(token) = &self.token {
232            request
233                .headers
234                .insert("Authorization".to_string(), format!("Bearer {token}"));
235        }
236
237        let method_str = request.method.as_str().to_string();
238        let path = extract_path(&request.url);
239
240        // The caller-supplied `request.timeout` is the per-endpoint base; it grows with each
241        // attempt up to the client's overall timeout budget.
242        let base_timeout = request.timeout;
243        let mut delay = self.retry.min_delay_between_retries;
244        let max_attempts = self.retry.max_retries + 1;
245
246        for attempt in 1..=max_attempts {
247            // Grow per-attempt timeout with each attempt, capped at the overall budget.
248            let mut attempt_request = request.clone();
249            attempt_request.timeout = self.attempt_timeout(base_timeout, attempt);
250
251            let outcome = match self.backend.send(attempt_request).await {
252                Ok(response) => {
253                    if response.status < MAX_SUCCESS_STATUS_CODE {
254                        return Ok(response);
255                    }
256                    let api_error = build_api_error(&response, attempt, &method_str, &path);
257                    let retryable = is_status_retryable(response.status);
258                    (ApifyClientError::from(api_error), retryable)
259                }
260                Err(err) => {
261                    let retryable = is_error_retryable(&err);
262                    (err, retryable)
263                }
264            };
265
266            let (error, retryable) = outcome;
267            // Give up immediately on non-retryable errors or after the last attempt.
268            if !retryable || attempt == max_attempts {
269                return Err(error);
270            }
271
272            // Sleep with randomized exponential backoff before the next attempt. The backoff
273            // doubles each retry (matching the reference client, which uses `async-retry` with
274            // a factor of 2) and is capped at the overall request timeout so a single backoff
275            // can never exceed the budget the whole request is allowed.
276            sleep(randomized_delay(delay)).await;
277            delay = (delay * BACKOFF_FACTOR).min(self.retry.timeout);
278        }
279
280        // Unreachable: the loop always returns on its final iteration, but the compiler
281        // cannot prove `max_attempts >= 1`, so provide a defensive fallback.
282        Err(ApifyClientError::InvalidResponse(
283            "request failed without a recorded error".to_string(),
284        ))
285    }
286
287    /// Per-attempt timeout: `min(overall_timeout, base * 2^(attempt-1))`.
288    ///
289    /// The first attempt uses the per-endpoint `base` timeout; each retry doubles it so a
290    /// slow-but-progressing connection gets more time, while never exceeding the client's
291    /// overall timeout budget. Mirrors the reference clients.
292    fn attempt_timeout(&self, base: Duration, attempt: u32) -> Duration {
293        let scaled = base.saturating_mul(2u32.saturating_pow(attempt.saturating_sub(1)));
294        scaled.min(self.retry.timeout)
295    }
296
297    pub(crate) fn user_agent(&self) -> &str {
298        &self.user_agent
299    }
300
301    /// Returns the token and user-agent, for endpoints (like log streaming) that must
302    /// open a raw connection outside the buffered backend.
303    pub(crate) fn stream_credentials(&self) -> (Option<String>, String) {
304        (self.token.clone(), self.user_agent.clone())
305    }
306}
307
308/// Returns the path + query portion of a URL, for error reporting.
309fn extract_path(url: &str) -> Option<String> {
310    // Find the start of the path after the scheme+host.
311    let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
312    after_scheme
313        .find('/')
314        .map(|idx| after_scheme[idx..].to_string())
315}
316
317/// We retry `429` (rate limit) and `5xx` (internal server errors), matching the
318/// reference client policy. Other `4xx` statuses are caller errors and are not retried.
319fn is_status_retryable(status: u16) -> bool {
320    status == RATE_LIMIT_EXCEEDED_STATUS_CODE || status >= MIN_SERVER_ERROR_STATUS_CODE
321}
322
323/// Only transport-level failures are retryable. Programming errors (serde, invalid
324/// argument) and already-classified API errors are handled elsewhere, so they are not
325/// retried here — matching the reference clients, which retry only network/timeout errors.
326fn is_error_retryable(err: &ApifyClientError) -> bool {
327    matches!(err, ApifyClientError::Http(_) | ApifyClientError::Timeout)
328}
329
330/// Parses the API error body (if present) into an [`ApiError`].
331fn build_api_error(
332    response: &HttpResponse,
333    attempt: u32,
334    method: &str,
335    path: &Option<String>,
336) -> ApiError {
337    let parsed: Option<ApiErrorBody> = serde_json::from_slice(&response.body).ok();
338    let (error_type, message, data) = match parsed {
339        Some(body) => (
340            body.error.error_type,
341            body.error
342                .message
343                .unwrap_or_else(|| format!("Unexpected error with status {}", response.status)),
344            body.error.data,
345        ),
346        None => {
347            let raw = String::from_utf8_lossy(&response.body);
348            let message = if raw.trim().is_empty() {
349                format!("Unexpected error with status {}", response.status)
350            } else {
351                format!("Unexpected error: {raw}")
352            };
353            (None, message, None)
354        }
355    };
356
357    ApiError {
358        status_code: response.status,
359        error_type,
360        message,
361        attempt,
362        http_method: Some(method.to_string()),
363        path: path.clone(),
364        data,
365    }
366}
367
368/// Returns a delay chosen randomly from the interval `[delay, 2*delay)`, matching the
369/// exponential-backoff-with-jitter algorithm described in the API docs.
370fn randomized_delay(delay: Duration) -> Duration {
371    let base = delay.as_millis() as u64;
372    if base == 0 {
373        return delay;
374    }
375    let extra = next_jitter() % base;
376    Duration::from_millis(base + extra)
377}
378
379/// A process-wide pseudo-random source for backoff jitter.
380///
381/// Backoff jitter does not need cryptographic quality, but it must be well-distributed and
382/// uncorrelated across concurrent retries (otherwise many clients retry in lockstep). A
383/// shared atomically-advanced SplitMix64 generator, seeded once from the clock, gives each
384/// caller a distinct value without pulling in a heavyweight RNG dependency.
385fn next_jitter() -> u64 {
386    use std::sync::atomic::{AtomicU64, Ordering};
387    static STATE: AtomicU64 = AtomicU64::new(0);
388
389    // Lazily seed from the clock on first use.
390    let mut current = STATE.load(Ordering::Relaxed);
391    if current == 0 {
392        let seed = std::time::SystemTime::now()
393            .duration_since(std::time::UNIX_EPOCH)
394            .map(|d| d.as_nanos() as u64)
395            .unwrap_or(0x9E3779B97F4A7C15)
396            | 1;
397        // Ignore the race: if two threads seed simultaneously both produce valid streams.
398        let _ = STATE.compare_exchange(0, seed, Ordering::Relaxed, Ordering::Relaxed);
399        current = STATE.load(Ordering::Relaxed);
400    }
401
402    // SplitMix64 step, advancing the shared state atomically.
403    let next = current.wrapping_add(0x9E3779B97F4A7C15);
404    STATE.store(next, Ordering::Relaxed);
405    let mut z = next;
406    z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
407    z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
408    z ^ (z >> 31)
409}
410
411/// Sleeps for the given duration (public crate-internal helper for poll loops).
412pub(crate) async fn sleep_public(duration: Duration) {
413    sleep(duration).await;
414}
415
416/// Sleeps for the given duration using the Tokio timer (the runtime `reqwest` requires).
417async fn sleep(duration: Duration) {
418    tokio::time::sleep(duration).await;
419}