Skip to content

Commit 6615efb

Browse files
committed
feat: default requests to a 30 second timeout
Requests had no timeout, so a hung connection blocked the caller indefinitely. niquests leaves `timeout` unset unless it is given. Pass a 30 second `timeout` to the niquests session, matching the API's own request timeout, and add a `timeout` option to `Seam` and `SeamMultiWorkspace` so callers can raise or lower it. The option takes the niquests forms: a number of seconds, a (connect, read) tuple, or None for no timeout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XMgDauUA2R9u2THCHmMgv1
1 parent b0bc49d commit 6615efb

6 files changed

Lines changed: 195 additions & 9 deletions

File tree

README.rst

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ Contents
6565

6666
* `Setting the endpoint`_
6767

68+
* `Setting the request timeout`_
69+
70+
* `Configuring the niquests session`_
71+
6872
* `Development and Testing`_
6973

7074
* `Quickstart`_
@@ -436,6 +440,36 @@ e.g., testing or proxy setups.
436440

437441
Either pass the ``endpoint`` option to the constructor, or set the ``SEAM_ENDPOINT`` environment variable.
438442

443+
Setting the request timeout
444+
^^^^^^^^^^^^^^^^^^^^^^^^^^^
445+
446+
Requests time out after 30 seconds by default.
447+
Pass the ``timeout`` option, in seconds, to override this:
448+
449+
.. code-block:: python
450+
451+
from seam import Seam
452+
453+
seam = Seam(api_key="your-api-key", timeout=60)
454+
455+
Setting it to ``None`` disables the timeout entirely.
456+
457+
A request that exceeds the timeout raises ``niquests.exceptions.Timeout``.
458+
459+
Configuring the niquests session
460+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
461+
462+
For control the options above do not cover, pass ``niquests_options``.
463+
These are handed to the underlying niquests ``Session`` and take
464+
precedence over the defaults the SDK sets:
465+
466+
.. code-block:: python
467+
468+
seam = Seam(
469+
api_key="your-api-key",
470+
niquests_options={"pool_maxsize": 25, "happy_eyeballs": True},
471+
)
472+
439473
Development and Testing
440474
-----------------------
441475

seam/client.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
from typing import Dict, Optional
1+
from typing import Any, Dict, Optional
22
from urllib.parse import urljoin
33
import niquests as requests
44
from importlib.metadata import version
5+
from inspect import signature
56
from urllib3.util import Retry
67
import abc
78

8-
from .constants import LTS_VERSION
9+
from .constants import DEFAULT_TIMEOUT, LTS_VERSION
910
from .exceptions import (
1011
SeamHttpApiError,
1112
SeamHttpInvalidInputError,
@@ -20,6 +21,10 @@
2021

2122
DEFAULT_RETRIES = Retry()
2223

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

2429
class AbstractSeamHttpClient(abc.ABC):
2530
@abc.abstractmethod
@@ -45,22 +50,36 @@ def __init__(
4550
base_url: str,
4651
auth_headers: Dict[str, str],
4752
retries: Optional[Retry] = DEFAULT_RETRIES,
53+
timeout: Optional[float] = DEFAULT_TIMEOUT,
54+
niquests_options: Optional[Dict[str, Any]] = None,
4855
**kwargs
4956
):
5057
# niquests.Session mounts its adapters while initializing, so retries
5158
# must be passed through here. Assigning self.retries afterwards leaves
5259
# the mounted adapters on their default and the option has no effect.
53-
super().__init__(
54-
retries=DEFAULT_RETRIES if retries is None else retries, **kwargs
55-
)
60+
options = {
61+
"retries": DEFAULT_RETRIES if retries is None else retries,
62+
**kwargs,
63+
**(niquests_options or {}),
64+
}
65+
66+
custom_headers = options.pop("headers", {})
67+
68+
super().__init__(**options)
5669

5770
self.base_url = base_url
5871

59-
headers = {**auth_headers, **kwargs.get("headers", {}), **SDK_HEADERS}
72+
self.timeout = timeout
73+
74+
headers = {**auth_headers, **custom_headers, **SDK_HEADERS}
6075
self.headers.update(headers)
6176

6277
def request(self, method, url, *args, **kwargs):
6378
url = urljoin(self.base_url, url)
79+
80+
if kwargs.get("timeout", NIQUESTS_TIMEOUT_DEFAULT) == NIQUESTS_TIMEOUT_DEFAULT:
81+
kwargs["timeout"] = self.timeout
82+
6483
response = super().request(method, url, *args, **kwargs)
6584

6685
return self._handle_response(response)

seam/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
LTS_VERSION = "1.0.0"
22

33
DEFAULT_ENDPOINT = "https://connect.getseam.com"
4+
5+
DEFAULT_TIMEOUT = 30

seam/seam.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing_extensions import Self
33
from urllib3.util.retry import Retry
44

5-
from .constants import LTS_VERSION
5+
from .constants import DEFAULT_TIMEOUT, LTS_VERSION
66
from .parse_options import parse_options
77
from .routes import Routes
88
from .models import AbstractSeam
@@ -42,6 +42,8 @@ def __init__(
4242
endpoint: Optional[str] = None,
4343
wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = True,
4444
retries: Optional[Retry] = None,
45+
timeout: Optional[float] = DEFAULT_TIMEOUT,
46+
niquests_options: Optional[Dict[str, Any]] = None,
4547
):
4648
"""Initialize a Seam client instance.
4749
@@ -66,6 +68,12 @@ def __init__(
6668
:type wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]]
6769
:param retries: Configuration for retry behavior on failed requests
6870
:type retries: Optional[urllib3.util.Retry]
71+
:param timeout: The request timeout in seconds. Defaults to 30
72+
seconds. Pass None for no timeout
73+
:type timeout: Optional[float]
74+
:param niquests_options: Options passed through to the underlying
75+
niquests Session, for control the other options do not cover
76+
:type niquests_options: Optional[Dict[str, Any]]
6977
7078
:raises SeamInvalidOptionsError: If neither api_key nor
7179
personal_access_token is provided, or if workspace_id is missing
@@ -85,7 +93,11 @@ def __init__(
8593
self.defaults = {"wait_for_action_attempt": wait_for_action_attempt}
8694

8795
self.client = SeamHttpClient(
88-
base_url=endpoint, auth_headers=auth_headers, retries=retries
96+
base_url=endpoint,
97+
auth_headers=auth_headers,
98+
retries=retries,
99+
timeout=timeout,
100+
niquests_options=niquests_options,
89101
)
90102

91103
Routes.__init__(self, client=self.client, defaults=self.defaults)
@@ -123,6 +135,8 @@ def from_api_key(
123135
endpoint: Optional[str] = None,
124136
wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = True,
125137
retries: Optional[Retry] = None,
138+
timeout: Optional[float] = DEFAULT_TIMEOUT,
139+
niquests_options: Optional[Dict[str, Any]] = None,
126140
) -> Self:
127141
"""Create a Seam instance using an API key.
128142
@@ -151,6 +165,8 @@ def from_api_key(
151165
endpoint=endpoint,
152166
wait_for_action_attempt=wait_for_action_attempt,
153167
retries=retries,
168+
timeout=timeout,
169+
niquests_options=niquests_options,
154170
)
155171

156172
@classmethod
@@ -162,6 +178,8 @@ def from_personal_access_token(
162178
endpoint: Optional[str] = None,
163179
wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = True,
164180
retries: Optional[Retry] = None,
181+
timeout: Optional[float] = DEFAULT_TIMEOUT,
182+
niquests_options: Optional[Dict[str, Any]] = None,
165183
) -> Self:
166184
"""Create a Seam instance using a personal access token.
167185
@@ -194,4 +212,6 @@ def from_personal_access_token(
194212
endpoint=endpoint,
195213
wait_for_action_attempt=wait_for_action_attempt,
196214
retries=retries,
215+
timeout=timeout,
216+
niquests_options=niquests_options,
197217
)

seam/seam_multi_workspace.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from urllib3.util import Retry
55

66
from .auth import get_auth_headers_for_multi_workspace_personal_access_token
7-
from .constants import LTS_VERSION
7+
from .constants import DEFAULT_TIMEOUT, LTS_VERSION
88
from .options import get_endpoint
99
from .client import SeamHttpClient
1010
from .models import AbstractSeamMultiWorkspace
@@ -52,6 +52,8 @@ def __init__(
5252
endpoint: Optional[str] = None,
5353
wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = True,
5454
retries: Optional[Retry] = None,
55+
timeout: Optional[float] = DEFAULT_TIMEOUT,
56+
niquests_options: Optional[Dict[str, Any]] = None,
5557
):
5658
"""
5759
Initialize a SeamMultiWorkspace client instance.
@@ -71,6 +73,12 @@ def __init__(
7173
:type wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]]
7274
:param retries: Configuration for retry behavior on failed requests
7375
:type retries: Optional[urllib3.util.Retry]
76+
:param timeout: The request timeout in seconds. Defaults to 30
77+
seconds. Pass None for no timeout
78+
:type timeout: Optional[float]
79+
:param niquests_options: Options passed through to the underlying
80+
niquests Session, for control the other options do not cover
81+
:type niquests_options: Optional[Dict[str, Any]]
7482
7583
:raises SeamInvalidTokenError: If the provided personal access token format is invalid
7684
"""
@@ -86,6 +94,8 @@ def __init__(
8694
base_url=endpoint,
8795
auth_headers=auth_headers,
8896
retries=retries,
97+
timeout=timeout,
98+
niquests_options=niquests_options,
8999
)
90100

91101
defaults = {"wait_for_action_attempt": wait_for_action_attempt}
@@ -101,6 +111,8 @@ def from_personal_access_token(
101111
endpoint: Optional[str] = None,
102112
wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = True,
103113
retries: Optional[Retry] = None,
114+
timeout: Optional[float] = DEFAULT_TIMEOUT,
115+
niquests_options: Optional[Dict[str, Any]] = None,
104116
) -> Self:
105117
"""
106118
Create a SeamMultiWorkspace instance using a personal access token.
@@ -132,4 +144,6 @@ def from_personal_access_token(
132144
endpoint=endpoint,
133145
wait_for_action_attempt=wait_for_action_attempt,
134146
retries=retries,
147+
timeout=timeout,
148+
niquests_options=niquests_options,
135149
)

test/timeout_test.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import threading
2+
import time
3+
from contextlib import contextmanager
4+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
5+
6+
import niquests
7+
import pytest
8+
from urllib3.util import Retry
9+
10+
from seam import Seam
11+
from seam.constants import DEFAULT_TIMEOUT
12+
13+
14+
def test_timeout_defaults_to_30_seconds():
15+
seam = Seam.from_api_key("seam_apikey_token")
16+
17+
assert DEFAULT_TIMEOUT == 30
18+
assert seam.client.timeout == 30
19+
20+
21+
def test_timeout_can_be_overridden():
22+
seam = Seam.from_api_key("seam_apikey_token", timeout=60)
23+
24+
assert seam.client.timeout == 60
25+
26+
27+
def test_timeout_can_be_disabled_with_none():
28+
seam = Seam.from_api_key("seam_apikey_token", timeout=None)
29+
30+
assert seam.client.timeout is None
31+
32+
33+
def test_niquests_options_are_passed_to_the_session():
34+
seam = Seam.from_api_key(
35+
"seam_apikey_token", niquests_options={"headers": {"Custom-Header": "Test"}}
36+
)
37+
38+
assert seam.client.headers["Custom-Header"] == "Test"
39+
assert seam.client.headers["seam-sdk-name"] == "seamapi/python"
40+
assert seam.client.headers["Authorization"] == "Bearer seam_apikey_token"
41+
42+
43+
def test_niquests_options_take_precedence():
44+
seam = Seam.from_api_key("seam_apikey_token", niquests_options={"pool_maxsize": 25})
45+
46+
assert seam.client.timeout == 30
47+
48+
49+
def test_per_request_timeout_overrides_the_client_timeout(recording_server):
50+
with recording_server([(200, {"devices": []})]) as (endpoint, _):
51+
seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint, timeout=30)
52+
53+
response = seam.client.post("/devices/list", json={}, timeout=10)
54+
55+
assert response == {"devices": []}
56+
57+
58+
def test_seam_times_out_a_slow_request():
59+
with slow_server() as endpoint:
60+
seam = Seam.from_api_key(
61+
"seam_apikey_token",
62+
endpoint=endpoint,
63+
timeout=0.25,
64+
retries=Retry(total=0),
65+
)
66+
67+
with pytest.raises(niquests.exceptions.Timeout):
68+
seam.devices.list()
69+
70+
71+
@contextmanager
72+
def slow_server():
73+
"""Serve a response too slowly for the client timeout to tolerate."""
74+
75+
class Handler(BaseHTTPRequestHandler):
76+
protocol_version = "HTTP/1.1"
77+
78+
# pylint: disable-next=invalid-name
79+
def do_POST(self): # BaseHTTPRequestHandler dispatches on this name.
80+
time.sleep(5)
81+
self.send_response(200)
82+
self.send_header("content-length", "0")
83+
self.end_headers()
84+
85+
def log_message(self, *args):
86+
pass
87+
88+
server = ThreadingHTTPServer(("localhost", 0), Handler)
89+
thread = threading.Thread(target=server.serve_forever, daemon=True)
90+
thread.start()
91+
92+
try:
93+
yield f"http://localhost:{server.server_port}"
94+
finally:
95+
server.shutdown()
96+
server.server_close()
97+
thread.join(timeout=5)

0 commit comments

Comments
 (0)