66import importlib .metadata
77import json
88import os
9+ import random
10+ import time
911import uuid
10- from collections .abc import AsyncIterator , Iterator , Mapping , Sequence
12+ from collections .abc import (
13+ AsyncIterator ,
14+ Awaitable ,
15+ Callable ,
16+ Iterator ,
17+ Mapping ,
18+ Sequence ,
19+ )
1120from contextlib import ExitStack
1221from os import PathLike
1322from pathlib import Path
14- from typing import IO , Any , Generic , Literal , TypeAlias , TypeVar , cast
23+ from typing import (
24+ IO ,
25+ Any ,
26+ Generic ,
27+ Literal ,
28+ TypeAlias ,
29+ TypeVar ,
30+ cast ,
31+ )
1532
1633import httpx
1734from httpx import URL
2138 PdfRestApiError ,
2239 PdfRestAuthenticationError ,
2340 PdfRestConfigurationError ,
41+ PdfRestError ,
42+ PdfRestRequestError ,
43+ PdfRestTimeoutError ,
44+ PdfRestTransportError ,
2445 translate_httpx_error ,
2546)
2647from .models import (
6586DEFAULT_READ_TIMEOUT_SECONDS = 120.0
6687FILE_UPLOAD_FIELD_NAME = "file"
6788DEFAULT_FILE_INFO_CONCURRENCY = 8
89+ DEFAULT_MAX_RETRIES = 2
90+ INITIAL_BACKOFF_SECONDS = 0.5
91+ MAX_BACKOFF_SECONDS = 8.0
92+ BACKOFF_JITTER_SECONDS = 0.1
6893
6994HttpMethod = Literal ["GET" , "POST" , "PUT" , "PATCH" , "DELETE" , "OPTIONS" , "HEAD" ]
7095QueryParamValue = str | int | float | bool | None
@@ -245,6 +270,7 @@ def _normalize_file_id(file_ref: PdfRestFileID | str) -> PdfRestFileID:
245270
246271
247272ClientType = TypeVar ("ClientType" , httpx .Client , httpx .AsyncClient )
273+ ReturnType = TypeVar ("ReturnType" )
248274
249275
250276class _ClientConfig (BaseModel ):
@@ -341,7 +367,12 @@ def __init__(
341367 base_url : str | URL | None = None ,
342368 timeout : TimeoutTypes | None = None ,
343369 headers : AnyMapping | None = None ,
370+ max_retries : int = DEFAULT_MAX_RETRIES ,
344371 ) -> None :
372+ if not isinstance (max_retries , int ) or max_retries < 0 :
373+ msg = "max_retries must be a non-negative integer."
374+ raise PdfRestConfigurationError (msg )
375+ self ._max_retries = max_retries
345376 raw_api_key = api_key if api_key is not None else os .getenv (API_KEY_ENV_VAR )
346377 resolved_api_key = (
347378 raw_api_key .strip () if raw_api_key and raw_api_key .strip () else None
@@ -387,6 +418,27 @@ def __init__(
387418 except ValidationError as exc : # pragma: no cover - defensive
388419 raise PdfRestConfigurationError (str (exc )) from exc
389420
421+ @staticmethod
422+ def _is_retryable_status (status_code : int ) -> bool :
423+ return status_code == 429 or 500 <= status_code < 600
424+
425+ def _should_retry_exception (self , exc : PdfRestError ) -> bool :
426+ if isinstance (exc , PdfRestApiError ):
427+ return self ._is_retryable_status (exc .status_code )
428+ return isinstance (
429+ exc , (PdfRestTimeoutError , PdfRestTransportError , PdfRestRequestError )
430+ )
431+
432+ def _compute_backoff_delay (self , retry_number : int ) -> float :
433+ base_delay = min (
434+ INITIAL_BACKOFF_SECONDS * (2 ** retry_number ),
435+ MAX_BACKOFF_SECONDS ,
436+ )
437+ # ignoring S311 because this isn't being used for cryptography
438+ jitter = random .uniform (- BACKOFF_JITTER_SECONDS , BACKOFF_JITTER_SECONDS ) # noqa: S311
439+ delay = base_delay + jitter
440+ return delay if delay > 0 else 0.0
441+
390442 @staticmethod
391443 def _base_url_requires_api_key (url : URL ) -> bool :
392444 host = url .host or ""
@@ -567,12 +619,14 @@ def __init__(
567619 headers : AnyMapping | None = None ,
568620 http_client : httpx .Client | None = None ,
569621 transport : httpx .BaseTransport | None = None ,
622+ max_retries : int = DEFAULT_MAX_RETRIES ,
570623 ) -> None :
571624 super ().__init__ (
572625 api_key = api_key ,
573626 base_url = base_url ,
574627 timeout = timeout ,
575628 headers = headers ,
629+ max_retries = max_retries ,
576630 )
577631 self ._owns_http_client = http_client is None
578632 self ._client = http_client or httpx .Client (
@@ -592,8 +646,30 @@ def __enter__(self) -> _SyncApiClient:
592646 def __exit__ (self , exc_type : Any , exc : Any , traceback : Any ) -> None :
593647 self .close ()
594648
649+ def _execute_with_retry (self , func : Callable [[], ReturnType ]) -> ReturnType :
650+ for attempt in range (self ._max_retries + 1 ):
651+ try :
652+ return func ()
653+ except PdfRestError as exc :
654+ if attempt == self ._max_retries or not self ._should_retry_exception (
655+ exc
656+ ):
657+ raise
658+ delay = self ._compute_backoff_delay (attempt )
659+ if delay > 0 :
660+ time .sleep (delay )
661+ msg = "Retry loop exited unexpectedly."
662+ raise RuntimeError (msg ) # pragma: no cover
663+
595664 def _send_request (self , request : _RequestModel ) -> Any :
596665 http_client = self ._client
666+ return self ._execute_with_retry (
667+ lambda : self ._perform_request (http_client , request )
668+ )
669+
670+ def _perform_request (
671+ self , http_client : httpx .Client , request : _RequestModel
672+ ) -> Any :
597673 try :
598674 response = http_client .request (
599675 method = request .method ,
@@ -607,7 +683,13 @@ def _send_request(self, request: _RequestModel) -> Any:
607683 )
608684 except httpx .HTTPError as exc :
609685 raise translate_httpx_error (exc ) from exc
610- return self ._handle_response (response )
686+ try :
687+ payload = self ._handle_response (response )
688+ except PdfRestApiError :
689+ response .close ()
690+ raise
691+ response .close ()
692+ return payload
611693
612694 def _post_file_operation (
613695 self ,
@@ -668,13 +750,18 @@ def download_file(
668750 extra_headers : AnyMapping | None = None ,
669751 timeout : TimeoutTypes | None = None ,
670752 ) -> httpx .Response :
671- request = self .prepare_request (
753+ request_model = self .prepare_request (
672754 "GET" ,
673755 f"/resource/{ file_id } " ,
674756 extra_query = extra_query ,
675757 extra_headers = extra_headers ,
676758 timeout = timeout ,
677759 )
760+ return self ._execute_with_retry (
761+ lambda : self ._download_with_retry (request_model )
762+ )
763+
764+ def _download_with_retry (self , request : _RequestModel ) -> httpx .Response :
678765 http_request = self ._client .build_request (
679766 request .method ,
680767 request .endpoint ,
@@ -692,12 +779,14 @@ def download_file(
692779 response = self ._client .send (http_request , stream = True )
693780 except httpx .HTTPError as exc :
694781 raise translate_httpx_error (exc ) from exc
695- if not response .is_success :
696- try :
697- self ._handle_response (response )
698- finally :
699- response .close ()
700- return response
782+ if response .is_success :
783+ return response
784+ try :
785+ self ._handle_response (response )
786+ finally :
787+ response .close ()
788+ msg = "Unreachable"
789+ raise RuntimeError (msg ) # pragma: no cover
701790
702791 def fetch_file_info (
703792 self ,
@@ -734,12 +823,14 @@ def __init__(
734823 http_client : httpx .AsyncClient | None = None ,
735824 transport : httpx .AsyncBaseTransport | None = None ,
736825 concurrency_limit : int = DEFAULT_FILE_INFO_CONCURRENCY ,
826+ max_retries : int = DEFAULT_MAX_RETRIES ,
737827 ) -> None :
738828 super ().__init__ (
739829 api_key = api_key ,
740830 base_url = base_url ,
741831 timeout = timeout ,
742832 headers = headers ,
833+ max_retries = max_retries ,
743834 )
744835 self ._owns_http_client = http_client is None
745836 self ._client = http_client or httpx .AsyncClient (
@@ -760,8 +851,32 @@ async def __aenter__(self) -> _AsyncApiClient:
760851 async def __aexit__ (self , exc_type : Any , exc : Any , traceback : Any ) -> None :
761852 await self .aclose ()
762853
854+ async def _execute_with_retry (
855+ self , func : Callable [[], Awaitable [ReturnType ]]
856+ ) -> ReturnType :
857+ for attempt in range (self ._max_retries + 1 ):
858+ try :
859+ return await func ()
860+ except PdfRestError as exc :
861+ if attempt == self ._max_retries or not self ._should_retry_exception (
862+ exc
863+ ):
864+ raise
865+ delay = self ._compute_backoff_delay (attempt )
866+ if delay > 0 :
867+ await asyncio .sleep (delay )
868+ msg = "Retry loop exited unexpectedly."
869+ raise RuntimeError (msg ) # pragma: no cover
870+
763871 async def _send_request (self , request : _RequestModel ) -> Any :
764872 http_client = self ._client
873+ return await self ._execute_with_retry (
874+ lambda : self ._perform_request (http_client , request )
875+ )
876+
877+ async def _perform_request (
878+ self , http_client : httpx .AsyncClient , request : _RequestModel
879+ ) -> Any :
765880 try :
766881 response = await http_client .request (
767882 method = request .method ,
@@ -775,7 +890,13 @@ async def _send_request(self, request: _RequestModel) -> Any:
775890 )
776891 except httpx .HTTPError as exc :
777892 raise translate_httpx_error (exc ) from exc
778- return self ._handle_response (response )
893+ try :
894+ payload = self ._handle_response (response )
895+ except PdfRestApiError :
896+ await response .aclose ()
897+ raise
898+ await response .aclose ()
899+ return payload
779900
780901 async def _post_file_operation (
781902 self ,
@@ -844,13 +965,18 @@ async def download_file(
844965 extra_headers : AnyMapping | None = None ,
845966 timeout : TimeoutTypes | None = None ,
846967 ) -> httpx .Response :
847- request = self .prepare_request (
968+ request_model = self .prepare_request (
848969 "GET" ,
849970 f"/resource/{ file_id } " ,
850971 extra_query = extra_query ,
851972 extra_headers = extra_headers ,
852973 timeout = timeout ,
853974 )
975+ return await self ._execute_with_retry (
976+ lambda : self ._download_with_retry (request_model )
977+ )
978+
979+ async def _download_with_retry (self , request : _RequestModel ) -> httpx .Response :
854980 http_request = self ._client .build_request (
855981 request .method ,
856982 request .endpoint ,
@@ -868,12 +994,14 @@ async def download_file(
868994 response = await self ._client .send (http_request , stream = True )
869995 except httpx .HTTPError as exc :
870996 raise translate_httpx_error (exc ) from exc
871- if not response .is_success :
872- try :
873- self ._handle_response (response )
874- finally :
875- await response .aclose ()
876- return response
997+ if response .is_success :
998+ return response
999+ try :
1000+ self ._handle_response (response )
1001+ finally :
1002+ await response .aclose ()
1003+ msg = "Unreachable"
1004+ raise RuntimeError (msg ) # pragma: no cover
8771005
8781006 async def fetch_file_info (
8791007 self ,
@@ -1441,6 +1569,7 @@ def __init__(
14411569 headers : AnyMapping | None = None ,
14421570 http_client : httpx .Client | None = None ,
14431571 transport : httpx .BaseTransport | None = None ,
1572+ max_retries : int = DEFAULT_MAX_RETRIES ,
14441573 ) -> None :
14451574 """Create a synchronous pdfRest client."""
14461575
@@ -1451,6 +1580,7 @@ def __init__(
14511580 headers = headers ,
14521581 http_client = http_client ,
14531582 transport = transport ,
1583+ max_retries = max_retries ,
14541584 )
14551585 self ._files_client = _FilesClient (self )
14561586
@@ -1867,6 +1997,7 @@ def __init__(
18671997 http_client : httpx .AsyncClient | None = None ,
18681998 transport : httpx .AsyncBaseTransport | None = None ,
18691999 concurrency_limit : int = DEFAULT_FILE_INFO_CONCURRENCY ,
2000+ max_retries : int = DEFAULT_MAX_RETRIES ,
18702001 ) -> None :
18712002 """Create an asynchronous pdfRest client."""
18722003
@@ -1878,6 +2009,7 @@ def __init__(
18782009 http_client = http_client ,
18792010 transport = transport ,
18802011 concurrency_limit = concurrency_limit ,
2012+ max_retries = max_retries ,
18812013 )
18822014 self ._files_client = _AsyncFilesClient (self )
18832015
0 commit comments