Skip to content

Commit eaeabb2

Browse files
feat(client): send retry count header (#170)
1 parent 8bf96b0 commit eaeabb2

2 files changed

Lines changed: 56 additions & 47 deletions

File tree

src/runloop_api_client/_base_client.py

Lines changed: 54 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -400,14 +400,7 @@ def _make_status_error(
400400
) -> _exceptions.APIStatusError:
401401
raise NotImplementedError()
402402

403-
def _remaining_retries(
404-
self,
405-
remaining_retries: Optional[int],
406-
options: FinalRequestOptions,
407-
) -> int:
408-
return remaining_retries if remaining_retries is not None else options.get_max_retries(self.max_retries)
409-
410-
def _build_headers(self, options: FinalRequestOptions) -> httpx.Headers:
403+
def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0) -> httpx.Headers:
411404
custom_headers = options.headers or {}
412405
headers_dict = _merge_mappings(self.default_headers, custom_headers)
413406
self._validate_headers(headers_dict, custom_headers)
@@ -419,6 +412,8 @@ def _build_headers(self, options: FinalRequestOptions) -> httpx.Headers:
419412
if idempotency_header and options.method.lower() != "get" and idempotency_header not in headers:
420413
headers[idempotency_header] = options.idempotency_key or self._idempotency_key()
421414

415+
headers.setdefault("x-stainless-retry-count", str(retries_taken))
416+
422417
return headers
423418

424419
def _prepare_url(self, url: str) -> URL:
@@ -440,6 +435,8 @@ def _make_sse_decoder(self) -> SSEDecoder | SSEBytesDecoder:
440435
def _build_request(
441436
self,
442437
options: FinalRequestOptions,
438+
*,
439+
retries_taken: int = 0,
443440
) -> httpx.Request:
444441
if log.isEnabledFor(logging.DEBUG):
445442
log.debug("Request options: %s", model_dump(options, exclude_unset=True))
@@ -455,7 +452,7 @@ def _build_request(
455452
else:
456453
raise RuntimeError(f"Unexpected JSON data type, {type(json_data)}, cannot merge with `extra_body`")
457454

458-
headers = self._build_headers(options)
455+
headers = self._build_headers(options, retries_taken=retries_taken)
459456
params = _merge_mappings(self.default_query, options.params)
460457
content_type = headers.get("Content-Type")
461458
files = options.files
@@ -938,20 +935,25 @@ def request(
938935
stream: bool = False,
939936
stream_cls: type[_StreamT] | None = None,
940937
) -> ResponseT | _StreamT:
938+
if remaining_retries is not None:
939+
retries_taken = options.get_max_retries(self.max_retries) - remaining_retries
940+
else:
941+
retries_taken = 0
942+
941943
return self._request(
942944
cast_to=cast_to,
943945
options=options,
944946
stream=stream,
945947
stream_cls=stream_cls,
946-
remaining_retries=remaining_retries,
948+
retries_taken=retries_taken,
947949
)
948950

949951
def _request(
950952
self,
951953
*,
952954
cast_to: Type[ResponseT],
953955
options: FinalRequestOptions,
954-
remaining_retries: int | None,
956+
retries_taken: int,
955957
stream: bool,
956958
stream_cls: type[_StreamT] | None,
957959
) -> ResponseT | _StreamT:
@@ -963,8 +965,8 @@ def _request(
963965
cast_to = self._maybe_override_cast_to(cast_to, options)
964966
options = self._prepare_options(options)
965967

966-
retries = self._remaining_retries(remaining_retries, options)
967-
request = self._build_request(options)
968+
remaining_retries = options.get_max_retries(self.max_retries) - retries_taken
969+
request = self._build_request(options, retries_taken=retries_taken)
968970
self._prepare_request(request)
969971

970972
kwargs: HttpxSendArgs = {}
@@ -982,11 +984,11 @@ def _request(
982984
except httpx.TimeoutException as err:
983985
log.debug("Encountered httpx.TimeoutException", exc_info=True)
984986

985-
if retries > 0:
987+
if remaining_retries > 0:
986988
return self._retry_request(
987989
input_options,
988990
cast_to,
989-
retries,
991+
retries_taken=retries_taken,
990992
stream=stream,
991993
stream_cls=stream_cls,
992994
response_headers=None,
@@ -997,11 +999,11 @@ def _request(
997999
except Exception as err:
9981000
log.debug("Encountered Exception", exc_info=True)
9991001

1000-
if retries > 0:
1002+
if remaining_retries > 0:
10011003
return self._retry_request(
10021004
input_options,
10031005
cast_to,
1004-
retries,
1006+
retries_taken=retries_taken,
10051007
stream=stream,
10061008
stream_cls=stream_cls,
10071009
response_headers=None,
@@ -1024,13 +1026,13 @@ def _request(
10241026
except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code
10251027
log.debug("Encountered httpx.HTTPStatusError", exc_info=True)
10261028

1027-
if retries > 0 and self._should_retry(err.response):
1029+
if remaining_retries > 0 and self._should_retry(err.response):
10281030
err.response.close()
10291031
return self._retry_request(
10301032
input_options,
10311033
cast_to,
1032-
retries,
1033-
err.response.headers,
1034+
retries_taken=retries_taken,
1035+
response_headers=err.response.headers,
10341036
stream=stream,
10351037
stream_cls=stream_cls,
10361038
)
@@ -1049,26 +1051,26 @@ def _request(
10491051
response=response,
10501052
stream=stream,
10511053
stream_cls=stream_cls,
1052-
retries_taken=options.get_max_retries(self.max_retries) - retries,
1054+
retries_taken=retries_taken,
10531055
)
10541056

10551057
def _retry_request(
10561058
self,
10571059
options: FinalRequestOptions,
10581060
cast_to: Type[ResponseT],
1059-
remaining_retries: int,
1060-
response_headers: httpx.Headers | None,
10611061
*,
1062+
retries_taken: int,
1063+
response_headers: httpx.Headers | None,
10621064
stream: bool,
10631065
stream_cls: type[_StreamT] | None,
10641066
) -> ResponseT | _StreamT:
1065-
remaining = remaining_retries - 1
1066-
if remaining == 1:
1067+
remaining_retries = options.get_max_retries(self.max_retries) - retries_taken
1068+
if remaining_retries == 1:
10671069
log.debug("1 retry left")
10681070
else:
1069-
log.debug("%i retries left", remaining)
1071+
log.debug("%i retries left", remaining_retries)
10701072

1071-
timeout = self._calculate_retry_timeout(remaining, options, response_headers)
1073+
timeout = self._calculate_retry_timeout(remaining_retries, options, response_headers)
10721074
log.info("Retrying request to %s in %f seconds", options.url, timeout)
10731075

10741076
# In a synchronous context we are blocking the entire thread. Up to the library user to run the client in a
@@ -1078,7 +1080,7 @@ def _retry_request(
10781080
return self._request(
10791081
options=options,
10801082
cast_to=cast_to,
1081-
remaining_retries=remaining,
1083+
retries_taken=retries_taken + 1,
10821084
stream=stream,
10831085
stream_cls=stream_cls,
10841086
)
@@ -1496,12 +1498,17 @@ async def request(
14961498
stream_cls: type[_AsyncStreamT] | None = None,
14971499
remaining_retries: Optional[int] = None,
14981500
) -> ResponseT | _AsyncStreamT:
1501+
if remaining_retries is not None:
1502+
retries_taken = options.get_max_retries(self.max_retries) - remaining_retries
1503+
else:
1504+
retries_taken = 0
1505+
14991506
return await self._request(
15001507
cast_to=cast_to,
15011508
options=options,
15021509
stream=stream,
15031510
stream_cls=stream_cls,
1504-
remaining_retries=remaining_retries,
1511+
retries_taken=retries_taken,
15051512
)
15061513

15071514
async def _request(
@@ -1511,7 +1518,7 @@ async def _request(
15111518
*,
15121519
stream: bool,
15131520
stream_cls: type[_AsyncStreamT] | None,
1514-
remaining_retries: int | None,
1521+
retries_taken: int,
15151522
) -> ResponseT | _AsyncStreamT:
15161523
if self._platform is None:
15171524
# `get_platform` can make blocking IO calls so we
@@ -1526,8 +1533,8 @@ async def _request(
15261533
cast_to = self._maybe_override_cast_to(cast_to, options)
15271534
options = await self._prepare_options(options)
15281535

1529-
retries = self._remaining_retries(remaining_retries, options)
1530-
request = self._build_request(options)
1536+
remaining_retries = options.get_max_retries(self.max_retries) - retries_taken
1537+
request = self._build_request(options, retries_taken=retries_taken)
15311538
await self._prepare_request(request)
15321539

15331540
kwargs: HttpxSendArgs = {}
@@ -1543,11 +1550,11 @@ async def _request(
15431550
except httpx.TimeoutException as err:
15441551
log.debug("Encountered httpx.TimeoutException", exc_info=True)
15451552

1546-
if retries > 0:
1553+
if remaining_retries > 0:
15471554
return await self._retry_request(
15481555
input_options,
15491556
cast_to,
1550-
retries,
1557+
retries_taken=retries_taken,
15511558
stream=stream,
15521559
stream_cls=stream_cls,
15531560
response_headers=None,
@@ -1558,11 +1565,11 @@ async def _request(
15581565
except Exception as err:
15591566
log.debug("Encountered Exception", exc_info=True)
15601567

1561-
if retries > 0:
1568+
if retries_taken > 0:
15621569
return await self._retry_request(
15631570
input_options,
15641571
cast_to,
1565-
retries,
1572+
retries_taken=retries_taken,
15661573
stream=stream,
15671574
stream_cls=stream_cls,
15681575
response_headers=None,
@@ -1580,13 +1587,13 @@ async def _request(
15801587
except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code
15811588
log.debug("Encountered httpx.HTTPStatusError", exc_info=True)
15821589

1583-
if retries > 0 and self._should_retry(err.response):
1590+
if remaining_retries > 0 and self._should_retry(err.response):
15841591
await err.response.aclose()
15851592
return await self._retry_request(
15861593
input_options,
15871594
cast_to,
1588-
retries,
1589-
err.response.headers,
1595+
retries_taken=retries_taken,
1596+
response_headers=err.response.headers,
15901597
stream=stream,
15911598
stream_cls=stream_cls,
15921599
)
@@ -1605,34 +1612,34 @@ async def _request(
16051612
response=response,
16061613
stream=stream,
16071614
stream_cls=stream_cls,
1608-
retries_taken=options.get_max_retries(self.max_retries) - retries,
1615+
retries_taken=retries_taken,
16091616
)
16101617

16111618
async def _retry_request(
16121619
self,
16131620
options: FinalRequestOptions,
16141621
cast_to: Type[ResponseT],
1615-
remaining_retries: int,
1616-
response_headers: httpx.Headers | None,
16171622
*,
1623+
retries_taken: int,
1624+
response_headers: httpx.Headers | None,
16181625
stream: bool,
16191626
stream_cls: type[_AsyncStreamT] | None,
16201627
) -> ResponseT | _AsyncStreamT:
1621-
remaining = remaining_retries - 1
1622-
if remaining == 1:
1628+
remaining_retries = options.get_max_retries(self.max_retries) - retries_taken
1629+
if remaining_retries == 1:
16231630
log.debug("1 retry left")
16241631
else:
1625-
log.debug("%i retries left", remaining)
1632+
log.debug("%i retries left", remaining_retries)
16261633

1627-
timeout = self._calculate_retry_timeout(remaining, options, response_headers)
1634+
timeout = self._calculate_retry_timeout(remaining_retries, options, response_headers)
16281635
log.info("Retrying request to %s in %f seconds", options.url, timeout)
16291636

16301637
await anyio.sleep(timeout)
16311638

16321639
return await self._request(
16331640
options=options,
16341641
cast_to=cast_to,
1635-
remaining_retries=remaining,
1642+
retries_taken=retries_taken + 1,
16361643
stream=stream,
16371644
stream_cls=stream_cls,
16381645
)

tests/test_client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,7 @@ def retry_handler(_request: httpx.Request) -> httpx.Response:
781781
response = client.devboxes.with_raw_response.create()
782782

783783
assert response.retries_taken == failures_before_success
784+
assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success
784785

785786

786787
class TestAsyncRunloop:
@@ -1521,3 +1522,4 @@ def retry_handler(_request: httpx.Request) -> httpx.Response:
15211522
response = await client.devboxes.with_raw_response.create()
15221523

15231524
assert response.retries_taken == failures_before_success
1525+
assert int(response.http_request.headers.get("x-stainless-retry-count")) == failures_before_success

0 commit comments

Comments
 (0)