Skip to content

Commit fabccc5

Browse files
committed
feat!: Replace niquests with httpx as the HTTP client
The niquests dependency urllib3-future installs into the urllib3 package namespace, which conflicts with packages depending on genuine urllib3 in customer environments and cannot be mitigated from the SDK. httpx is already in the dependency tree via svix and its dependency chain does not touch urllib3. BREAKING CHANGE: The niquests_options option is now httpx_options and is passed to the underlying httpx.Client. The retries option now takes a seam.Retry instead of a urllib3.util.Retry; the fields mirror the urllib3 names. Seam.client is now an httpx.Client subclass, and request errors are raised as httpx exceptions, e.g. httpx. HTTPStatusError instead of niquests.HTTPError and httpx. TimeoutException instead of niquests.exceptions.Timeout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QxwdLpWpFhxSn7mZ2Fb6p1
1 parent 91f6f30 commit fabccc5

12 files changed

Lines changed: 323 additions & 503 deletions

‎README.rst‎

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,9 @@ Contents
6969

7070
* `Setting the request timeout`_
7171

72-
* `Configuring the niquests session`_
72+
* `Configuring retries`_
73+
74+
* `Configuring the httpx client`_
7375

7476
* `Development and Testing`_
7577

@@ -464,20 +466,45 @@ Pass the ``timeout`` option, in seconds, to override this:
464466
465467
Setting it to ``None`` disables the timeout entirely.
466468

467-
A request that exceeds the timeout raises ``niquests.exceptions.Timeout``.
469+
A request that exceeds the timeout raises ``httpx.TimeoutException``.
470+
471+
Configuring retries
472+
^^^^^^^^^^^^^^^^^^^
473+
474+
Connection errors are retried automatically.
475+
Pass the ``retries`` option to customize retry behavior,
476+
e.g., to also retry requests answered with specific status codes:
468477

469-
Configuring the niquests session
470-
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
478+
.. code-block:: python
479+
480+
from seam import Seam, Retry
471481
472-
For control the options above do not cover, pass ``niquests_options``.
473-
These are handed to the underlying niquests ``Session`` and take
482+
seam = Seam(
483+
api_key="your-api-key",
484+
retries=Retry(
485+
total=3,
486+
backoff_factor=0.5,
487+
status_forcelist=[503],
488+
allowed_methods=["POST"],
489+
),
490+
)
491+
492+
Configuring the httpx client
493+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
494+
495+
For control the options above do not cover, pass ``httpx_options``.
496+
These are handed to the underlying httpx ``Client`` and take
474497
precedence over the defaults the SDK sets:
475498

476499
.. code-block:: python
477500
501+
import httpx
502+
478503
seam = Seam(
479504
api_key="your-api-key",
480-
niquests_options={"pool_connections": 20, "pool_maxsize": 25},
505+
httpx_options={
506+
"limits": httpx.Limits(max_connections=25, max_keepalive_connections=20),
507+
},
481508
)
482509
483510
Development and Testing

‎poetry.lock‎

Lines changed: 78 additions & 413 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ include = ["seam/py.typed"]
1313
[tool.poetry.dependencies]
1414
python = "^3.10.0"
1515
dataclasses-json = "^0.6.4"
16-
niquests = "^3.6.4"
16+
httpx = "^0.28.1"
1717
svix = "^1.24.0"
1818

1919
[tool.poetry.group.dev.dependencies]

‎seam/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from .seam import Seam
55
from .seam_without_workspace import SeamWithoutWorkspace
6+
from .retry import Retry
67
from .options import SeamInvalidOptionsError
78
from .auth import SeamInvalidTokenError
89
from .exceptions import (

‎seam/client.py‎

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
from typing import Any, Dict, Optional
2-
from urllib.parse import urljoin
3-
import niquests as requests
42
from importlib.metadata import version
5-
from inspect import signature
6-
from urllib3.util import Retry
73
import abc
84

5+
import httpx
6+
97
from .constants import DEFAULT_TIMEOUT, LTS_VERSION
8+
from .retry import Retry, RetryTransport
109
from .exceptions import (
1110
SeamHttpApiError,
1211
SeamHttpInvalidInputError,
@@ -21,10 +20,6 @@
2120

2221
DEFAULT_RETRIES = Retry()
2322

24-
NIQUESTS_TIMEOUT_DEFAULT = (
25-
signature(requests.Session.post).parameters["timeout"].default
26-
)
27-
2823

2924
class AbstractSeamHttpClient(abc.ABC):
3025
@abc.abstractmethod
@@ -36,55 +31,49 @@ def request(self, method: str, url: str, *args, **kwargs):
3631
raise NotImplementedError
3732

3833
@abc.abstractmethod
39-
def _handle_response(self, response: requests.Response):
34+
def _handle_response(self, response: httpx.Response):
4035
raise NotImplementedError
4136

4237
@abc.abstractmethod
43-
def _handle_error_response(self, response: requests.Response):
38+
def _handle_error_response(self, response: httpx.Response):
4439
raise NotImplementedError
4540

4641

47-
class SeamHttpClient(requests.Session, AbstractSeamHttpClient):
42+
class SeamHttpClient(httpx.Client, AbstractSeamHttpClient):
4843
def __init__(
4944
self,
5045
base_url: str,
5146
auth_headers: Dict[str, str],
5247
retries: Optional[Retry] = DEFAULT_RETRIES,
5348
timeout: Optional[float] = DEFAULT_TIMEOUT,
54-
niquests_options: Optional[Dict[str, Any]] = None,
49+
httpx_options: Optional[Dict[str, Any]] = None,
5550
**kwargs
5651
):
57-
# niquests.Session mounts its adapters while initializing, so retries
58-
# must be passed through here. Assigning self.retries afterwards leaves
59-
# the mounted adapters on their default and the option has no effect.
6052
options = {
61-
"retries": DEFAULT_RETRIES if retries is None else retries,
53+
"base_url": base_url,
54+
"timeout": timeout,
6255
**kwargs,
63-
**(niquests_options or {}),
56+
**(httpx_options or {}),
6457
}
6558

6659
custom_headers = options.pop("headers", {})
6760

68-
super().__init__(**options)
69-
70-
self.base_url = base_url
61+
if "transport" not in options:
62+
options["transport"] = RetryTransport(
63+
DEFAULT_RETRIES if retries is None else retries
64+
)
7165

72-
self.timeout = timeout
66+
super().__init__(**options)
7367

7468
headers = {**auth_headers, **custom_headers, **SDK_HEADERS}
7569
self.headers.update(headers)
7670

7771
def request(self, method, url, *args, **kwargs):
78-
url = urljoin(self.base_url, url)
79-
80-
if kwargs.get("timeout", NIQUESTS_TIMEOUT_DEFAULT) == NIQUESTS_TIMEOUT_DEFAULT:
81-
kwargs["timeout"] = self.timeout
82-
8372
response = super().request(method, url, *args, **kwargs)
8473

8574
return self._handle_response(response)
8675

87-
def _handle_response(self, response: requests.Response):
76+
def _handle_response(self, response: httpx.Response):
8877
if not 200 <= response.status_code < 300:
8978
self._handle_error_response(response)
9079

@@ -93,7 +82,7 @@ def _handle_response(self, response: requests.Response):
9382

9483
return response.text
9584

96-
def _handle_error_response(self, response: requests.Response):
85+
def _handle_error_response(self, response: httpx.Response):
9786
status_code = response.status_code
9887
request_id = response.headers.get("seam-request-id")
9988

@@ -120,7 +109,7 @@ def _handle_error_response(self, response: requests.Response):
120109
raise SeamHttpApiError(error_details, status_code, request_id)
121110

122111

123-
def is_api_error_response(response: requests.Response) -> bool:
112+
def is_api_error_response(response: httpx.Response) -> bool:
124113
try:
125114
content_type = response.headers.get("content-type", "")
126115

@@ -130,7 +119,7 @@ def is_api_error_response(response: requests.Response) -> bool:
130119
return False
131120

132121
data = response.json()
133-
except (ValueError, requests.exceptions.JSONDecodeError):
122+
except ValueError:
134123
return False
135124

136125
if not isinstance(data, dict):

‎seam/paginator.py‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from typing import Callable, Dict, Any, Tuple, Generator, List
2+
from json import JSONDecodeError
3+
from httpx import Response
24
from .client import SeamHttpClient
3-
from niquests import Response, JSONDecodeError
45
from .pagination import Pagination
56

67

@@ -34,11 +35,11 @@ def __init__(
3435

3536
def first_page(self) -> Tuple[List[Any], Pagination | None]:
3637
"""Fetches the first page of results."""
37-
self.client.hooks["response"].append(
38+
self.client.event_hooks["response"].append(
3839
lambda response: self._cache_pagination(response, self._FIRST_PAGE)
3940
)
4041
data = self._request(**self._params)
41-
self.client.hooks["response"].pop()
42+
self.client.event_hooks["response"].pop()
4243

4344
pagination = self._pagination_cache.get(self._FIRST_PAGE)
4445

@@ -56,11 +57,11 @@ def next_page(
5657
"page_cursor": next_page_cursor,
5758
}
5859

59-
self.client.hooks["response"].append(
60+
self.client.event_hooks["response"].append(
6061
lambda response: self._cache_pagination(response, next_page_cursor)
6162
)
6263
data = self._request(**params)
63-
self.client.hooks["response"].pop()
64+
self.client.event_hooks["response"].pop()
6465

6566
pagination = self._pagination_cache.get(next_page_cursor)
6667

@@ -95,6 +96,8 @@ def flatten(self) -> Generator[Any, None, None]:
9596
def _cache_pagination(self, response: Response, page_key: str) -> None:
9697
"""Extracts pagination dict from response, creates Pagination object, and caches it."""
9798
try:
99+
# httpx response hooks fire before the response body is read.
100+
response.read()
98101
response_json = response.json()
99102
pagination = response_json.get("pagination", {})
100103
except JSONDecodeError:

‎seam/retry.py‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import time
2+
from dataclasses import dataclass
3+
from email.utils import parsedate_to_datetime
4+
from typing import FrozenSet, Optional
5+
6+
import httpx
7+
8+
# Methods safe to retry by default. The Seam API is POST-only, so pass
9+
# allowed_methods=["POST"] to opt in to retrying API requests on status.
10+
DEFAULT_ALLOWED_METHODS = frozenset(
11+
["HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE"]
12+
)
13+
14+
# Statuses retried when the response includes a Retry-After header,
15+
# even if they are not in the status_forcelist.
16+
RETRY_AFTER_STATUS_CODES = frozenset([413, 429, 503])
17+
18+
# Never sleep longer than this between attempts, in seconds.
19+
BACKOFF_MAX = 120
20+
21+
22+
@dataclass(frozen=True)
23+
class Retry:
24+
"""Retry behavior for failed requests.
25+
26+
The fields mirror urllib3.util.Retry, which previous SDK versions
27+
accepted for the retries option.
28+
29+
:ivar total: Number of retries allowed after the initial request.
30+
Set to 0 to disable retries.
31+
:ivar backoff_factor: Sleep backoff_factor * (2 ** retry_number)
32+
seconds between attempts, where retry_number starts at 0.
33+
:ivar status_forcelist: Response status codes to retry.
34+
:ivar allowed_methods: HTTP methods eligible for retry on status.
35+
Connection errors are always eligible, as the request never
36+
reached the server. Pass None to allow all methods.
37+
:ivar respect_retry_after_header: Honor the Retry-After header on
38+
413, 429, and 503 responses.
39+
"""
40+
41+
total: int = 10
42+
backoff_factor: float = 0
43+
status_forcelist: Optional[FrozenSet[int]] = None
44+
allowed_methods: Optional[FrozenSet[str]] = DEFAULT_ALLOWED_METHODS
45+
respect_retry_after_header: bool = True
46+
47+
def __post_init__(self):
48+
if self.status_forcelist is not None:
49+
object.__setattr__(
50+
self, "status_forcelist", frozenset(self.status_forcelist)
51+
)
52+
if self.allowed_methods is not None:
53+
object.__setattr__(
54+
self,
55+
"allowed_methods",
56+
frozenset(method.upper() for method in self.allowed_methods),
57+
)
58+
59+
def is_retryable_response(self, method: str, response: httpx.Response) -> bool:
60+
if (
61+
self.allowed_methods is not None
62+
and method.upper() not in self.allowed_methods
63+
):
64+
return False
65+
66+
if (
67+
self.status_forcelist is not None
68+
and response.status_code in self.status_forcelist
69+
):
70+
return True
71+
72+
return (
73+
self.respect_retry_after_header
74+
and "retry-after" in response.headers
75+
and response.status_code in RETRY_AFTER_STATUS_CODES
76+
)
77+
78+
def get_backoff_time(self, retry_number: int) -> float:
79+
return min(BACKOFF_MAX, self.backoff_factor * (2**retry_number))
80+
81+
82+
class RetryTransport(httpx.BaseTransport):
83+
"""Transport that retries requests according to a Retry configuration."""
84+
85+
def __init__(self, retries: Retry, transport: Optional[httpx.BaseTransport] = None):
86+
self.retries = retries
87+
self.transport = transport if transport is not None else httpx.HTTPTransport()
88+
89+
def handle_request(self, request: httpx.Request) -> httpx.Response:
90+
retries_left = self.retries.total
91+
retry_number = 0
92+
93+
while True:
94+
try:
95+
response = self.transport.handle_request(request)
96+
except (httpx.ConnectError, httpx.ConnectTimeout):
97+
if retries_left <= 0:
98+
raise
99+
retry_after = None
100+
else:
101+
if retries_left <= 0 or not self.retries.is_retryable_response(
102+
request.method, response
103+
):
104+
return response
105+
retry_after = parse_retry_after(response)
106+
response.close()
107+
108+
self._sleep(retry_number, retry_after)
109+
retries_left -= 1
110+
retry_number += 1
111+
112+
def close(self) -> None:
113+
self.transport.close()
114+
115+
def _sleep(self, retry_number: int, retry_after: Optional[float]) -> None:
116+
backoff = self.retries.get_backoff_time(retry_number)
117+
118+
if retry_after is not None and self.retries.respect_retry_after_header:
119+
backoff = max(backoff, min(BACKOFF_MAX, retry_after))
120+
121+
if backoff > 0:
122+
time.sleep(backoff)
123+
124+
125+
def parse_retry_after(response: httpx.Response) -> Optional[float]:
126+
retry_after = response.headers.get("retry-after")
127+
128+
if retry_after is None:
129+
return None
130+
131+
if retry_after.isdigit():
132+
return float(retry_after)
133+
134+
try:
135+
retry_date = parsedate_to_datetime(retry_after)
136+
except (TypeError, ValueError):
137+
return None
138+
139+
return max(0.0, retry_date.timestamp() - time.time())

0 commit comments

Comments
 (0)