66from http import HTTPStatus
77from typing import TYPE_CHECKING , Any , TypeVar
88
9+ import impit
10+
911from apify_client ._http_clients ._base import BaseHttpClient
1012from apify_client ._logging import log_context , logger_name
1113from apify_client .errors import ApifyApiError
1214
1315if 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
2022T = TypeVar ('T' )
2123
2224logger = logging .getLogger (logger_name )
2325
2426
2527class 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