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}