From af6158d95a48006ba037679301536ebd409e8cd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 08:13:51 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01XMgDauUA2R9u2THCHmMgv1 --- README.rst | 34 +++++++++++++ seam/client.py | 31 +++++++++--- seam/constants.py | 2 + seam/seam.py | 24 ++++++++- seam/seam_multi_workspace.py | 16 +++++- test/timeout_test.py | 97 ++++++++++++++++++++++++++++++++++++ 6 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 test/timeout_test.py diff --git a/README.rst b/README.rst index d3f7c52e..74e42026 100644 --- a/README.rst +++ b/README.rst @@ -65,6 +65,10 @@ Contents * `Setting the endpoint`_ + * `Setting the request timeout`_ + + * `Configuring the niquests session`_ + * `Development and Testing`_ * `Quickstart`_ @@ -436,6 +440,36 @@ e.g., testing or proxy setups. Either pass the ``endpoint`` option to the constructor, or set the ``SEAM_ENDPOINT`` environment variable. +Setting the request timeout +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Requests time out after 30 seconds by default. +Pass the ``timeout`` option, in seconds, to override this: + +.. code-block:: python + + from seam import Seam + + seam = Seam(api_key="your-api-key", timeout=60) + +Setting it to ``None`` disables the timeout entirely. + +A request that exceeds the timeout raises ``niquests.exceptions.Timeout``. + +Configuring the niquests session +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +For control the options above do not cover, pass ``niquests_options``. +These are handed to the underlying niquests ``Session`` and take +precedence over the defaults the SDK sets: + +.. code-block:: python + + seam = Seam( + api_key="your-api-key", + niquests_options={"pool_connections": 20, "pool_maxsize": 25}, + ) + Development and Testing ----------------------- diff --git a/seam/client.py b/seam/client.py index a0b4094a..644b7ad5 100644 --- a/seam/client.py +++ b/seam/client.py @@ -1,11 +1,12 @@ -from typing import Dict, Optional +from typing import Any, Dict, Optional from urllib.parse import urljoin import niquests as requests from importlib.metadata import version +from inspect import signature from urllib3.util import Retry import abc -from .constants import LTS_VERSION +from .constants import DEFAULT_TIMEOUT, LTS_VERSION from .exceptions import ( SeamHttpApiError, SeamHttpInvalidInputError, @@ -20,6 +21,10 @@ DEFAULT_RETRIES = Retry() +NIQUESTS_TIMEOUT_DEFAULT = ( + signature(requests.Session.post).parameters["timeout"].default +) + class AbstractSeamHttpClient(abc.ABC): @abc.abstractmethod @@ -45,22 +50,36 @@ def __init__( base_url: str, auth_headers: Dict[str, str], retries: Optional[Retry] = DEFAULT_RETRIES, + timeout: Optional[float] = DEFAULT_TIMEOUT, + niquests_options: Optional[Dict[str, Any]] = None, **kwargs ): # niquests.Session mounts its adapters while initializing, so retries # must be passed through here. Assigning self.retries afterwards leaves # the mounted adapters on their default and the option has no effect. - super().__init__( - retries=DEFAULT_RETRIES if retries is None else retries, **kwargs - ) + options = { + "retries": DEFAULT_RETRIES if retries is None else retries, + **kwargs, + **(niquests_options or {}), + } + + custom_headers = options.pop("headers", {}) + + super().__init__(**options) self.base_url = base_url - headers = {**auth_headers, **kwargs.get("headers", {}), **SDK_HEADERS} + self.timeout = timeout + + headers = {**auth_headers, **custom_headers, **SDK_HEADERS} self.headers.update(headers) def request(self, method, url, *args, **kwargs): url = urljoin(self.base_url, url) + + if kwargs.get("timeout", NIQUESTS_TIMEOUT_DEFAULT) == NIQUESTS_TIMEOUT_DEFAULT: + kwargs["timeout"] = self.timeout + response = super().request(method, url, *args, **kwargs) return self._handle_response(response) diff --git a/seam/constants.py b/seam/constants.py index 562a28d9..751b277b 100644 --- a/seam/constants.py +++ b/seam/constants.py @@ -1,3 +1,5 @@ LTS_VERSION = "1.0.0" DEFAULT_ENDPOINT = "https://connect.getseam.com" + +DEFAULT_TIMEOUT = 30 diff --git a/seam/seam.py b/seam/seam.py index df49551e..97a204d3 100644 --- a/seam/seam.py +++ b/seam/seam.py @@ -2,7 +2,7 @@ from typing_extensions import Self from urllib3.util.retry import Retry -from .constants import LTS_VERSION +from .constants import DEFAULT_TIMEOUT, LTS_VERSION from .parse_options import parse_options from .routes import Routes from .models import AbstractSeam @@ -42,6 +42,8 @@ def __init__( endpoint: Optional[str] = None, wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = True, retries: Optional[Retry] = None, + timeout: Optional[float] = DEFAULT_TIMEOUT, + niquests_options: Optional[Dict[str, Any]] = None, ): """Initialize a Seam client instance. @@ -66,6 +68,12 @@ def __init__( :type wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] :param retries: Configuration for retry behavior on failed requests :type retries: Optional[urllib3.util.Retry] + :param timeout: The request timeout in seconds. Defaults to 30 + seconds. Pass None for no timeout + :type timeout: Optional[float] + :param niquests_options: Options passed through to the underlying + niquests Session, for control the other options do not cover + :type niquests_options: Optional[Dict[str, Any]] :raises SeamInvalidOptionsError: If neither api_key nor personal_access_token is provided, or if workspace_id is missing @@ -85,7 +93,11 @@ def __init__( self.defaults = {"wait_for_action_attempt": wait_for_action_attempt} self.client = SeamHttpClient( - base_url=endpoint, auth_headers=auth_headers, retries=retries + base_url=endpoint, + auth_headers=auth_headers, + retries=retries, + timeout=timeout, + niquests_options=niquests_options, ) Routes.__init__(self, client=self.client, defaults=self.defaults) @@ -123,6 +135,8 @@ def from_api_key( endpoint: Optional[str] = None, wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = True, retries: Optional[Retry] = None, + timeout: Optional[float] = DEFAULT_TIMEOUT, + niquests_options: Optional[Dict[str, Any]] = None, ) -> Self: """Create a Seam instance using an API key. @@ -151,6 +165,8 @@ def from_api_key( endpoint=endpoint, wait_for_action_attempt=wait_for_action_attempt, retries=retries, + timeout=timeout, + niquests_options=niquests_options, ) @classmethod @@ -162,6 +178,8 @@ def from_personal_access_token( endpoint: Optional[str] = None, wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = True, retries: Optional[Retry] = None, + timeout: Optional[float] = DEFAULT_TIMEOUT, + niquests_options: Optional[Dict[str, Any]] = None, ) -> Self: """Create a Seam instance using a personal access token. @@ -194,4 +212,6 @@ def from_personal_access_token( endpoint=endpoint, wait_for_action_attempt=wait_for_action_attempt, retries=retries, + timeout=timeout, + niquests_options=niquests_options, ) diff --git a/seam/seam_multi_workspace.py b/seam/seam_multi_workspace.py index 6078e17e..73b95f92 100644 --- a/seam/seam_multi_workspace.py +++ b/seam/seam_multi_workspace.py @@ -4,7 +4,7 @@ from urllib3.util import Retry from .auth import get_auth_headers_for_multi_workspace_personal_access_token -from .constants import LTS_VERSION +from .constants import DEFAULT_TIMEOUT, LTS_VERSION from .options import get_endpoint from .client import SeamHttpClient from .models import AbstractSeamMultiWorkspace @@ -52,6 +52,8 @@ def __init__( endpoint: Optional[str] = None, wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = True, retries: Optional[Retry] = None, + timeout: Optional[float] = DEFAULT_TIMEOUT, + niquests_options: Optional[Dict[str, Any]] = None, ): """ Initialize a SeamMultiWorkspace client instance. @@ -71,6 +73,12 @@ def __init__( :type wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] :param retries: Configuration for retry behavior on failed requests :type retries: Optional[urllib3.util.Retry] + :param timeout: The request timeout in seconds. Defaults to 30 + seconds. Pass None for no timeout + :type timeout: Optional[float] + :param niquests_options: Options passed through to the underlying + niquests Session, for control the other options do not cover + :type niquests_options: Optional[Dict[str, Any]] :raises SeamInvalidTokenError: If the provided personal access token format is invalid """ @@ -86,6 +94,8 @@ def __init__( base_url=endpoint, auth_headers=auth_headers, retries=retries, + timeout=timeout, + niquests_options=niquests_options, ) defaults = {"wait_for_action_attempt": wait_for_action_attempt} @@ -101,6 +111,8 @@ def from_personal_access_token( endpoint: Optional[str] = None, wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = True, retries: Optional[Retry] = None, + timeout: Optional[float] = DEFAULT_TIMEOUT, + niquests_options: Optional[Dict[str, Any]] = None, ) -> Self: """ Create a SeamMultiWorkspace instance using a personal access token. @@ -132,4 +144,6 @@ def from_personal_access_token( endpoint=endpoint, wait_for_action_attempt=wait_for_action_attempt, retries=retries, + timeout=timeout, + niquests_options=niquests_options, ) diff --git a/test/timeout_test.py b/test/timeout_test.py new file mode 100644 index 00000000..5e3cebcd --- /dev/null +++ b/test/timeout_test.py @@ -0,0 +1,97 @@ +import threading +import time +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import niquests +import pytest +from urllib3.util import Retry + +from seam import Seam +from seam.constants import DEFAULT_TIMEOUT + + +def test_timeout_defaults_to_30_seconds(): + seam = Seam.from_api_key("seam_apikey_token") + + assert DEFAULT_TIMEOUT == 30 + assert seam.client.timeout == 30 + + +def test_timeout_can_be_overridden(): + seam = Seam.from_api_key("seam_apikey_token", timeout=60) + + assert seam.client.timeout == 60 + + +def test_timeout_can_be_disabled_with_none(): + seam = Seam.from_api_key("seam_apikey_token", timeout=None) + + assert seam.client.timeout is None + + +def test_niquests_options_are_passed_to_the_session(): + seam = Seam.from_api_key( + "seam_apikey_token", niquests_options={"headers": {"Custom-Header": "Test"}} + ) + + assert seam.client.headers["Custom-Header"] == "Test" + assert seam.client.headers["seam-sdk-name"] == "seamapi/python" + assert seam.client.headers["Authorization"] == "Bearer seam_apikey_token" + + +def test_niquests_options_take_precedence(): + seam = Seam.from_api_key("seam_apikey_token", niquests_options={"pool_maxsize": 25}) + + assert seam.client.timeout == 30 + + +def test_per_request_timeout_overrides_the_client_timeout(recording_server): + with recording_server([(200, {"devices": []})]) as (endpoint, _): + seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint, timeout=30) + + response = seam.client.post("/devices/list", json={}, timeout=10) + + assert response == {"devices": []} + + +def test_seam_times_out_a_slow_request(): + with slow_server() as endpoint: + seam = Seam.from_api_key( + "seam_apikey_token", + endpoint=endpoint, + timeout=0.25, + retries=Retry(total=0), + ) + + with pytest.raises(niquests.exceptions.Timeout): + seam.devices.list() + + +@contextmanager +def slow_server(): + """Serve a response too slowly for the client timeout to tolerate.""" + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + # pylint: disable-next=invalid-name + def do_POST(self): # BaseHTTPRequestHandler dispatches on this name. + time.sleep(5) + self.send_response(200) + self.send_header("content-length", "0") + self.end_headers() + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("localhost", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + try: + yield f"http://localhost:{server.server_port}" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5)