Skip to content
Merged
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
34 changes: 34 additions & 0 deletions README.rst
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,6 +65,10 @@ Contents

* `Setting the endpoint`_

* `Setting the request timeout`_

* `Configuring the niquests session`_

* `Development and Testing`_

* `Quickstart`_
Expand DownExpand Up@@ -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
-----------------------

Expand Down
31 changes: 25 additions & 6 deletions seam/client.py
Original file line numberDiff line numberDiff line change
@@ -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,
Expand All@@ -20,6 +21,10 @@

DEFAULT_RETRIES = Retry()

NIQUESTS_TIMEOUT_DEFAULT = (
signature(requests.Session.post).parameters["timeout"].default
)


class AbstractSeamHttpClient(abc.ABC):
@abc.abstractmethod
Expand All@@ -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)
Expand Down
2 changes: 2 additions & 0 deletions seam/constants.py
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
LTS_VERSION="1.0.0"

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

DEFAULT_TIMEOUT=30
24 changes: 22 additions & 2 deletions seam/seam.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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.

Expand All@@ -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
Expand All@@ -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)
Expand DownExpand Up@@ -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.

Expand DownExpand Up@@ -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
Expand All@@ -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.

Expand DownExpand Up@@ -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,
)
16 changes: 15 additions & 1 deletion seam/seam_multi_workspace.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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.
Expand All@@ -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
"""
Expand All@@ -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}
Expand All@@ -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.
Expand DownExpand Up@@ -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,
)
97 changes: 97 additions & 0 deletions test/timeout_test.py
Original file line numberDiff line numberDiff line change
@@ -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)
Loading