Skip to content

Commit b45a81f

Browse files
Retry only unused streams without rewinding
- To prevent complexity, the file stream and its position belong to the caller. - Any failure that happens after the connection starts might have read from the stream. After that, doing the retry is the responsibility of the caller. - Removed complex stream tracking logic in favor of a simplified `_has_stream_uploads` attribute. - Introduced exceptions `PdfRestConnectTimeoutError` and `PdfRestPoolTimeoutError` for granularity in timeout scenarios, including new cases for retry logic. - Updated `translate_httpx_error` method for finer mapping between `httpx` exceptions and retry behavior. - Enhanced `_contains_open_stream` method for better stream detection in nested structures. - Updated tests for refined handling of retries and no-retry cases, including transport errors, timeouts, and server errors. Assisted-by: Codex
1 parent 9186eb7 commit b45a81f

3 files changed

Lines changed: 383 additions & 176 deletions

File tree

src/pdfrest/client.py

Lines changed: 37 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@
4848
PdfRestApiError,
4949
PdfRestAuthenticationError,
5050
PdfRestConfigurationError,
51+
PdfRestConnectTimeoutError,
5152
PdfRestError,
53+
PdfRestPoolTimeoutError,
5254
PdfRestRequestError,
5355
PdfRestTimeoutError,
5456
PdfRestTransportError,
@@ -101,11 +103,6 @@
101103
MAX_BACKOFF_SECONDS = 8.0
102104
BACKOFF_JITTER_SECONDS = 0.1
103105
RETRYABLE_STATUS_CODES = {408, 425, 429, 499}
104-
FileStreamSnapshot = tuple[IO[Any], int]
105-
106-
107-
def _empty_snapshot_list() -> list[FileStreamSnapshot]:
108-
return []
109106

110107

111108
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
@@ -423,11 +420,7 @@ class _RequestModel(BaseModel):
423420

424421
model_config = ConfigDict(arbitrary_types_allowed=True)
425422

426-
_stream_snapshots: list[FileStreamSnapshot] = PrivateAttr(
427-
default_factory=_empty_snapshot_list
428-
)
429-
_stream_tracking_ready: bool = PrivateAttr(default=False)
430-
_has_non_rewindable_streams: bool = PrivateAttr(default=False)
423+
_has_stream_uploads: bool = PrivateAttr(default=False)
431424

432425
@field_validator("endpoint")
433426
@classmethod
@@ -437,25 +430,11 @@ def _validate_endpoint(cls, value: str) -> str:
437430
raise PdfRestConfigurationError(msg)
438431
return value
439432

440-
def stream_tracking_ready(self) -> bool:
441-
return self._stream_tracking_ready
442-
443-
def mark_stream_tracking_ready(self) -> None:
444-
self._stream_tracking_ready = True
445-
446-
def has_non_rewindable_streams(self) -> bool:
447-
return self._has_non_rewindable_streams
433+
def mark_has_stream_uploads(self) -> None:
434+
self._has_stream_uploads = True
448435

449-
def mark_non_rewindable_streams(self) -> None:
450-
self._has_non_rewindable_streams = True
451-
self._stream_snapshots.clear()
452-
self._stream_tracking_ready = True
453-
454-
def record_stream_snapshot(self, stream: IO[Any], position: int) -> None:
455-
self._stream_snapshots.append((stream, position))
456-
457-
def stream_snapshots(self) -> tuple[FileStreamSnapshot, ...]:
458-
return tuple(self._stream_snapshots)
436+
def has_stream_uploads(self) -> bool:
437+
return self._has_stream_uploads
459438

460439

461440
class _BaseApiClient(Generic[ClientType]):
@@ -658,6 +637,8 @@ def _prepare_request(
658637
raise
659638
except ValidationError as exc: # pragma: no cover - defensive
660639
raise PdfRestConfigurationError(str(exc)) from exc
640+
if self._contains_open_stream(files_payload):
641+
request.mark_has_stream_uploads()
661642
return request
662643

663644
def prepare_request(
@@ -725,109 +706,35 @@ def _compose_json_body(
725706
return payload
726707

727708
@staticmethod
728-
def _iterate_file_like_objects(value: Any) -> Iterator[IO[Any]]:
709+
def _contains_open_stream(value: Any) -> bool:
729710
if value is None:
730-
return
731-
if hasattr(value, "read"):
732-
yield cast(IO[Any], value)
733-
return
711+
return False
734712
if isinstance(value, (bytes, bytearray, str)):
735-
return
713+
return False
714+
if hasattr(value, "read"):
715+
return True
736716
if isinstance(value, Mapping):
737-
for item in value.values():
738-
yield from _BaseApiClient._iterate_file_like_objects(item)
739-
return
740-
if isinstance(value, Sequence):
741-
for item in value:
742-
yield from _BaseApiClient._iterate_file_like_objects(item)
717+
return any(
718+
_BaseApiClient._contains_open_stream(item) for item in value.values()
719+
)
720+
if isinstance(value, Sequence) and not isinstance(
721+
value, (str, bytes, bytearray)
722+
):
723+
return any(_BaseApiClient._contains_open_stream(item) for item in value)
724+
return False
743725

744-
def _log_non_rewindable_stream(self, request: _RequestModel, reason: str) -> None:
745-
self._logger.error(
746-
"Cannot retry %s because %s",
747-
self._describe_request(request),
748-
reason,
749-
)
726+
def _build_stream_retry_checker(
727+
self, request: _RequestModel
728+
) -> Callable[[PdfRestError], bool] | None:
729+
if not request.has_stream_uploads():
730+
return None
750731

751-
def _capture_file_stream_positions(self, request: _RequestModel) -> None:
752-
if request.stream_tracking_ready():
753-
return
754-
if request.files is None or self._max_retries == 0:
755-
request.mark_stream_tracking_ready()
756-
return
757-
snapshots = request.stream_snapshots()
758-
seen_ids: set[int] = {id(stream) for stream, _ in snapshots}
759-
for stream in self._iterate_file_like_objects(request.files):
760-
stream_id = id(stream)
761-
if stream_id in seen_ids:
762-
continue
763-
seek_fn = getattr(stream, "seek", None)
764-
tell_fn = getattr(stream, "tell", None)
765-
if not callable(seek_fn) or not callable(tell_fn):
766-
request.mark_non_rewindable_streams()
767-
self._log_non_rewindable_stream(
768-
request,
769-
"one or more upload streams do not provide seek/tell",
770-
)
771-
return
772-
try:
773-
position_value = tell_fn()
774-
except (OSError, ValueError):
775-
request.mark_non_rewindable_streams()
776-
self._log_non_rewindable_stream(
777-
request,
778-
"reading the current position failed for an upload stream",
779-
)
780-
return
781-
position = cast(int, position_value)
782-
request.record_stream_snapshot(stream, position)
783-
seen_ids.add(stream_id)
784-
request.mark_stream_tracking_ready()
785-
786-
def _rewind_stream_snapshots(self, request: _RequestModel) -> bool:
787-
for stream, position in request.stream_snapshots():
788-
seek_fn = getattr(stream, "seek", None)
789-
if not callable(seek_fn):
790-
request.mark_non_rewindable_streams()
791-
self._log_non_rewindable_stream(
792-
request,
793-
"one or more upload streams do not support seek",
794-
)
795-
return False
796-
try:
797-
seek_fn(position)
798-
except (OSError, ValueError) as exc: # pragma: no cover - defensive
799-
request.mark_non_rewindable_streams()
800-
self._log_non_rewindable_stream(
801-
request,
802-
"resetting an upload stream failed",
803-
)
804-
if self._logger.isEnabledFor(logging.DEBUG):
805-
self._logger.debug(
806-
"Failed to reset upload stream for %s: %s",
807-
self._describe_request(request),
808-
exc,
809-
)
810-
return False
811-
return True
812-
813-
def _prepare_request_files_for_attempt(
814-
self,
815-
request: _RequestModel,
816-
*,
817-
is_retry: bool,
818-
) -> bool:
819-
if request.files is None or self._max_retries == 0:
820-
return True
821-
if not request.stream_tracking_ready():
822-
self._capture_file_stream_positions(request)
823-
if not is_retry:
824-
return True
825-
if request.has_non_rewindable_streams():
826-
return False
827-
snapshots = request.stream_snapshots()
828-
if not snapshots:
829-
return True
830-
return self._rewind_stream_snapshots(request)
732+
def checker(exc: PdfRestError) -> bool:
733+
return bool(
734+
isinstance(exc, (PdfRestConnectTimeoutError, PdfRestPoolTimeoutError))
735+
)
736+
737+
return checker
831738

832739
def _handle_response(self, response: httpx.Response) -> Any:
833740
request = response.request
@@ -946,24 +853,13 @@ def _execute_with_retry(
946853
func: Callable[[], ReturnType],
947854
*,
948855
operation: str,
949-
before_attempt: Callable[[bool], bool] | None = None,
950856
should_continue: Callable[[PdfRestError], bool] | None = None,
951857
) -> ReturnType:
952858
total_attempts = self._max_retries + 1
953-
last_exception: PdfRestError | None = None
954859
for attempt in range(total_attempts):
955-
is_retry = attempt > 0
956-
if before_attempt is not None:
957-
can_continue = before_attempt(is_retry)
958-
if not can_continue:
959-
if last_exception is not None:
960-
raise last_exception
961-
msg = "Retry aborted before the initial attempt."
962-
raise RuntimeError(msg)
963860
try:
964861
return func()
965862
except PdfRestError as exc:
966-
last_exception = exc
967863
self._logger.debug(
968864
"Exception during %s attempt %d/%d: %s",
969865
operation,
@@ -992,13 +888,12 @@ def _execute_with_retry(
992888
def _send_request(self, request: _RequestModel) -> Any:
993889
http_client = self._client
994890

995-
def before_attempt(is_retry: bool) -> bool:
996-
return self._prepare_request_files_for_attempt(request, is_retry=is_retry)
891+
stream_retry_checker = self._build_stream_retry_checker(request)
997892

998893
return self._execute_with_retry(
999894
lambda: self._perform_request(http_client, request),
1000895
operation=self._describe_request(request),
1001-
before_attempt=before_attempt,
896+
should_continue=stream_retry_checker,
1002897
)
1003898

1004899
def _perform_request(
@@ -1205,24 +1100,13 @@ async def _execute_with_retry(
12051100
func: Callable[[], Awaitable[ReturnType]],
12061101
*,
12071102
operation: str,
1208-
before_attempt: Callable[[bool], bool] | None = None,
12091103
should_continue: Callable[[PdfRestError], bool] | None = None,
12101104
) -> ReturnType:
12111105
total_attempts = self._max_retries + 1
1212-
last_exception: PdfRestError | None = None
12131106
for attempt in range(total_attempts):
1214-
is_retry = attempt > 0
1215-
if before_attempt is not None:
1216-
can_continue = before_attempt(is_retry)
1217-
if not can_continue:
1218-
if last_exception is not None:
1219-
raise last_exception
1220-
msg = "Retry aborted before the initial attempt."
1221-
raise RuntimeError(msg)
12221107
try:
12231108
return await func()
12241109
except PdfRestError as exc:
1225-
last_exception = exc
12261110
self._logger.debug(
12271111
"Exception during %s attempt %d/%d: %s",
12281112
operation,
@@ -1251,13 +1135,12 @@ async def _execute_with_retry(
12511135
async def _send_request(self, request: _RequestModel) -> Any:
12521136
http_client = self._client
12531137

1254-
def before_attempt(is_retry: bool) -> bool:
1255-
return self._prepare_request_files_for_attempt(request, is_retry=is_retry)
1138+
stream_retry_checker = self._build_stream_retry_checker(request)
12561139

12571140
return await self._execute_with_retry(
12581141
lambda: self._perform_request(http_client, request),
12591142
operation=self._describe_request(request),
1260-
before_attempt=before_attempt,
1143+
should_continue=stream_retry_checker,
12611144
)
12621145

12631146
async def _perform_request(

src/pdfrest/exceptions.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
"PdfRestApiError",
1111
"PdfRestAuthenticationError",
1212
"PdfRestConfigurationError",
13+
"PdfRestConnectTimeoutError",
1314
"PdfRestError",
15+
"PdfRestPoolTimeoutError",
1416
"PdfRestRequestError",
1517
"PdfRestTimeoutError",
1618
"PdfRestTransportError",
@@ -30,6 +32,14 @@ class PdfRestTimeoutError(PdfRestError):
3032
"""Raised when a request to pdfrest exceeds the configured timeout."""
3133

3234

35+
class PdfRestConnectTimeoutError(PdfRestTimeoutError):
36+
"""Raised when the client cannot establish a connection before timeout."""
37+
38+
39+
class PdfRestPoolTimeoutError(PdfRestTimeoutError):
40+
"""Raised when the connection pool cannot provide a connection in time."""
41+
42+
3343
class PdfRestTransportError(PdfRestError):
3444
"""Raised when a transport-level error occurs while communicating with pdfrest."""
3545

@@ -69,6 +79,14 @@ class PdfRestAuthenticationError(PdfRestApiError):
6979
def translate_httpx_error(exc: httpx.HTTPError) -> PdfRestError:
7080
"""Convert an httpx exception into a library-specific exception."""
7181

82+
if isinstance(exc, httpx.ConnectTimeout):
83+
return PdfRestConnectTimeoutError(
84+
str(exc) or "Connection timed out while calling pdfRest."
85+
)
86+
if isinstance(exc, httpx.PoolTimeout):
87+
return PdfRestPoolTimeoutError(
88+
str(exc) or "Connection pool timed out while calling pdfRest."
89+
)
7290
if isinstance(exc, httpx.TimeoutException):
7391
return PdfRestTimeoutError(
7492
str(exc) or "Request timed out while calling pdfRest."

0 commit comments

Comments
 (0)