Skip to content

Commit 452cc26

Browse files
Add retry logic and backoff mechanism for client requests
- Implemented exponential backoff with jitter for retryable errors. - Added `max_retries` parameter to clients for configurable retry limits. - Enhanced error handling for transport, timeout, and server-side errors. - Updated unit tests to validate retry behavior, including success after retries and error propagation after retry limits. - Covered edge cases, such as negative retry limits and proper delay calculations. Assisted-by: Codex
1 parent 16881dc commit 452cc26

2 files changed

Lines changed: 241 additions & 18 deletions

File tree

src/pdfrest/client.py

Lines changed: 150 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,29 @@
66
import importlib.metadata
77
import json
88
import os
9+
import random
10+
import time
911
import 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+
)
1120
from contextlib import ExitStack
1221
from os import PathLike
1322
from 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

1633
import httpx
1734
from httpx import URL
@@ -21,6 +38,10 @@
2138
PdfRestApiError,
2239
PdfRestAuthenticationError,
2340
PdfRestConfigurationError,
41+
PdfRestError,
42+
PdfRestRequestError,
43+
PdfRestTimeoutError,
44+
PdfRestTransportError,
2445
translate_httpx_error,
2546
)
2647
from .models import (
@@ -65,6 +86,10 @@
6586
DEFAULT_READ_TIMEOUT_SECONDS = 120.0
6687
FILE_UPLOAD_FIELD_NAME = "file"
6788
DEFAULT_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

6994
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
7095
QueryParamValue = str | int | float | bool | None
@@ -245,6 +270,7 @@ def _normalize_file_id(file_ref: PdfRestFileID | str) -> PdfRestFileID:
245270

246271

247272
ClientType = TypeVar("ClientType", httpx.Client, httpx.AsyncClient)
273+
ReturnType = TypeVar("ReturnType")
248274

249275

250276
class _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

Comments
 (0)