Skip to content

Commit a0c0e97

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 a0c0e97

6 files changed

Lines changed: 141 additions & 8 deletions

File tree

README.rst

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

6666
* `Setting the endpoint`_
6767

68+
* `Setting the request timeout`_
69+
6870
* `Development and Testing`_
6971

7072
* `Quickstart`_
@@ -436,6 +438,27 @@ e.g., testing or proxy setups.
436438

437439
Either pass the ``endpoint`` option to the constructor, or set the ``SEAM_ENDPOINT`` environment variable.
438440

441+
Setting the request timeout
442+
^^^^^^^^^^^^^^^^^^^^^^^^^^^
443+
444+
Requests time out after 30 seconds by default.
445+
Pass the ``timeout`` option, in seconds, to override this:
446+
447+
.. code-block:: python
448+
449+
from seam import Seam
450+
451+
seam = Seam(api_key="your-api-key", timeout=60)
452+
453+
The timeout may also be a ``(connect, read)`` tuple,
454+
and setting it to ``None`` disables the timeout entirely:
455+
456+
.. code-block:: python
457+
458+
seam = Seam(api_key="your-api-key", timeout=(5, 60))
459+
460+
A request that exceeds the timeout raises ``niquests.exceptions.Timeout``.
461+
439462
Development and Testing
440463
-----------------------
441464

seam/client.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
from typing import Dict, Optional
1+
from typing import Dict, Optional, Tuple, Union
22
from urllib.parse import urljoin
33
import niquests as requests
44
from importlib.metadata import version
55
from urllib3.util import Retry
66
import abc
77

8-
from .constants import LTS_VERSION
8+
from .constants import DEFAULT_TIMEOUT, LTS_VERSION
99
from .exceptions import (
1010
SeamHttpApiError,
1111
SeamHttpInvalidInputError,
@@ -20,6 +20,8 @@
2020

2121
DEFAULT_RETRIES = Retry()
2222

23+
TimeoutType = Union[float, Tuple[float, float]]
24+
2325

2426
class AbstractSeamHttpClient(abc.ABC):
2527
@abc.abstractmethod
@@ -45,13 +47,18 @@ def __init__(
4547
base_url: str,
4648
auth_headers: Dict[str, str],
4749
retries: Optional[Retry] = DEFAULT_RETRIES,
50+
timeout: Optional[TimeoutType] = DEFAULT_TIMEOUT,
4851
**kwargs
4952
):
5053
# niquests.Session mounts its adapters while initializing, so retries
5154
# must be passed through here. Assigning self.retries afterwards leaves
5255
# the mounted adapters on their default and the option has no effect.
56+
#
57+
# timeout follows the niquests convention, where None means no timeout.
5358
super().__init__(
54-
retries=DEFAULT_RETRIES if retries is None else retries, **kwargs
59+
retries=DEFAULT_RETRIES if retries is None else retries,
60+
timeout=timeout,
61+
**kwargs
5562
)
5663

5764
self.base_url = base_url

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: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
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
9-
from .client import SeamHttpClient
9+
from .client import SeamHttpClient, TimeoutType
1010
from .paginator import SeamPaginator
1111

1212

@@ -42,6 +42,7 @@ 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[TimeoutType] = DEFAULT_TIMEOUT,
4546
):
4647
"""Initialize a Seam client instance.
4748
@@ -66,6 +67,10 @@ def __init__(
6667
:type wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]]
6768
:param retries: Configuration for retry behavior on failed requests
6869
:type retries: Optional[urllib3.util.Retry]
70+
:param timeout: The request timeout in seconds, or a
71+
(connect, read) tuple. Defaults to 30 seconds. Pass None for no
72+
timeout
73+
:type timeout: Optional[Union[float, Tuple[float, float]]]
6974
7075
:raises SeamInvalidOptionsError: If neither api_key nor
7176
personal_access_token is provided, or if workspace_id is missing
@@ -85,7 +90,10 @@ def __init__(
8590
self.defaults = {"wait_for_action_attempt": wait_for_action_attempt}
8691

8792
self.client = SeamHttpClient(
88-
base_url=endpoint, auth_headers=auth_headers, retries=retries
93+
base_url=endpoint,
94+
auth_headers=auth_headers,
95+
retries=retries,
96+
timeout=timeout,
8997
)
9098

9199
Routes.__init__(self, client=self.client, defaults=self.defaults)
@@ -123,6 +131,7 @@ def from_api_key(
123131
endpoint: Optional[str] = None,
124132
wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = True,
125133
retries: Optional[Retry] = None,
134+
timeout: Optional[TimeoutType] = DEFAULT_TIMEOUT,
126135
) -> Self:
127136
"""Create a Seam instance using an API key.
128137
@@ -151,6 +160,7 @@ def from_api_key(
151160
endpoint=endpoint,
152161
wait_for_action_attempt=wait_for_action_attempt,
153162
retries=retries,
163+
timeout=timeout,
154164
)
155165

156166
@classmethod
@@ -162,6 +172,7 @@ def from_personal_access_token(
162172
endpoint: Optional[str] = None,
163173
wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = True,
164174
retries: Optional[Retry] = None,
175+
timeout: Optional[TimeoutType] = DEFAULT_TIMEOUT,
165176
) -> Self:
166177
"""Create a Seam instance using a personal access token.
167178
@@ -194,4 +205,5 @@ def from_personal_access_token(
194205
endpoint=endpoint,
195206
wait_for_action_attempt=wait_for_action_attempt,
196207
retries=retries,
208+
timeout=timeout,
197209
)

seam/seam_multi_workspace.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
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
9-
from .client import SeamHttpClient
9+
from .client import SeamHttpClient, TimeoutType
1010
from .models import AbstractSeamMultiWorkspace
1111
from .routes.workspaces import Workspaces
1212

@@ -52,6 +52,7 @@ 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[TimeoutType] = DEFAULT_TIMEOUT,
5556
):
5657
"""
5758
Initialize a SeamMultiWorkspace client instance.
@@ -71,6 +72,10 @@ def __init__(
7172
:type wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]]
7273
:param retries: Configuration for retry behavior on failed requests
7374
:type retries: Optional[urllib3.util.Retry]
75+
:param timeout: The request timeout in seconds, or a
76+
(connect, read) tuple. Defaults to 30 seconds. Pass None for no
77+
timeout
78+
:type timeout: Optional[Union[float, Tuple[float, float]]]
7479
7580
:raises SeamInvalidTokenError: If the provided personal access token format is invalid
7681
"""
@@ -86,6 +91,7 @@ def __init__(
8691
base_url=endpoint,
8792
auth_headers=auth_headers,
8893
retries=retries,
94+
timeout=timeout,
8995
)
9096

9197
defaults = {"wait_for_action_attempt": wait_for_action_attempt}
@@ -101,6 +107,7 @@ def from_personal_access_token(
101107
endpoint: Optional[str] = None,
102108
wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = True,
103109
retries: Optional[Retry] = None,
110+
timeout: Optional[TimeoutType] = DEFAULT_TIMEOUT,
104111
) -> Self:
105112
"""
106113
Create a SeamMultiWorkspace instance using a personal access token.
@@ -132,4 +139,5 @@ def from_personal_access_token(
132139
endpoint=endpoint,
133140
wait_for_action_attempt=wait_for_action_attempt,
134141
retries=retries,
142+
timeout=timeout,
135143
)

test/timeout_test.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import socket
2+
import threading
3+
from contextlib import contextmanager
4+
5+
import niquests
6+
import pytest
7+
from urllib3.util import Retry
8+
9+
from seam import Seam
10+
from seam.constants import DEFAULT_TIMEOUT
11+
12+
13+
def test_timeout_defaults_to_30_seconds():
14+
seam = Seam.from_api_key("seam_apikey_token")
15+
16+
assert DEFAULT_TIMEOUT == 30
17+
assert seam.client.timeout == 30
18+
19+
20+
def test_timeout_can_be_overridden():
21+
seam = Seam.from_api_key("seam_apikey_token", timeout=60)
22+
23+
assert seam.client.timeout == 60
24+
25+
26+
def test_timeout_accepts_a_connect_read_tuple():
27+
seam = Seam.from_api_key("seam_apikey_token", timeout=(5, 60))
28+
29+
assert seam.client.timeout == (5, 60)
30+
31+
32+
def test_timeout_can_be_disabled_with_none():
33+
seam = Seam.from_api_key("seam_apikey_token", timeout=None)
34+
35+
assert seam.client.timeout is None
36+
37+
38+
def test_seam_times_out_a_request_that_never_responds():
39+
with unresponsive_server() as endpoint:
40+
seam = Seam.from_api_key(
41+
"seam_apikey_token",
42+
endpoint=endpoint,
43+
timeout=0.25,
44+
retries=Retry(total=0),
45+
)
46+
47+
with pytest.raises(niquests.exceptions.Timeout):
48+
seam.devices.list()
49+
50+
51+
@contextmanager
52+
def unresponsive_server():
53+
"""Accept connections but never send a response, so reads hang."""
54+
55+
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
56+
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
57+
listener.bind(("localhost", 0))
58+
listener.listen(8)
59+
60+
accepted = []
61+
stop = threading.Event()
62+
63+
def accept_forever():
64+
while not stop.is_set():
65+
try:
66+
connection, _ = listener.accept()
67+
except OSError:
68+
return
69+
accepted.append(connection)
70+
71+
thread = threading.Thread(target=accept_forever, daemon=True)
72+
thread.start()
73+
74+
try:
75+
yield f"http://localhost:{listener.getsockname()[1]}"
76+
finally:
77+
stop.set()
78+
listener.close()
79+
for connection in accepted:
80+
connection.close()
81+
thread.join(timeout=5)

0 commit comments

Comments
 (0)