11from __future__ import annotations
22
3+ import asyncio
34import gzip
45import json as jsonlib
56import logging
67import os
8+ import random
79import sys
10+ import time
811from datetime import datetime , timezone
912from http import HTTPStatus
1013from importlib import metadata
11- from typing import TYPE_CHECKING , Any
14+ from typing import TYPE_CHECKING , Any , TypeVar
1215from urllib .parse import urlencode
1316
1417import impit
1518
1619from apify_client ._logging import log_context , logger_name
1720from apify_client ._statistics import ClientStatistics
18- from apify_client ._utils import is_retryable_error , retry_with_exp_backoff , retry_with_exp_backoff_async
19- from apify_client .errors import ApifyApiError
21+ from apify_client .errors import ApifyApiError , InvalidResponseBodyError
2022
2123if TYPE_CHECKING :
22- from collections .abc import Callable
24+ from collections .abc import Awaitable , Callable
2325
2426 from apify_client ._config import ClientConfig
2527 from apify_client ._consts import JsonSerializable
2628
2729DEFAULT_BACKOFF_EXPONENTIAL_FACTOR = 2
2830DEFAULT_BACKOFF_RANDOM_FACTOR = 1
2931
32+ T = TypeVar ('T' )
33+
3034logger = logging .getLogger (logger_name )
3135
3236
@@ -99,6 +103,26 @@ def _parse_params(params: dict | None) -> dict | None:
99103
100104 return parsed_params
101105
106+ @staticmethod
107+ def _is_retryable_error (exc : Exception ) -> bool :
108+ """Check if an exception should be retried.
109+
110+ Args:
111+ exc: The exception to check.
112+
113+ Returns:
114+ True if the exception is retryable (network errors, timeouts, etc.).
115+ """
116+ return isinstance (
117+ exc ,
118+ (
119+ InvalidResponseBodyError ,
120+ impit .NetworkError ,
121+ impit .TimeoutException ,
122+ impit .RemoteProtocolError ,
123+ ),
124+ )
125+
102126 def _prepare_request_call (
103127 self ,
104128 headers : dict | None = None ,
@@ -201,7 +225,7 @@ def _make_request(stop_retrying: Callable, attempt: int) -> impit.Response:
201225
202226 except Exception as exc :
203227 logger .debug ('Request threw exception' , exc_info = exc )
204- if not is_retryable_error (exc ):
228+ if not self . _is_retryable_error (exc ):
205229 logger .debug ('Exception is not retryable' , exc_info = exc )
206230 stop_retrying ()
207231 raise
@@ -217,14 +241,59 @@ def _make_request(stop_retrying: Callable, attempt: int) -> impit.Response:
217241 response .read ()
218242 raise ApifyApiError (response , attempt , method = method )
219243
220- return retry_with_exp_backoff (
244+ return self . _retry_with_exp_backoff (
221245 _make_request ,
222246 max_retries = self ._config .max_retries ,
223247 backoff_base_millis = self ._config .min_delay_between_retries_millis ,
224248 backoff_factor = DEFAULT_BACKOFF_EXPONENTIAL_FACTOR ,
225249 random_factor = DEFAULT_BACKOFF_RANDOM_FACTOR ,
226250 )
227251
252+ @staticmethod
253+ def _retry_with_exp_backoff (
254+ func : Callable [[Callable [[], None ], int ], T ],
255+ * ,
256+ max_retries : int = 8 ,
257+ backoff_base_millis : int = 500 ,
258+ backoff_factor : float = 2 ,
259+ random_factor : float = 1 ,
260+ ) -> T :
261+ """Retry a function with exponential backoff.
262+
263+ Args:
264+ func: Function to retry. Receives a stop_retrying callback and attempt number.
265+ max_retries: Maximum number of retry attempts.
266+ backoff_base_millis: Base backoff delay in milliseconds.
267+ backoff_factor: Exponential backoff multiplier (1-10).
268+ random_factor: Random jitter factor (0-1).
269+
270+ Returns:
271+ The return value of the function.
272+ """
273+ random_factor = min (max (0 , random_factor ), 1 )
274+ backoff_factor = min (max (1 , backoff_factor ), 10 )
275+ swallow = True
276+
277+ def stop_retrying () -> None :
278+ nonlocal swallow
279+ swallow = False
280+
281+ for attempt in range (1 , max_retries + 1 ):
282+ try :
283+ return func (stop_retrying , attempt )
284+ except Exception :
285+ if not swallow :
286+ raise
287+
288+ random_sleep_factor = random .uniform (1 , 1 + random_factor )
289+ backoff_base_secs = backoff_base_millis / 1000
290+ backoff_exp_factor = backoff_factor ** (attempt - 1 )
291+
292+ sleep_time_secs = random_sleep_factor * backoff_base_secs * backoff_exp_factor
293+ time .sleep (sleep_time_secs )
294+
295+ return func (stop_retrying , max_retries + 1 )
296+
228297
229298class HttpClientAsync (_BaseHttpClient ):
230299 async def call (
@@ -279,7 +348,7 @@ async def _make_request(stop_retrying: Callable, attempt: int) -> impit.Response
279348
280349 except Exception as exc :
281350 logger .debug ('Request threw exception' , exc_info = exc )
282- if not is_retryable_error (exc ):
351+ if not self . _is_retryable_error (exc ):
283352 logger .debug ('Exception is not retryable' , exc_info = exc )
284353 stop_retrying ()
285354 raise
@@ -295,10 +364,55 @@ async def _make_request(stop_retrying: Callable, attempt: int) -> impit.Response
295364 await response .aread ()
296365 raise ApifyApiError (response , attempt , method = method )
297366
298- return await retry_with_exp_backoff_async (
367+ return await self . _retry_with_exp_backoff (
299368 _make_request ,
300369 max_retries = self ._config .max_retries ,
301370 backoff_base_millis = self ._config .min_delay_between_retries_millis ,
302371 backoff_factor = DEFAULT_BACKOFF_EXPONENTIAL_FACTOR ,
303372 random_factor = DEFAULT_BACKOFF_RANDOM_FACTOR ,
304373 )
374+
375+ @staticmethod
376+ async def _retry_with_exp_backoff (
377+ func : Callable [[Callable [[], None ], int ], Awaitable [T ]],
378+ * ,
379+ max_retries : int = 8 ,
380+ backoff_base_millis : int = 500 ,
381+ backoff_factor : float = 2 ,
382+ random_factor : float = 1 ,
383+ ) -> T :
384+ """Retry a function with exponential backoff.
385+
386+ Args:
387+ func: Function to retry. Receives a stop_retrying callback and attempt number.
388+ max_retries: Maximum number of retry attempts.
389+ backoff_base_millis: Base backoff delay in milliseconds.
390+ backoff_factor: Exponential backoff multiplier (1-10).
391+ random_factor: Random jitter factor (0-1).
392+
393+ Returns:
394+ The return value of the function.
395+ """
396+ random_factor = min (max (0 , random_factor ), 1 )
397+ backoff_factor = min (max (1 , backoff_factor ), 10 )
398+ swallow = True
399+
400+ def stop_retrying () -> None :
401+ nonlocal swallow
402+ swallow = False
403+
404+ for attempt in range (1 , max_retries + 1 ):
405+ try :
406+ return await func (stop_retrying , attempt )
407+ except Exception :
408+ if not swallow :
409+ raise
410+
411+ random_sleep_factor = random .uniform (1 , 1 + random_factor )
412+ backoff_base_secs = backoff_base_millis / 1000
413+ backoff_exp_factor = backoff_factor ** (attempt - 1 )
414+
415+ sleep_time_secs = random_sleep_factor * backoff_base_secs * backoff_exp_factor
416+ await asyncio .sleep (sleep_time_secs )
417+
418+ return await func (stop_retrying , max_retries + 1 )
0 commit comments