Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ Contents

* `Setting the endpoint`_

* `Setting the request timeout`_

* `Development and Testing`_

* `Quickstart`_
Expand Down Expand Up @@ -436,6 +438,27 @@ 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)

The timeout may also be a ``(connect, read)`` tuple,
and setting it to ``None`` disables the timeout entirely:

.. code-block:: python

seam = Seam(api_key="your-api-key", timeout=(5, 60))

A request that exceeds the timeout raises ``niquests.exceptions.Timeout``.

Development and Testing
-----------------------

Expand Down
13 changes: 10 additions & 3 deletions seam/client.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from typing import Dict, Optional
from typing import Dict, Optional, Tuple, Union
from urllib.parse import urljoin
import niquests as requests
from importlib.metadata import version
from urllib3.util import Retry
import abc

from .constants import LTS_VERSION
from .constants import DEFAULT_TIMEOUT, LTS_VERSION
from .exceptions import (
SeamHttpApiError,
SeamHttpInvalidInputError,
Expand All @@ -20,6 +20,8 @@

DEFAULT_RETRIES = Retry()

TimeoutType = Union[float, Tuple[float, float]]


class AbstractSeamHttpClient(abc.ABC):
@abc.abstractmethod
Expand All @@ -45,13 +47,18 @@ def __init__(
base_url: str,
auth_headers: Dict[str, str],
retries: Optional[Retry] = DEFAULT_RETRIES,
timeout: Optional[TimeoutType] = DEFAULT_TIMEOUT,
**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.
#
# timeout follows the niquests convention, where None means no timeout.
super().__init__(
retries=DEFAULT_RETRIES if retries is None else retries, **kwargs
retries=DEFAULT_RETRIES if retries is None else retries,
timeout=timeout,
**kwargs
)

self.base_url = base_url
Expand Down
2 changes: 2 additions & 0 deletions seam/constants.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
LTS_VERSION = "1.0.0"

DEFAULT_ENDPOINT = "https://connect.getseam.com"

DEFAULT_TIMEOUT = 30
18 changes: 15 additions & 3 deletions seam/seam.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
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
from .client import SeamHttpClient
from .client import SeamHttpClient, TimeoutType
from .paginator import SeamPaginator


Expand Down Expand Up @@ -42,6 +42,7 @@ def __init__(
endpoint: Optional[str] = None,
wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = True,
retries: Optional[Retry] = None,
timeout: Optional[TimeoutType] = DEFAULT_TIMEOUT,
):
"""Initialize a Seam client instance.

Expand All @@ -66,6 +67,10 @@ 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, or a
(connect, read) tuple. Defaults to 30 seconds. Pass None for no
timeout
:type timeout: Optional[Union[float, Tuple[float, float]]]

:raises SeamInvalidOptionsError: If neither api_key nor
personal_access_token is provided, or if workspace_id is missing
Expand All @@ -85,7 +90,10 @@ 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,
)

Routes.__init__(self, client=self.client, defaults=self.defaults)
Expand Down Expand Up @@ -123,6 +131,7 @@ 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[TimeoutType] = DEFAULT_TIMEOUT,
) -> Self:
"""Create a Seam instance using an API key.

Expand Down Expand Up @@ -151,6 +160,7 @@ def from_api_key(
endpoint=endpoint,
wait_for_action_attempt=wait_for_action_attempt,
retries=retries,
timeout=timeout,
)

@classmethod
Expand All @@ -162,6 +172,7 @@ 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[TimeoutType] = DEFAULT_TIMEOUT,
) -> Self:
"""Create a Seam instance using a personal access token.

Expand Down Expand Up @@ -194,4 +205,5 @@ def from_personal_access_token(
endpoint=endpoint,
wait_for_action_attempt=wait_for_action_attempt,
retries=retries,
timeout=timeout,
)
12 changes: 10 additions & 2 deletions seam/seam_multi_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
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 .client import SeamHttpClient, TimeoutType
from .models import AbstractSeamMultiWorkspace
from .routes.workspaces import Workspaces

Expand Down Expand Up @@ -52,6 +52,7 @@ def __init__(
endpoint: Optional[str] = None,
wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = True,
retries: Optional[Retry] = None,
timeout: Optional[TimeoutType] = DEFAULT_TIMEOUT,
):
"""
Initialize a SeamMultiWorkspace client instance.
Expand All @@ -71,6 +72,10 @@ 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, or a
(connect, read) tuple. Defaults to 30 seconds. Pass None for no
timeout
:type timeout: Optional[Union[float, Tuple[float, float]]]

:raises SeamInvalidTokenError: If the provided personal access token format is invalid
"""
Expand All @@ -86,6 +91,7 @@ def __init__(
base_url=endpoint,
auth_headers=auth_headers,
retries=retries,
timeout=timeout,
)

defaults = {"wait_for_action_attempt": wait_for_action_attempt}
Expand All @@ -101,6 +107,7 @@ 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[TimeoutType] = DEFAULT_TIMEOUT,
) -> Self:
"""
Create a SeamMultiWorkspace instance using a personal access token.
Expand Down Expand Up @@ -132,4 +139,5 @@ def from_personal_access_token(
endpoint=endpoint,
wait_for_action_attempt=wait_for_action_attempt,
retries=retries,
timeout=timeout,
)
81 changes: 81 additions & 0 deletions test/timeout_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import socket
import threading
from contextlib import contextmanager

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_accepts_a_connect_read_tuple():
seam = Seam.from_api_key("seam_apikey_token", timeout=(5, 60))

assert seam.client.timeout == (5, 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_seam_times_out_a_request_that_never_responds():
with unresponsive_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 unresponsive_server():
"""Accept connections but never send a response, so reads hang."""

listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("localhost", 0))
listener.listen(8)

accepted = []
stop = threading.Event()

def accept_forever():
while not stop.is_set():
try:
connection, _ = listener.accept()
except OSError:
return
accepted.append(connection)

thread = threading.Thread(target=accept_forever, daemon=True)
thread.start()

try:
yield f"http://localhost:{listener.getsockname()[1]}"
finally:
stop.set()
listener.close()
for connection in accepted:
connection.close()
thread.join(timeout=5)
Loading