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}