Skip to content

Commit e04cba4

Browse files
committed
fix: Reject the retries option with a custom transport
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y1RzepycXEYA3LStfjt8cY
1 parent cb6a798 commit e04cba4

3 files changed

Lines changed: 129 additions & 2 deletions

File tree

README.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,31 @@ and its ``Retry`` class is re-exported from ``seam`` for convenience:
682682
retries=Retry(total=3, backoff_factor=0.5, status_forcelist=[503]),
683683
)
684684
685+
Bringing your own transport
686+
+++++++++++++++++++++++++++
687+
688+
A custom ``transport`` or ``mounts`` passed through ``httpx_options`` replaces
689+
the transport the SDK builds, so it takes full responsibility for retries:
690+
requests through it are not retried unless you wrap it yourself. Combining
691+
either with the ``retries`` option raises a ``SeamInvalidOptionsError``. To
692+
retry through your own transport, wrap it with ``RetryTransport``:
693+
694+
.. code-block:: python
695+
696+
from httpx_retries import RetryTransport
697+
698+
from seam import Seam, Retry
699+
700+
seam = Seam(
701+
api_key="your-api-key",
702+
httpx_options={
703+
"transport": RetryTransport(
704+
transport=MyCustomTransport(),
705+
retry=Retry(total=2, status_forcelist=[429, 503]),
706+
),
707+
},
708+
)
709+
685710
Configuring the httpx client
686711
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
687712

seam/client.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
SeamHttpUnauthorizedError,
1515
)
1616
from .null import replace_null
17+
from .options import SeamInvalidOptionsError
1718
from .strict_url_search_params_serializer import serialize_url_search_params
1819

1920
SDK_HEADERS = {
@@ -105,14 +106,24 @@ def __init__(
105106
self,
106107
base_url: str,
107108
auth_headers: Dict[str, str],
108-
retries: Optional[Retry] = DEFAULT_RETRIES,
109+
retries: Optional[Retry] = None,
109110
timeout: Optional[float] = DEFAULT_TIMEOUT,
110111
httpx_options: Optional[Dict[str, Any]] = None,
111112
**kwargs,
112113
):
113114
options = _build_client_options(base_url, timeout, httpx_options, kwargs)
114115

115116
custom_headers = options.pop("headers", {})
117+
118+
if retries is not None and (
119+
options.get("transport") is not None or options.get("mounts") is not None
120+
):
121+
raise SeamInvalidOptionsError(
122+
"The retries option cannot be combined with a custom transport "
123+
"or mounts, which bypass the retry transport; wrap your "
124+
"transport with httpx_retries.RetryTransport instead"
125+
)
126+
116127
self._retry_policy = DEFAULT_RETRIES if retries is None else retries
117128

118129
super().__init__(**options)
@@ -170,14 +181,24 @@ def __init__(
170181
self,
171182
base_url: str,
172183
auth_headers: Dict[str, str],
173-
retries: Optional[Retry] = DEFAULT_RETRIES,
184+
retries: Optional[Retry] = None,
174185
timeout: Optional[float] = DEFAULT_TIMEOUT,
175186
httpx_options: Optional[Dict[str, Any]] = None,
176187
**kwargs,
177188
):
178189
options = _build_client_options(base_url, timeout, httpx_options, kwargs)
179190

180191
custom_headers = options.pop("headers", {})
192+
193+
if retries is not None and (
194+
options.get("transport") is not None or options.get("mounts") is not None
195+
):
196+
raise SeamInvalidOptionsError(
197+
"The retries option cannot be combined with a custom transport "
198+
"or mounts, which bypass the retry transport; wrap your "
199+
"transport with httpx_retries.RetryTransport instead"
200+
)
201+
181202
self._retry_policy = DEFAULT_RETRIES if retries is None else retries
182203

183204
super().__init__(**options)

test/custom_transport_test.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import httpx
2+
import pytest
3+
from httpx_retries import Retry, RetryTransport
4+
5+
from seam import AsyncSeam, Seam, SeamHttpApiError, SeamInvalidOptionsError
6+
7+
CONFLICT_MESSAGE = (
8+
"The retries option cannot be combined with a custom transport or mounts"
9+
)
10+
11+
12+
def test_retries_with_a_custom_transport_raises():
13+
with pytest.raises(SeamInvalidOptionsError, match=CONFLICT_MESSAGE):
14+
Seam(
15+
api_key="seam_apikey_token",
16+
retries=Retry(total=3),
17+
httpx_options={"transport": httpx.HTTPTransport()},
18+
)
19+
20+
21+
def test_retries_with_custom_mounts_raises():
22+
with pytest.raises(SeamInvalidOptionsError, match=CONFLICT_MESSAGE):
23+
Seam(
24+
api_key="seam_apikey_token",
25+
retries=Retry(total=3),
26+
httpx_options={
27+
"mounts": {"https://": httpx.HTTPTransport()},
28+
},
29+
)
30+
31+
32+
def test_retries_with_a_custom_transport_raises_async():
33+
with pytest.raises(SeamInvalidOptionsError, match=CONFLICT_MESSAGE):
34+
AsyncSeam(
35+
api_key="seam_apikey_token",
36+
retries=Retry(total=3),
37+
httpx_options={"transport": httpx.AsyncHTTPTransport()},
38+
)
39+
40+
41+
def test_a_custom_transport_is_not_retried(recording_server):
42+
with recording_server(
43+
[
44+
(503, {"error": {"type": "service_unavailable", "message": "Down"}}),
45+
(200, {"device": {"device_id": "x"}}),
46+
]
47+
) as (endpoint, requests):
48+
seam = Seam.from_api_key(
49+
"seam_apikey_token",
50+
endpoint=endpoint,
51+
httpx_options={"transport": httpx.HTTPTransport()},
52+
)
53+
54+
with pytest.raises(SeamHttpApiError):
55+
seam.devices.get(device_id="x")
56+
57+
assert len(requests) == 1
58+
59+
60+
def test_a_wrapped_custom_transport_retries(recording_server):
61+
with recording_server(
62+
[
63+
(503, {"error": {"type": "service_unavailable", "message": "Down"}}),
64+
(200, {"device": {"device_id": "x"}}),
65+
]
66+
) as (endpoint, requests):
67+
seam = Seam.from_api_key(
68+
"seam_apikey_token",
69+
endpoint=endpoint,
70+
httpx_options={
71+
"transport": RetryTransport(
72+
transport=httpx.HTTPTransport(),
73+
retry=Retry(total=2, status_forcelist=[503]),
74+
),
75+
},
76+
)
77+
78+
device = seam.devices.get(device_id="x")
79+
80+
assert device.device_id == "x"
81+
assert len(requests) == 2

0 commit comments

Comments
 (0)