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