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/// Media-type prefixes whose payloads already carry their own compression, so running them
52/// through brotli/gzip burns CPU and memory for a result that is usually no smaller — and the
53/// request also keeps the intended `Content-Type`, rather than becoming an unreadable
54/// `Content-Encoding: br` blob the destination may not expect for these formats.
55/// Matches the reference client's `ALREADY_COMPRESSED_MEDIA_TYPE_PREFIXES`.
56const ALREADY_COMPRESSED_MEDIA_TYPE_PREFIXES: [&str; 3] = ["audio/", "image/", "video/"];
57
58/// Exact media types (outside the prefixes above) whose payloads already carry their own
59/// compression. Matches the reference client's `ALREADY_COMPRESSED_MEDIA_TYPES`.
60const ALREADY_COMPRESSED_MEDIA_TYPES: [&str; 19] = [
61    "application/epub+zip",
62    "application/gzip",
63    "application/java-archive",
64    "application/vnd.android.package-archive",
65    "application/vnd.openxmlformats-officedocument.presentationml.presentation",
66    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
67    "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
68    "application/vnd.rar",
69    "application/x-7z-compressed",
70    "application/x-bzip",
71    "application/x-bzip2",
72    "application/x-gzip",
73    "application/x-rar-compressed",
74    "application/x-xz",
75    "application/x-zip-compressed",
76    "application/zip",
77    "application/zstd",
78    "font/woff",
79    "font/woff2",
80];
81
82/// Uncompressed media types that sit under an already-compressed prefix above, so compressing
83/// them still pays off. Matches the reference client's `COMPRESSIBLE_MEDIA_TYPES`.
84const COMPRESSIBLE_MEDIA_TYPES: [&str; 16] = [
85    "audio/aiff",
86    "audio/basic",
87    "audio/l16",
88    "audio/l24",
89    "audio/midi",
90    "audio/vnd.wave",
91    "audio/wav",
92    "audio/wave",
93    "audio/x-aiff",
94    "audio/x-wav",
95    "image/bmp",
96    "image/tiff",
97    "image/vnd.adobe.photoshop",
98    "image/vnd.microsoft.icon",
99    "image/x-icon",
100    "image/x-ms-bmp",
101];
102
103/// Structured-syntax suffixes that mark a media type as text even under an already-compressed
104/// prefix (e.g. `image/svg+xml`). Matches the reference client's
105/// `COMPRESSIBLE_MEDIA_TYPE_SUFFIXES`.
106const COMPRESSIBLE_MEDIA_TYPE_SUFFIXES: [&str; 2] = ["+json", "+xml"];
107
108/// Algorithm used to compress large request bodies before they are sent.
109///
110/// The Apify API accepts both brotli (`br`) and gzip (`gzip`) request bodies. The reference JS
111/// client picks between them automatically (brotli when available, gzip otherwise); this client
112/// exposes the choice explicitly via
113/// [`ApifyClientBuilder::request_compression`](crate::ApifyClientBuilder::request_compression),
114/// because Rust's brotli support is always compiled in and would leave no runtime path to gzip.
115/// [`Brotli`](RequestCompression::Brotli) is the default (best ratio).
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
117#[non_exhaustive]
118pub enum RequestCompression {
119    /// Brotli (`Content-Encoding: br`). The default: best compression ratio, and the encoding the
120    /// reference client prefers.
121    #[default]
122    Brotli,
123    /// Gzip (`Content-Encoding: gzip`). Choose this for environments or intermediaries that do not
124    /// handle brotli.
125    Gzip,
126}
127
128/// HTTP method of a request.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum HttpMethod {
131    /// HTTP `GET`.
132    Get,
133    /// HTTP `POST`.
134    Post,
135    /// HTTP `PUT`.
136    Put,
137    /// HTTP `DELETE`.
138    Delete,
139    /// HTTP `HEAD`.
140    Head,
141}
142
143impl HttpMethod {
144    /// Returns the uppercase string representation, e.g. `"GET"`.
145    pub fn as_str(&self) -> &'static str {
146        match self {
147            HttpMethod::Get => "GET",
148            HttpMethod::Post => "POST",
149            HttpMethod::Put => "PUT",
150            HttpMethod::Delete => "DELETE",
151            HttpMethod::Head => "HEAD",
152        }
153    }
154}
155
156/// A fully-resolved HTTP request, ready to be sent by an [`HttpBackend`].
157///
158/// All cross-cutting concerns (auth header, user agent, retry policy) are applied by
159/// [`HttpClient`] before the request reaches the backend.
160#[derive(Debug, Clone)]
161pub struct HttpRequest {
162    /// The HTTP method.
163    pub method: HttpMethod,
164    /// The fully-qualified request URL (including query string).
165    pub url: String,
166    /// Request headers.
167    pub headers: HashMap<String, String>,
168    /// Raw request body bytes (already serialized).
169    pub body: Option<Vec<u8>>,
170    /// Per-request timeout. The backend should abort the request after this duration.
171    pub timeout: Duration,
172}
173
174/// An HTTP response returned by an [`HttpBackend`].
175#[derive(Debug, Clone)]
176pub struct HttpResponse {
177    /// HTTP status code.
178    pub status: u16,
179    /// Response headers.
180    pub headers: HashMap<String, String>,
181    /// Raw response body bytes.
182    pub body: Vec<u8>,
183}
184
185impl HttpResponse {
186    /// Returns the value of a response header (case-insensitive lookup).
187    pub fn header(&self, name: &str) -> Option<&str> {
188        let lower = name.to_ascii_lowercase();
189        self.headers
190            .iter()
191            .find(|(k, _)| k.to_ascii_lowercase() == lower)
192            .map(|(_, v)| v.as_str())
193    }
194}
195
196/// A streaming counterpart of [`HttpResponse`]: the status and headers, available as soon as
197/// the response starts, and the body delivered incrementally instead of as one buffered `Vec<u8>`.
198pub struct StreamingHttpResponse {
199    /// HTTP status code.
200    pub status: u16,
201    /// Response headers.
202    pub headers: HashMap<String, String>,
203    /// The body, as a stream of chunks.
204    pub body:
205        std::pin::Pin<Box<dyn futures_util::Stream<Item = ApifyClientResult<Vec<u8>>> + Send>>,
206}
207
208/// The replaceable transport contract.
209///
210/// Implementors are responsible only for sending a single request and returning the
211/// raw response. Retries, authentication and serialization are handled by
212/// [`HttpClient`], so a backend only needs to perform one network round-trip.
213#[async_trait]
214pub trait HttpBackend: Send + Sync + std::fmt::Debug {
215    /// Sends a single HTTP request and returns the response.
216    ///
217    /// Network-level failures (connection refused, DNS, timeout) should be returned as
218    /// [`ApifyClientError::Http`] or [`ApifyClientError::Timeout`]. A non-2xx HTTP
219    /// status is *not* an error at this layer — return it as a normal [`HttpResponse`].
220    async fn send(&self, request: HttpRequest) -> ApifyClientResult<HttpResponse>;
221
222    /// Like [`send`](Self::send), but for a request whose body the caller wants to process
223    /// incrementally (a live log or a large export) instead of fully buffered.
224    ///
225    /// The default implementation just buffers the whole response via [`send`](Self::send) and
226    /// yields it as a single chunk: correct for a custom backend that does not override this
227    /// (streaming still works end-to-end, just without a memory-usage benefit), while the
228    /// default [`ReqwestBackend`] overrides it for real constant-memory streaming through
229    /// `reqwest`'s own chunked body reader.
230    async fn send_streaming(
231        &self,
232        request: HttpRequest,
233    ) -> ApifyClientResult<StreamingHttpResponse> {
234        let response = self.send(request).await?;
235        Ok(StreamingHttpResponse {
236            status: response.status,
237            headers: response.headers,
238            body: Box::pin(futures_util::stream::once(async move { Ok(response.body) })),
239        })
240    }
241}
242
243/// Maps this crate's [`HttpMethod`] to `reqwest`'s, shared by [`ReqwestBackend`]'s buffered and
244/// streaming sends.
245fn to_reqwest_method(method: HttpMethod) -> reqwest::Method {
246    match method {
247        HttpMethod::Get => reqwest::Method::GET,
248        HttpMethod::Post => reqwest::Method::POST,
249        HttpMethod::Put => reqwest::Method::PUT,
250        HttpMethod::Delete => reqwest::Method::DELETE,
251        HttpMethod::Head => reqwest::Method::HEAD,
252    }
253}
254
255/// The default [`HttpBackend`] implementation, backed by [`reqwest`].
256#[derive(Debug, Clone)]
257pub struct ReqwestBackend {
258    client: reqwest::Client,
259}
260
261impl ReqwestBackend {
262    /// Creates a new backend with a default `reqwest::Client`.
263    pub fn new() -> Self {
264        Self {
265            client: reqwest::Client::new(),
266        }
267    }
268
269    /// Creates a backend wrapping a caller-provided `reqwest::Client`.
270    ///
271    /// Useful for sharing a connection pool or customizing proxy/TLS settings.
272    pub fn with_client(client: reqwest::Client) -> Self {
273        Self { client }
274    }
275}
276
277impl Default for ReqwestBackend {
278    fn default() -> Self {
279        Self::new()
280    }
281}
282
283#[async_trait]
284impl HttpBackend for ReqwestBackend {
285    async fn send(&self, request: HttpRequest) -> ApifyClientResult<HttpResponse> {
286        let method = to_reqwest_method(request.method);
287
288        let mut builder = self
289            .client
290            .request(method, &request.url)
291            .timeout(request.timeout);
292
293        for (key, value) in &request.headers {
294            builder = builder.header(key, value);
295        }
296        if let Some(body) = request.body {
297            builder = builder.body(body);
298        }
299
300        let response = builder.send().await?;
301        let status = response.status().as_u16();
302
303        let mut headers = HashMap::new();
304        for (name, value) in response.headers().iter() {
305            if let Ok(v) = value.to_str() {
306                headers.insert(name.as_str().to_string(), v.to_string());
307            }
308        }
309
310        let body = response.bytes().await?.to_vec();
311        Ok(HttpResponse {
312            status,
313            headers,
314            body,
315        })
316    }
317
318    async fn send_streaming(
319        &self,
320        request: HttpRequest,
321    ) -> ApifyClientResult<StreamingHttpResponse> {
322        let method = to_reqwest_method(request.method);
323
324        // Deliberately no `.timeout(request.timeout)` here: reqwest's per-request timeout bounds
325        // the *entire* request including reading the whole body (it wraps the body stream in a
326        // `TotalTimeoutBody`), which would cut off a long-but-active stream (e.g. live log
327        // redirection). `HttpClient::call_streaming` applies its own response-start and
328        // per-chunk-inactivity bounds instead, uniformly across backends.
329        let mut builder = self.client.request(method, &request.url);
330        for (key, value) in &request.headers {
331            builder = builder.header(key, value);
332        }
333        if let Some(body) = request.body {
334            builder = builder.body(body);
335        }
336
337        let response = builder.send().await?;
338        let status = response.status().as_u16();
339        let mut headers = HashMap::new();
340        for (name, value) in response.headers().iter() {
341            if let Ok(v) = value.to_str() {
342                headers.insert(name.as_str().to_string(), v.to_string());
343            }
344        }
345
346        let byte_stream = response.bytes_stream();
347        let body = Box::pin(futures_util::StreamExt::map(byte_stream, |chunk| {
348            chunk.map(|b| b.to_vec()).map_err(ApifyClientError::from)
349        }));
350        Ok(StreamingHttpResponse {
351            status,
352            headers,
353            body,
354        })
355    }
356}
357
358/// Configuration for the retry/timeout behaviour of the [`HttpClient`].
359#[derive(Debug, Clone)]
360pub struct RetryConfig {
361    /// Maximum number of *retries* (i.e. the request is attempted up to `max_retries + 1` times).
362    pub max_retries: u32,
363    /// Minimum delay between retries; doubled on each subsequent retry (exponential backoff).
364    pub min_delay_between_retries: Duration,
365    /// Overall per-request timeout budget. Each attempt's timeout grows but is capped here.
366    pub timeout: Duration,
367}
368
369/// The orchestrating HTTP client shared by every resource client.
370///
371/// It owns the [`HttpBackend`], the optional API token, and the retry/timeout policy.
372/// It is cheap to clone (everything is reference-counted) so each resource client can
373/// hold its own handle.
374#[derive(Debug, Clone)]
375pub struct HttpClient {
376    backend: Arc<dyn HttpBackend>,
377    token: Option<String>,
378    user_agent: String,
379    retry: RetryConfig,
380    compression: RequestCompression,
381}
382
383impl HttpClient {
384    pub(crate) fn new(
385        backend: Arc<dyn HttpBackend>,
386        token: Option<String>,
387        user_agent: String,
388        retry: RetryConfig,
389        compression: RequestCompression,
390    ) -> Self {
391        Self {
392            backend,
393            token,
394            user_agent,
395            retry,
396            compression,
397        }
398    }
399
400    /// Sends `request` with authentication, the user-agent header, and the retry policy
401    /// applied. Returns the first successful response, or the final error.
402    pub async fn call(&self, mut request: HttpRequest) -> ApifyClientResult<HttpResponse> {
403        // Inject auth + user-agent headers shared by every endpoint.
404        request
405            .headers
406            .insert("User-Agent".to_string(), self.user_agent.clone());
407        if let Some(token) = &self.token {
408            request
409                .headers
410                .insert("Authorization".to_string(), format!("Bearer {token}"));
411        }
412
413        // Compress the request body once (not per attempt) when it is large enough, mirroring the
414        // reference client. The API accepts both brotli- and gzip-encoded request bodies.
415        maybe_compress_request(&mut request, self.compression);
416
417        let method_str = request.method.as_str().to_string();
418        let path = extract_path(&request.url);
419
420        // The caller-supplied `request.timeout` is the per-endpoint base; it grows with each
421        // attempt up to the client's overall timeout budget.
422        let base_timeout = request.timeout;
423        let mut delay = self.retry.min_delay_between_retries;
424        // `saturating_add` so an extreme `max_retries` can't overflow the attempt count.
425        let max_attempts = self.retry.max_retries.saturating_add(1);
426
427        let mut attempt = 1;
428        loop {
429            // Grow per-attempt timeout with each attempt, capped at the overall budget.
430            let mut attempt_request = request.clone();
431            attempt_request.timeout = self.attempt_timeout(base_timeout, attempt);
432
433            let outcome = match self.backend.send(attempt_request).await {
434                Ok(response) => {
435                    if response.status < MAX_SUCCESS_STATUS_CODE {
436                        return Ok(response);
437                    }
438                    let api_error = build_api_error(&response, attempt, &method_str, &path);
439                    let retryable = is_status_retryable(response.status);
440                    (ApifyClientError::from(api_error), retryable)
441                }
442                Err(err) => {
443                    let retryable = is_error_retryable(&err);
444                    (err, retryable)
445                }
446            };
447
448            let (error, retryable) = outcome;
449            // Give up immediately on non-retryable errors or after the last attempt.
450            if !retryable || attempt == max_attempts {
451                return Err(error);
452            }
453
454            // Sleep with randomized exponential backoff before the next attempt. The backoff
455            // doubles each retry (matching the reference client, which uses `async-retry` with
456            // a factor of 2) and is capped at the overall request timeout so a single backoff
457            // can never exceed the budget the whole request is allowed.
458            sleep(randomized_delay(delay)).await;
459            // `saturating_mul` mirrors the saturating arithmetic in `attempt_timeout`.
460            delay = delay.saturating_mul(BACKOFF_FACTOR).min(self.retry.timeout);
461            attempt += 1;
462        }
463    }
464
465    /// Per-attempt timeout: `min(overall_timeout, base * 2^(attempt-1))`.
466    ///
467    /// The first attempt uses the per-endpoint `base` timeout; each retry doubles it so a
468    /// slow-but-progressing connection gets more time, while never exceeding the client's
469    /// overall timeout budget. Mirrors the reference clients.
470    fn attempt_timeout(&self, base: Duration, attempt: u32) -> Duration {
471        let scaled = base.saturating_mul(2u32.saturating_pow(attempt.saturating_sub(1)));
472        scaled.min(self.retry.timeout)
473    }
474
475    pub(crate) fn user_agent(&self) -> &str {
476        &self.user_agent
477    }
478
479    /// Sends `request` through the configured [`HttpBackend`]'s [`HttpBackend::send_streaming`],
480    /// with authentication and the user-agent header applied like [`call`](Self::call), but with
481    /// no retries and no request compression: matches the reference clients, which do not retry
482    /// an open stream (a log redirection or a large export already in flight can't be safely
483    /// replayed from the start).
484    ///
485    /// On a non-success status, reads the body (bounded by the same inactivity timeout as a
486    /// successful body below) and returns the same structured [`ApiError`] [`call`](Self::call)
487    /// would. On success, returns the body as a stream whose `.next()` must resolve within the
488    /// client's overall timeout budget each time — bounding silence (a stalled connection)
489    /// without capping the stream's total duration, so a long-but-active stream (e.g. live log
490    /// redirection) is never cut off. That same budget also bounds the initial wait for the
491    /// response to start, mirroring the reference clients' streaming `timeoutSecs`.
492    pub(crate) async fn call_streaming(
493        &self,
494        mut request: HttpRequest,
495    ) -> ApifyClientResult<impl futures_util::Stream<Item = ApifyClientResult<Vec<u8>>>> {
496        request
497            .headers
498            .insert("User-Agent".to_string(), self.user_agent.clone());
499        if let Some(token) = &self.token {
500            request
501                .headers
502                .insert("Authorization".to_string(), format!("Bearer {token}"));
503        }
504
505        let method_str = request.method.as_str().to_string();
506        let path = extract_path(&request.url);
507        // The caller-supplied `request.timeout` (the same per-endpoint base every buffered call
508        // uses — e.g. `DEFAULT_REQUEST_TIMEOUT`, the reference clients' "long" tier) is reused
509        // here as both the response-start wait and the per-chunk inactivity window: one
510        // configured duration, not a separate, larger one for inactivity. The JS reference client
511        // documents the same single-duration behavior ("it only limits inactivity"), though it
512        // gets there differently — via its HTTP library's socket-level idle timeout, not an
513        // explicit per-chunk reset like `with_inactivity_timeout` below — so this is a
514        // same-outcome, not same-mechanism, match. It carries the same tradeoff either way: a
515        // stream legitimately silent for the whole window (e.g. a long-running Actor logging
516        // nothing for `DEFAULT_REQUEST_TIMEOUT`) times out; the reference's escape hatch for that
517        // is an explicit `'noTimeout'` request option, which this crate does not yet expose.
518        let budget = request.timeout;
519
520        let response = tokio::time::timeout(budget, self.backend.send_streaming(request))
521            .await
522            .map_err(|_| ApifyClientError::Timeout)??;
523
524        if response.status < MAX_SUCCESS_STATUS_CODE {
525            return Ok(with_inactivity_timeout(response.body, budget));
526        }
527
528        // The error body arrives as a stream here too; read it (under the same inactivity
529        // budget) so the failure carries the same structured `ApiError` (type/message/data) as
530        // every buffered endpoint, rather than losing the body to a generic transport error. A
531        // body that fails to read (e.g. the connection drops mid-response) still surfaces the
532        // status code, just without a parsed message.
533        let body = collect_with_timeout(response.body, budget).await;
534        let http_response = HttpResponse {
535            status: response.status,
536            headers: response.headers,
537            body,
538        };
539        let api_error = build_api_error(&http_response, 1, &method_str, &path);
540        Err(ApifyClientError::from(api_error))
541    }
542}
543
544/// Wraps a streaming body so each chunk must arrive within `timeout` of the previous one (or of
545/// the stream opening), erroring with [`ApifyClientError::Timeout`] otherwise — an inactivity
546/// timeout, not a cap on the stream's total duration. Shared by
547/// [`HttpClient::call_streaming`]'s success and error-body-reading paths.
548///
549/// A timeout yields exactly one `Err(Timeout)` and then ends the stream (further polls return
550/// `None`), rather than retrying the same read forever: the underlying body is dropped once it
551/// has stalled, so a caller that logs the error and keeps draining (instead of stopping on the
552/// first `Err`, as `?`-style consumption does) still sees the stream terminate.
553fn with_inactivity_timeout(
554    body: std::pin::Pin<Box<dyn futures_util::Stream<Item = ApifyClientResult<Vec<u8>>> + Send>>,
555    timeout: Duration,
556) -> std::pin::Pin<Box<dyn futures_util::Stream<Item = ApifyClientResult<Vec<u8>>> + Send>> {
557    enum State {
558        Active(
559            std::pin::Pin<Box<dyn futures_util::Stream<Item = ApifyClientResult<Vec<u8>>> + Send>>,
560        ),
561        Stalled,
562    }
563
564    Box::pin(futures_util::stream::unfold(
565        State::Active(body),
566        move |state| async move {
567            let State::Active(mut body) = state else {
568                return None;
569            };
570            match tokio::time::timeout(timeout, futures_util::StreamExt::next(&mut body)).await {
571                Ok(Some(item)) => Some((item, State::Active(body))),
572                Ok(None) => None,
573                Err(_) => Some((Err(ApifyClientError::Timeout), State::Stalled)),
574            }
575        },
576    ))
577}
578
579/// Drains a streaming body into a single buffer, under the same per-chunk inactivity timeout as
580/// [`with_inactivity_timeout`]. Used only for a non-success response's (typically small) error
581/// body, so buffering it is fine — unlike a successful body, which callers stream incrementally.
582async fn collect_with_timeout(
583    body: std::pin::Pin<Box<dyn futures_util::Stream<Item = ApifyClientResult<Vec<u8>>> + Send>>,
584    timeout: Duration,
585) -> Vec<u8> {
586    let mut buf = Vec::new();
587    let mut timed = with_inactivity_timeout(body, timeout);
588    while let Some(chunk) = futures_util::StreamExt::next(&mut timed).await {
589        match chunk {
590            Ok(bytes) => buf.extend(bytes),
591            Err(_) => break,
592        }
593    }
594    buf
595}
596
597/// Compresses `request.body` in place when it is present, at least [`MIN_COMPRESS_BYTES`] long,
598/// and no `Content-Encoding` is already set, adding the matching `Content-Encoding` header.
599///
600/// The algorithm is chosen by `compression` (defaulting to brotli). The size threshold and the
601/// "compress once, before retries" behaviour mirror the reference client.
602fn maybe_compress_request(request: &mut HttpRequest, compression: RequestCompression) {
603    let Some(body) = request.body.as_ref() else {
604        return;
605    };
606    if body.len() < MIN_COMPRESS_BYTES {
607        return;
608    }
609    // Respect a caller-provided `Content-Encoding` (case-insensitive): the body is then assumed to
610    // already be encoded, so re-compressing it would corrupt it.
611    let already_encoded = request
612        .headers
613        .keys()
614        .any(|k| k.eq_ignore_ascii_case("Content-Encoding"));
615    if already_encoded {
616        return;
617    }
618    // Skip media types that already carry their own compression (images, audio, video,
619    // archives): compressing them again burns CPU for little to no size reduction. This is
620    // mainly relevant to a raw-bytes Actor input (e.g. `ActorClient::start_raw` with
621    // `content_type: Some("application/zip")`); JSON request bodies always pass this check.
622    let content_type = request
623        .headers
624        .iter()
625        .find(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
626        .map(|(_, v)| v.as_str());
627    if !is_compressible_content_type(content_type) {
628        return;
629    }
630
631    let (encoding, compressed) = match compression {
632        RequestCompression::Brotli => (CONTENT_ENCODING_BROTLI, brotli_compress(body)),
633        RequestCompression::Gzip => (CONTENT_ENCODING_GZIP, gzip_compress(body)),
634    };
635    request
636        .headers
637        .insert("Content-Encoding".to_string(), encoding.to_string());
638    request.body = Some(compressed);
639}
640
641/// Decides whether a request body with the given content type is worth compressing.
642///
643/// Images, audio, video and archives already carry their own compression: running them through
644/// brotli or gzip burns CPU, holds a second full copy of the body in memory, and usually produces
645/// output no smaller than the input (sometimes larger). Formats that are raw despite such a media
646/// type, e.g. `image/bmp` or `audio/wav`, are still compressed. A body with no content type is
647/// assumed to be compressible. Matches the reference client's `isCompressibleContentType`.
648fn is_compressible_content_type(content_type: Option<&str>) -> bool {
649    let Some(content_type) = content_type else {
650        return true;
651    };
652    // `Content-Type` is case-insensitive and may carry parameters, e.g. `text/plain; charset=utf-8`.
653    let media_type = content_type
654        .split(';')
655        .next()
656        .unwrap_or(content_type)
657        .trim()
658        .to_ascii_lowercase();
659
660    if COMPRESSIBLE_MEDIA_TYPES.contains(&media_type.as_str()) {
661        return true;
662    }
663    if COMPRESSIBLE_MEDIA_TYPE_SUFFIXES
664        .iter()
665        .any(|suffix| media_type.ends_with(suffix))
666    {
667        return true;
668    }
669    if ALREADY_COMPRESSED_MEDIA_TYPES.contains(&media_type.as_str()) {
670        return false;
671    }
672    !ALREADY_COMPRESSED_MEDIA_TYPE_PREFIXES
673        .iter()
674        .any(|prefix| media_type.starts_with(prefix))
675}
676
677/// Brotli-compresses `data`. Writing to an in-memory `Vec` is infallible, so this cannot fail.
678fn brotli_compress(data: &[u8]) -> Vec<u8> {
679    use std::io::Write;
680
681    let mut writer = brotli::CompressorWriter::new(
682        Vec::new(),
683        BROTLI_BUFFER_SIZE,
684        BROTLI_QUALITY,
685        BROTLI_WINDOW_SIZE,
686    );
687    writer
688        .write_all(data)
689        .expect("writing to an in-memory Vec never fails");
690    writer.into_inner()
691}
692
693/// Gzip-compresses `data`. Writing to and finishing an in-memory `Vec` is infallible, so this
694/// cannot fail.
695fn gzip_compress(data: &[u8]) -> Vec<u8> {
696    use flate2::{write::GzEncoder, Compression};
697    use std::io::Write;
698
699    let mut encoder = GzEncoder::new(Vec::new(), Compression::new(GZIP_COMPRESSION_LEVEL));
700    encoder
701        .write_all(data)
702        .expect("writing to an in-memory Vec never fails");
703    encoder
704        .finish()
705        .expect("finishing an in-memory Vec never fails")
706}
707
708/// Returns the path + query portion of a URL, for error reporting.
709fn extract_path(url: &str) -> Option<String> {
710    // Find the start of the path after the scheme+host.
711    let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
712    after_scheme
713        .find('/')
714        .map(|idx| after_scheme[idx..].to_string())
715}
716
717/// We retry `429` (rate limit) and `5xx` (internal server errors), matching the
718/// reference client policy. Other `4xx` statuses are caller errors and are not retried.
719fn is_status_retryable(status: u16) -> bool {
720    status == RATE_LIMIT_EXCEEDED_STATUS_CODE || status >= MIN_SERVER_ERROR_STATUS_CODE
721}
722
723/// Only transport-level failures are retryable. Programming errors (serde, invalid
724/// argument) and already-classified API errors are handled elsewhere, so they are not
725/// retried here — matching the reference clients, which retry only network/timeout errors.
726fn is_error_retryable(err: &ApifyClientError) -> bool {
727    matches!(err, ApifyClientError::Http(_) | ApifyClientError::Timeout)
728}
729
730/// Parses the API error body (if present) into an [`ApiError`].
731fn build_api_error(
732    response: &HttpResponse,
733    attempt: u32,
734    method: &str,
735    path: &Option<String>,
736) -> ApiError {
737    let parsed: Option<ApiErrorBody> = serde_json::from_slice(&response.body).ok();
738    let (error_type, message, data) = match parsed {
739        Some(body) => (
740            body.error.error_type,
741            body.error
742                .message
743                .unwrap_or_else(|| format!("Unexpected error with status {}", response.status)),
744            body.error.data,
745        ),
746        None => {
747            let raw = String::from_utf8_lossy(&response.body);
748            let message = if raw.trim().is_empty() {
749                format!("Unexpected error with status {}", response.status)
750            } else {
751                format!("Unexpected error: {raw}")
752            };
753            (None, message, None)
754        }
755    };
756
757    ApiError {
758        status_code: response.status,
759        error_type,
760        message,
761        attempt,
762        http_method: Some(method.to_string()),
763        path: path.clone(),
764        data,
765    }
766}
767
768/// Returns a delay chosen randomly from the interval `[delay, 2*delay)`, matching the
769/// exponential-backoff-with-jitter algorithm described in the API docs.
770///
771/// `pub(crate)` so other retrying call sites (e.g. `RequestQueueClient::batch_add_requests`'s
772/// unprocessed-request retries) can reuse the same jitter source instead of duplicating it.
773pub(crate) fn randomized_delay(delay: Duration) -> Duration {
774    let base = delay.as_millis() as u64;
775    if base == 0 {
776        return delay;
777    }
778    let extra = next_jitter() % base;
779    Duration::from_millis(base + extra)
780}
781
782/// A process-wide pseudo-random source for backoff jitter.
783///
784/// Backoff jitter does not need cryptographic quality, but it must be well-distributed and
785/// uncorrelated across concurrent retries (otherwise many clients retry in lockstep). A
786/// shared atomically-advanced SplitMix64 generator, seeded once from the clock, gives each
787/// caller a distinct value without pulling in a heavyweight RNG dependency.
788fn next_jitter() -> u64 {
789    use std::sync::atomic::{AtomicU64, Ordering};
790    static STATE: AtomicU64 = AtomicU64::new(0);
791
792    const GOLDEN_GAMMA: u64 = 0x9E3779B97F4A7C15;
793
794    // Lazily seed from the clock on first use. A racing double-seed is harmless: both
795    // candidate seeds are valid SplitMix64 stream starting points.
796    if STATE.load(Ordering::Relaxed) == 0 {
797        let seed = std::time::SystemTime::now()
798            .duration_since(std::time::UNIX_EPOCH)
799            .map(|d| d.as_nanos() as u64)
800            .unwrap_or(GOLDEN_GAMMA)
801            | 1;
802        let _ = STATE.compare_exchange(0, seed, Ordering::Relaxed, Ordering::Relaxed);
803    }
804
805    // SplitMix64: advance the shared state by the golden-ratio increment in a single atomic
806    // read-modify-write (`fetch_add`) so concurrent callers each observe a distinct value —
807    // a plain load-then-store could hand two racing retries the same number. Then scramble.
808    let mut z = STATE
809        .fetch_add(GOLDEN_GAMMA, Ordering::Relaxed)
810        .wrapping_add(GOLDEN_GAMMA);
811    z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
812    z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
813    z ^ (z >> 31)
814}
815
816/// Sleeps for the given duration (public crate-internal helper for poll loops).
817pub(crate) async fn sleep_public(duration: Duration) {
818    sleep(duration).await;
819}
820
821/// Sleeps for the given duration using the Tokio timer (the runtime `reqwest` requires).
822async fn sleep(duration: Duration) {
823    tokio::time::sleep(duration).await;
824}
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829    use futures_util::StreamExt;
830
831    /// After one inactivity timeout, [`with_inactivity_timeout`] must end the stream rather than
832    /// retrying the stalled read forever: a chunk that would only arrive after the stall must
833    /// never be yielded, and a third poll past the timeout error must return `None`.
834    #[tokio::test(start_paused = true)]
835    async fn with_inactivity_timeout_ends_after_one_stall() {
836        let timeout = Duration::from_millis(10);
837        let body: std::pin::Pin<
838            Box<dyn futures_util::Stream<Item = ApifyClientResult<Vec<u8>>> + Send>,
839        > = Box::pin(futures_util::stream::unfold(0u8, move |n| async move {
840            match n {
841                0 => Some((Ok(b"chunk".to_vec()), 1)),
842                // Stall well past the inactivity timeout before the stream would otherwise
843                // continue, simulating a connection that goes quiet.
844                1 => {
845                    tokio::time::sleep(timeout * 10).await;
846                    Some((Ok(b"should never be seen".to_vec()), 2))
847                }
848                _ => None,
849            }
850        }));
851
852        let mut timed = with_inactivity_timeout(body, timeout);
853
854        let first = timed.next().await;
855        assert!(
856            matches!(&first, Some(Ok(b)) if b == b"chunk"),
857            "expected the first chunk, got {first:?}"
858        );
859
860        let second = timed.next().await;
861        assert!(
862            matches!(second, Some(Err(ApifyClientError::Timeout))),
863            "expected a timeout error after the stall, got {second:?}"
864        );
865
866        let third = timed.next().await;
867        assert!(
868            third.is_none(),
869            "the stream must end after the inactivity timeout instead of retrying forever, got {third:?}"
870        );
871    }
872}