Skip to content

Commit e43e9f9

Browse files
committed
HTTP client refactor
1 parent df73e6e commit e43e9f9

File tree

9 files changed

+327
-194
lines changed

9 files changed

+327
-194
lines changed

src/apify_client/_client_registry.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
"""Client classes configuration for dependency injection."""
2-
31
from __future__ import annotations
42

53
from dataclasses import dataclass

src/apify_client/_config.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,3 @@
1-
"""Client configuration module.
2-
3-
This module provides the ClientConfig dataclass that encapsulates all
4-
configuration options for the Apify API client.
5-
"""
6-
71
from __future__ import annotations
82

93
from dataclasses import dataclass

src/apify_client/_consts.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
"""Constants and enums used by the Apify client."""
2-
31
from __future__ import annotations
42

53
from enum import Enum

src/apify_client/_http_clients/_async.py

Lines changed: 140 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -6,97 +6,172 @@
66
from http import HTTPStatus
77
from typing import TYPE_CHECKING, Any, TypeVar
88

9+
import impit
10+
911
from apify_client._http_clients._base import BaseHttpClient
1012
from apify_client._logging import log_context, logger_name
1113
from apify_client.errors import ApifyApiError
1214

1315
if TYPE_CHECKING:
1416
from collections.abc import Awaitable, Callable
1517

16-
import impit
17-
18+
from apify_client._config import ClientConfig
1819
from apify_client._consts import JsonSerializable
20+
from apify_client._statistics import ClientStatistics
1921

2022
T = TypeVar('T')
2123

2224
logger = logging.getLogger(logger_name)
2325

2426

2527
class HttpClientAsync(BaseHttpClient):
28+
"""Asynchronous HTTP client for Apify API with automatic retries and exponential backoff."""
29+
30+
def __init__(self, config: ClientConfig, statistics: ClientStatistics | None = None) -> None:
31+
"""Initialize the asynchronous HTTP client.
32+
33+
Args:
34+
config: Client configuration with API URL, token, timeout, and retry settings.
35+
statistics: Statistics tracker for API calls. Created automatically if not provided.
36+
"""
37+
super().__init__(config, statistics)
38+
39+
self._impit_async_client = impit.AsyncClient(
40+
headers=self._headers,
41+
follow_redirects=True,
42+
timeout=self._config.timeout_secs,
43+
)
44+
2645
async def call(
2746
self,
2847
*,
2948
method: str,
3049
url: str,
31-
headers: dict | None = None,
32-
params: dict | None = None,
50+
headers: dict[str, str] | None = None,
51+
params: dict[str, Any] | None = None,
3352
data: Any = None,
3453
json: JsonSerializable | None = None,
3554
stream: bool | None = None,
3655
timeout_secs: int | None = None,
3756
) -> impit.Response:
38-
log_context.method.set(method)
39-
log_context.url.set(url)
57+
"""Make an HTTP request with automatic retry and exponential backoff.
4058
41-
self._statistics.calls += 1
59+
Args:
60+
method: HTTP method (GET, POST, PUT, DELETE, etc.).
61+
url: Full URL to make the request to.
62+
headers: Additional headers to include.
63+
params: Query parameters to append to the URL.
64+
data: Raw request body data. Cannot be used together with json.
65+
json: JSON-serializable data for the request body. Cannot be used together with data.
66+
stream: Whether to stream the response body.
67+
timeout_secs: Timeout override for this request.
4268
43-
headers, params, content = self._prepare_request_call(headers, params, data, json)
69+
Returns:
70+
The HTTP response object.
4471
45-
impit_async_client = self.impit_async_client
72+
Raises:
73+
ApifyApiError: If the request fails after all retries or returns a non-retryable error status.
74+
ValueError: If both json and data are provided.
75+
"""
76+
log_context.method.set(method)
77+
log_context.url.set(url)
4678

47-
async def _make_request(stop_retrying: Callable, attempt: int) -> impit.Response:
48-
log_context.attempt.set(attempt)
49-
logger.debug('Sending request')
50-
try:
51-
# Increase timeout with each attempt. Max timeout is bounded by the client timeout.
52-
timeout = min(
53-
self._config.timeout_secs, (timeout_secs or self._config.timeout_secs) * 2 ** (attempt - 1)
54-
)
55-
56-
url_with_params = self._build_url_with_params(url, params)
57-
58-
response = await impit_async_client.request(
59-
method=method,
60-
url=url_with_params,
61-
headers=headers,
62-
content=content,
63-
timeout=timeout,
64-
stream=stream or False,
65-
)
66-
67-
# If response status is < 300, the request was successful, and we can return the result
68-
if response.status_code < 300: # noqa: PLR2004
69-
logger.debug('Request successful', extra={'status_code': response.status_code})
70-
71-
return response
72-
73-
if response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
74-
self._statistics.add_rate_limit_error(attempt)
75-
76-
except Exception as exc:
77-
logger.debug('Request threw exception', exc_info=exc)
78-
if not self._is_retryable_error(exc):
79-
logger.debug('Exception is not retryable', exc_info=exc)
80-
stop_retrying()
81-
raise
82-
83-
# We want to retry only requests which are server errors (status >= 500) and could resolve on their own,
84-
# and also retry rate limited requests that throw 429 Too Many Requests errors
85-
logger.debug('Request unsuccessful', extra={'status_code': response.status_code})
86-
if response.status_code < 500 and response.status_code != HTTPStatus.TOO_MANY_REQUESTS: # noqa: PLR2004
87-
logger.debug('Status code is not retryable', extra={'status_code': response.status_code})
88-
stop_retrying()
79+
self._statistics.calls += 1
8980

90-
# Read the response in case it is a stream, so we can raise the error properly
91-
await response.aread()
92-
raise ApifyApiError(response, attempt, method=method)
81+
prepared_headers, prepared_params, content = self._prepare_request_call(headers, params, data, json)
9382

9483
return await self._retry_with_exp_backoff(
95-
_make_request,
84+
lambda stop_retrying, attempt: self._make_request(
85+
stop_retrying=stop_retrying,
86+
attempt=attempt,
87+
method=method,
88+
url=url,
89+
headers=prepared_headers,
90+
params=prepared_params,
91+
content=content,
92+
stream=stream,
93+
timeout_secs=timeout_secs,
94+
),
9695
max_retries=self._config.max_retries,
9796
backoff_base_millis=self._config.min_delay_between_retries_millis,
9897
)
9998

99+
async def _make_request(
100+
self,
101+
*,
102+
stop_retrying: Callable[[], None],
103+
attempt: int,
104+
method: str,
105+
url: str,
106+
headers: dict[str, str],
107+
params: dict[str, Any] | None,
108+
content: Any,
109+
stream: bool | None,
110+
timeout_secs: int | None,
111+
) -> impit.Response:
112+
"""Execute a single HTTP request attempt.
113+
114+
Args:
115+
stop_retrying: Callback to signal that retries should stop.
116+
attempt: Current attempt number (1-indexed).
117+
method: HTTP method.
118+
url: Request URL.
119+
headers: Request headers.
120+
params: Query parameters.
121+
content: Request body content.
122+
stream: Whether to stream the response.
123+
timeout_secs: Timeout override for this request.
124+
125+
Returns:
126+
The HTTP response object.
127+
128+
Raises:
129+
ApifyApiError: If the request fails with an error status.
130+
"""
131+
log_context.attempt.set(attempt)
132+
logger.debug('Sending request')
133+
134+
self._statistics.requests += 1
135+
136+
try:
137+
url_with_params = self._build_url_with_params(url, params)
138+
139+
response = await self._impit_async_client.request(
140+
method=method,
141+
url=url_with_params,
142+
headers=headers,
143+
content=content,
144+
timeout=self._calculate_timeout(attempt, timeout_secs),
145+
stream=stream or False,
146+
)
147+
148+
if response.status_code < HTTPStatus.MULTIPLE_CHOICES:
149+
logger.debug('Request successful', extra={'status_code': response.status_code})
150+
return response
151+
152+
if response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
153+
self._statistics.add_rate_limit_error(attempt)
154+
155+
except Exception as exc:
156+
logger.debug('Request threw exception', exc_info=exc)
157+
if not self._is_retryable_error(exc):
158+
logger.debug('Exception is not retryable', exc_info=exc)
159+
stop_retrying()
160+
raise
161+
162+
# Retry only server errors (5xx) and rate limits (429).
163+
logger.debug('Request unsuccessful', extra={'status_code': response.status_code})
164+
if (
165+
response.status_code < HTTPStatus.INTERNAL_SERVER_ERROR
166+
and response.status_code != HTTPStatus.TOO_MANY_REQUESTS
167+
):
168+
logger.debug('Status code is not retryable', extra={'status_code': response.status_code})
169+
stop_retrying()
170+
171+
# Read the response in case it is a stream, so we can raise the error properly.
172+
await response.aread()
173+
raise ApifyApiError(response, attempt, method=method)
174+
100175
@staticmethod
101176
async def _retry_with_exp_backoff(
102177
func: Callable[[Callable[[], None], int], Awaitable[T]],
@@ -106,17 +181,20 @@ async def _retry_with_exp_backoff(
106181
backoff_factor: float = 2,
107182
random_factor: float = 1,
108183
) -> T:
109-
"""Retry a function with exponential backoff.
184+
"""Retry an async function with exponential backoff and jitter.
110185
111186
Args:
112-
func: Function to retry. Receives a stop_retrying callback and attempt number.
113-
max_retries: Maximum number of retry attempts.
114-
backoff_base_millis: Base backoff delay in milliseconds.
115-
backoff_factor: Exponential backoff multiplier (1-10).
116-
random_factor: Random jitter factor (0-1).
187+
func: Async function to retry. Receives (stop_retrying callback, attempt number).
188+
max_retries: Maximum retry attempts.
189+
backoff_base_millis: Base delay in milliseconds.
190+
backoff_factor: Exponential multiplier (clamped to 1-10).
191+
random_factor: Jitter factor (clamped to 0-1).
117192
118193
Returns:
119-
The return value of the function.
194+
The function's return value on success.
195+
196+
Raises:
197+
Exception: Re-raises the last exception if all retries fail or stop_retrying is called.
120198
"""
121199
random_factor = min(max(0, random_factor), 1)
122200
backoff_factor = min(max(1, backoff_factor), 10)

0 commit comments

Comments
 (0)