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/// Request bodies at least this many bytes are compressed before sending. Smaller bodies are
33/// left uncompressed because the CPU cost outweighs the transfer savings. Matches the reference
34/// client's `MIN_COMPRESS_BYTES` threshold.
35const MIN_COMPRESS_BYTES: usize = 1024;
36/// `Content-Encoding` value used for brotli-compressed request bodies.
37const CONTENT_ENCODING_BROTLI: &str = "br";
38/// `Content-Encoding` value used for gzip-compressed request bodies.
39const CONTENT_ENCODING_GZIP: &str = "gzip";
40/// Brotli quality level (0–11). The reference client compresses request bodies at quality 6,
41/// which balances ratio against CPU cost; we mirror that.
42const BROTLI_QUALITY: u32 = 6;
43/// Brotli sliding-window size (log2), 22 is the library default (a 4 MiB window).
44const BROTLI_WINDOW_SIZE: u32 = 22;
45/// Internal buffer size for the brotli encoder.
46const BROTLI_BUFFER_SIZE: usize = 4096;
47/// Gzip compression level (0–9). Matches the reference client, which gzips using `node:zlib`'s
48/// default level (6).
49const GZIP_COMPRESSION_LEVEL: u32 = 6;
50
51/// Algorithm used to compress large request bodies before they are sent.
52///
53/// The Apify API accepts both brotli (`br`) and gzip (`gzip`) request bodies. The reference JS
54/// client picks between them automatically (brotli when available, gzip otherwise); this client
55/// exposes the choice explicitly via
56/// [`ApifyClientBuilder::request_compression`](crate::ApifyClientBuilder::request_compression),
57/// because Rust's brotli support is always compiled in and would leave no runtime path to gzip.
58/// [`Brotli`](RequestCompression::Brotli) is the default (best ratio).
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60#[non_exhaustive]
61pub enum RequestCompression {
62    /// Brotli (`Content-Encoding: br`). The default: best compression ratio, and the encoding the
63    /// reference client prefers.
64    #[default]
65    Brotli,
66    /// Gzip (`Content-Encoding: gzip`). Choose this for environments or intermediaries that do not
67    /// handle brotli.
68    Gzip,
69}
70
71/// HTTP method of a request.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum HttpMethod {
74    /// HTTP `GET`.
75    Get,
76    /// HTTP `POST`.
77    Post,
78    /// HTTP `PUT`.
79    Put,
80    /// HTTP `DELETE`.
81    Delete,
82    /// HTTP `HEAD`.
83    Head,
84}
85
86impl HttpMethod {
87    /// Returns the uppercase string representation, e.g. `"GET"`.
88    pub fn as_str(&self) -> &'static str {
89        match self {
90            HttpMethod::Get => "GET",
91            HttpMethod::Post => "POST",
92            HttpMethod::Put => "PUT",
93            HttpMethod::Delete => "DELETE",
94            HttpMethod::Head => "HEAD",
95        }
96    }
97}
98
99/// A fully-resolved HTTP request, ready to be sent by an [`HttpBackend`].
100///
101/// All cross-cutting concerns (auth header, user agent, retry policy) are applied by
102/// [`HttpClient`] before the request reaches the backend.
103#[derive(Debug, Clone)]
104pub struct HttpRequest {
105    /// The HTTP method.
106    pub method: HttpMethod,
107    /// The fully-qualified request URL (including query string).
108    pub url: String,
109    /// Request headers.
110    pub headers: HashMap<String, String>,
111    /// Raw request body bytes (already serialized).
112    pub body: Option<Vec<u8>>,
113    /// Per-request timeout. The backend should abort the request after this duration.
114    pub timeout: Duration,
115}
116
117/// An HTTP response returned by an [`HttpBackend`].
118#[derive(Debug, Clone)]
119pub struct HttpResponse {
120    /// HTTP status code.
121    pub status: u16,
122    /// Response headers.
123    pub headers: HashMap<String, String>,
124    /// Raw response body bytes.
125    pub body: Vec<u8>,
126}
127
128impl HttpResponse {
129    /// Returns the value of a response header (case-insensitive lookup).
130    pub fn header(&self, name: &str) -> Option<&str> {
131        let lower = name.to_ascii_lowercase();
132        self.headers
133            .iter()
134            .find(|(k, _)| k.to_ascii_lowercase() == lower)
135            .map(|(_, v)| v.as_str())
136    }
137}
138
139/// The replaceable transport contract.
140///
141/// Implementors are responsible only for sending a single request and returning the
142/// raw response. Retries, authentication and serialization are handled by
143/// [`HttpClient`], so a backend only needs to perform one network round-trip.
144#[async_trait]
145pub trait HttpBackend: Send + Sync + std::fmt::Debug {
146    /// Sends a single HTTP request and returns the response.
147    ///
148    /// Network-level failures (connection refused, DNS, timeout) should be returned as
149    /// [`ApifyClientError::Http`] or [`ApifyClientError::Timeout`]. A non-2xx HTTP
150    /// status is *not* an error at this layer — return it as a normal [`HttpResponse`].
151    async fn send(&self, request: HttpRequest) -> ApifyClientResult<HttpResponse>;
152}
153
154/// The default [`HttpBackend`] implementation, backed by [`reqwest`].
155#[derive(Debug, Clone)]
156pub struct ReqwestBackend {
157    client: reqwest::Client,
158}
159
160impl ReqwestBackend {
161    /// Creates a new backend with a default `reqwest::Client`.
162    pub fn new() -> Self {
163        Self {
164            client: reqwest::Client::new(),
165        }
166    }
167
168    /// Creates a backend wrapping a caller-provided `reqwest::Client`.
169    ///
170    /// Useful for sharing a connection pool or customizing proxy/TLS settings.
171    pub fn with_client(client: reqwest::Client) -> Self {
172        Self { client }
173    }
174}
175
176impl Default for ReqwestBackend {
177    fn default() -> Self {
178        Self::new()
179    }
180}
181
182#[async_trait]
183impl HttpBackend for ReqwestBackend {
184    async fn send(&self, request: HttpRequest) -> ApifyClientResult<HttpResponse> {
185        let method = match request.method {
186            HttpMethod::Get => reqwest::Method::GET,
187            HttpMethod::Post => reqwest::Method::POST,
188            HttpMethod::Put => reqwest::Method::PUT,
189            HttpMethod::Delete => reqwest::Method::DELETE,
190            HttpMethod::Head => reqwest::Method::HEAD,
191        };
192
193        let mut builder = self
194            .client
195            .request(method, &request.url)
196            .timeout(request.timeout);
197
198        for (key, value) in &request.headers {
199            builder = builder.header(key, value);
200        }
201        if let Some(body) = request.body {
202            builder = builder.body(body);
203        }
204
205        let response = builder.send().await?;
206        let status = response.status().as_u16();
207
208        let mut headers = HashMap::new();
209        for (name, value) in response.headers().iter() {
210            if let Ok(v) = value.to_str() {
211                headers.insert(name.as_str().to_string(), v.to_string());
212            }
213        }
214
215        let body = response.bytes().await?.to_vec();
216        Ok(HttpResponse {
217            status,
218            headers,
219            body,
220        })
221    }
222}
223
224/// Configuration for the retry/timeout behaviour of the [`HttpClient`].
225#[derive(Debug, Clone)]
226pub struct RetryConfig {
227    /// Maximum number of *retries* (i.e. the request is attempted up to `max_retries + 1` times).
228    pub max_retries: u32,
229    /// Minimum delay between retries; doubled on each subsequent retry (exponential backoff).
230    pub min_delay_between_retries: Duration,
231    /// Overall per-request timeout budget. Each attempt's timeout grows but is capped here.
232    pub timeout: Duration,
233}
234
235/// The orchestrating HTTP client shared by every resource client.
236///
237/// It owns the [`HttpBackend`], the optional API token, and the retry/timeout policy.
238/// It is cheap to clone (everything is reference-counted) so each resource client can
239/// hold its own handle.
240#[derive(Debug, Clone)]
241pub struct HttpClient {
242    backend: Arc<dyn HttpBackend>,
243    token: Option<String>,
244    user_agent: String,
245    retry: RetryConfig,
246    compression: RequestCompression,
247}
248
249impl HttpClient {
250    pub(crate) fn new(
251        backend: Arc<dyn HttpBackend>,
252        token: Option<String>,
253        user_agent: String,
254        retry: RetryConfig,
255        compression: RequestCompression,
256    ) -> Self {
257        Self {
258            backend,
259            token,
260            user_agent,
261            retry,
262            compression,
263        }
264    }
265
266    /// Sends `request` with authentication, the user-agent header, and the retry policy
267    /// applied. Returns the first successful response, or the final error.
268    pub async fn call(&self, mut request: HttpRequest) -> ApifyClientResult<HttpResponse> {
269        // Inject auth + user-agent headers shared by every endpoint.
270        request
271            .headers
272            .insert("User-Agent".to_string(), self.user_agent.clone());
273        if let Some(token) = &self.token {
274            request
275                .headers
276                .insert("Authorization".to_string(), format!("Bearer {token}"));
277        }
278
279        // Compress the request body once (not per attempt) when it is large enough, mirroring the
280        // reference client. The API accepts both brotli- and gzip-encoded request bodies.
281        maybe_compress_request(&mut request, self.compression);
282
283        let method_str = request.method.as_str().to_string();
284        let path = extract_path(&request.url);
285
286        // The caller-supplied `request.timeout` is the per-endpoint base; it grows with each
287        // attempt up to the client's overall timeout budget.
288        let base_timeout = request.timeout;
289        let mut delay = self.retry.min_delay_between_retries;
290        // `saturating_add` so an extreme `max_retries` can't overflow the attempt count.
291        let max_attempts = self.retry.max_retries.saturating_add(1);
292
293        let mut attempt = 1;
294        loop {
295            // Grow per-attempt timeout with each attempt, capped at the overall budget.
296            let mut attempt_request = request.clone();
297            attempt_request.timeout = self.attempt_timeout(base_timeout, attempt);
298
299            let outcome = match self.backend.send(attempt_request).await {
300                Ok(response) => {
301                    if response.status < MAX_SUCCESS_STATUS_CODE {
302                        return Ok(response);
303                    }
304                    let api_error = build_api_error(&response, attempt, &method_str, &path);
305                    let retryable = is_status_retryable(response.status);
306                    (ApifyClientError::from(api_error), retryable)
307                }
308                Err(err) => {
309                    let retryable = is_error_retryable(&err);
310                    (err, retryable)
311                }
312            };
313
314            let (error, retryable) = outcome;
315            // Give up immediately on non-retryable errors or after the last attempt.
316            if !retryable || attempt == max_attempts {
317                return Err(error);
318            }
319
320            // Sleep with randomized exponential backoff before the next attempt. The backoff
321            // doubles each retry (matching the reference client, which uses `async-retry` with
322            // a factor of 2) and is capped at the overall request timeout so a single backoff
323            // can never exceed the budget the whole request is allowed.
324            sleep(randomized_delay(delay)).await;
325            // `saturating_mul` mirrors the saturating arithmetic in `attempt_timeout`.
326            delay = delay.saturating_mul(BACKOFF_FACTOR).min(self.retry.timeout);
327            attempt += 1;
328        }
329    }
330
331    /// Per-attempt timeout: `min(overall_timeout, base * 2^(attempt-1))`.
332    ///
333    /// The first attempt uses the per-endpoint `base` timeout; each retry doubles it so a
334    /// slow-but-progressing connection gets more time, while never exceeding the client's
335    /// overall timeout budget. Mirrors the reference clients.
336    fn attempt_timeout(&self, base: Duration, attempt: u32) -> Duration {
337        let scaled = base.saturating_mul(2u32.saturating_pow(attempt.saturating_sub(1)));
338        scaled.min(self.retry.timeout)
339    }
340
341    pub(crate) fn user_agent(&self) -> &str {
342        &self.user_agent
343    }
344
345    /// Returns the token and user-agent, for endpoints (like log streaming) that must
346    /// open a raw connection outside the buffered backend.
347    pub(crate) fn stream_credentials(&self) -> (Option<String>, String) {
348        (self.token.clone(), self.user_agent.clone())
349    }
350}
351
352/// Compresses `request.body` in place when it is present, at least [`MIN_COMPRESS_BYTES`] long,
353/// and no `Content-Encoding` is already set, adding the matching `Content-Encoding` header.
354///
355/// The algorithm is chosen by `compression` (defaulting to brotli). The size threshold and the
356/// "compress once, before retries" behaviour mirror the reference client.
357fn maybe_compress_request(request: &mut HttpRequest, compression: RequestCompression) {
358    let Some(body) = request.body.as_ref() else {
359        return;
360    };
361    if body.len() < MIN_COMPRESS_BYTES {
362        return;
363    }
364    // Respect a caller-provided `Content-Encoding` (case-insensitive): the body is then assumed to
365    // already be encoded, so re-compressing it would corrupt it.
366    let already_encoded = request
367        .headers
368        .keys()
369        .any(|k| k.eq_ignore_ascii_case("Content-Encoding"));
370    if already_encoded {
371        return;
372    }
373
374    let (encoding, compressed) = match compression {
375        RequestCompression::Brotli => (CONTENT_ENCODING_BROTLI, brotli_compress(body)),
376        RequestCompression::Gzip => (CONTENT_ENCODING_GZIP, gzip_compress(body)),
377    };
378    request
379        .headers
380        .insert("Content-Encoding".to_string(), encoding.to_string());
381    request.body = Some(compressed);
382}
383
384/// Brotli-compresses `data`. Writing to an in-memory `Vec` is infallible, so this cannot fail.
385fn brotli_compress(data: &[u8]) -> Vec<u8> {
386    use std::io::Write;
387
388    let mut writer = brotli::CompressorWriter::new(
389        Vec::new(),
390        BROTLI_BUFFER_SIZE,
391        BROTLI_QUALITY,
392        BROTLI_WINDOW_SIZE,
393    );
394    writer
395        .write_all(data)
396        .expect("writing to an in-memory Vec never fails");
397    writer.into_inner()
398}
399
400/// Gzip-compresses `data`. Writing to and finishing an in-memory `Vec` is infallible, so this
401/// cannot fail.
402fn gzip_compress(data: &[u8]) -> Vec<u8> {
403    use flate2::{write::GzEncoder, Compression};
404    use std::io::Write;
405
406    let mut encoder = GzEncoder::new(Vec::new(), Compression::new(GZIP_COMPRESSION_LEVEL));
407    encoder
408        .write_all(data)
409        .expect("writing to an in-memory Vec never fails");
410    encoder
411        .finish()
412        .expect("finishing an in-memory Vec never fails")
413}
414
415/// Returns the path + query portion of a URL, for error reporting.
416fn extract_path(url: &str) -> Option<String> {
417    // Find the start of the path after the scheme+host.
418    let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
419    after_scheme
420        .find('/')
421        .map(|idx| after_scheme[idx..].to_string())
422}
423
424/// We retry `429` (rate limit) and `5xx` (internal server errors), matching the
425/// reference client policy. Other `4xx` statuses are caller errors and are not retried.
426fn is_status_retryable(status: u16) -> bool {
427    status == RATE_LIMIT_EXCEEDED_STATUS_CODE || status >= MIN_SERVER_ERROR_STATUS_CODE
428}
429
430/// Only transport-level failures are retryable. Programming errors (serde, invalid
431/// argument) and already-classified API errors are handled elsewhere, so they are not
432/// retried here — matching the reference clients, which retry only network/timeout errors.
433fn is_error_retryable(err: &ApifyClientError) -> bool {
434    matches!(err, ApifyClientError::Http(_) | ApifyClientError::Timeout)
435}
436
437/// Parses the API error body (if present) into an [`ApiError`].
438fn build_api_error(
439    response: &HttpResponse,
440    attempt: u32,
441    method: &str,
442    path: &Option<String>,
443) -> ApiError {
444    let parsed: Option<ApiErrorBody> = serde_json::from_slice(&response.body).ok();
445    let (error_type, message, data) = match parsed {
446        Some(body) => (
447            body.error.error_type,
448            body.error
449                .message
450                .unwrap_or_else(|| format!("Unexpected error with status {}", response.status)),
451            body.error.data,
452        ),
453        None => {
454            let raw = String::from_utf8_lossy(&response.body);
455            let message = if raw.trim().is_empty() {
456                format!("Unexpected error with status {}", response.status)
457            } else {
458                format!("Unexpected error: {raw}")
459            };
460            (None, message, None)
461        }
462    };
463
464    ApiError {
465        status_code: response.status,
466        error_type,
467        message,
468        attempt,
469        http_method: Some(method.to_string()),
470        path: path.clone(),
471        data,
472    }
473}
474
475/// Returns a delay chosen randomly from the interval `[delay, 2*delay)`, matching the
476/// exponential-backoff-with-jitter algorithm described in the API docs.
477fn randomized_delay(delay: Duration) -> Duration {
478    let base = delay.as_millis() as u64;
479    if base == 0 {
480        return delay;
481    }
482    let extra = next_jitter() % base;
483    Duration::from_millis(base + extra)
484}
485
486/// A process-wide pseudo-random source for backoff jitter.
487///
488/// Backoff jitter does not need cryptographic quality, but it must be well-distributed and
489/// uncorrelated across concurrent retries (otherwise many clients retry in lockstep). A
490/// shared atomically-advanced SplitMix64 generator, seeded once from the clock, gives each
491/// caller a distinct value without pulling in a heavyweight RNG dependency.
492fn next_jitter() -> u64 {
493    use std::sync::atomic::{AtomicU64, Ordering};
494    static STATE: AtomicU64 = AtomicU64::new(0);
495
496    const GOLDEN_GAMMA: u64 = 0x9E3779B97F4A7C15;
497
498    // Lazily seed from the clock on first use. A racing double-seed is harmless: both
499    // candidate seeds are valid SplitMix64 stream starting points.
500    if STATE.load(Ordering::Relaxed) == 0 {
501        let seed = std::time::SystemTime::now()
502            .duration_since(std::time::UNIX_EPOCH)
503            .map(|d| d.as_nanos() as u64)
504            .unwrap_or(GOLDEN_GAMMA)
505            | 1;
506        let _ = STATE.compare_exchange(0, seed, Ordering::Relaxed, Ordering::Relaxed);
507    }
508
509    // SplitMix64: advance the shared state by the golden-ratio increment in a single atomic
510    // read-modify-write (`fetch_add`) so concurrent callers each observe a distinct value —
511    // a plain load-then-store could hand two racing retries the same number. Then scramble.
512    let mut z = STATE
513        .fetch_add(GOLDEN_GAMMA, Ordering::Relaxed)
514        .wrapping_add(GOLDEN_GAMMA);
515    z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
516    z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
517    z ^ (z >> 31)
518}
519
520/// Sleeps for the given duration (public crate-internal helper for poll loops).
521pub(crate) async fn sleep_public(duration: Duration) {
522    sleep(duration).await;
523}
524
525/// Sleeps for the given duration using the Tokio timer (the runtime `reqwest` requires).
526async fn sleep(duration: Duration) {
527    tokio::time::sleep(duration).await;
528}