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
36 changes: 29 additions & 7 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ Contents

* `Setting the request timeout`_

* `Configuring the niquests session`_
* `Configuring retries`_

* `Configuring the httpx client`_

* `Development and Testing`_

Expand Down Expand Up @@ -470,20 +472,40 @@ Pass the ``timeout`` option, in seconds, to override this:

Setting it to ``None`` disables the timeout entirely.

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

Configuring retries
^^^^^^^^^^^^^^^^^^^

Pass the ``retries`` option to configure retry behavior.
Retries are handled by `httpx-retries <https://will-ockmore.github.io/httpx-retries/>`_,
and its ``Retry`` class is re-exported from ``seam`` for convenience:

Configuring the niquests session
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.. code-block:: python

from seam import Seam, Retry

For control the options above do not cover, pass ``niquests_options``.
These are handed to the underlying niquests ``Session`` and take
seam = Seam(
api_key="your-api-key",
retries=Retry(total=3, backoff_factor=0.5, status_forcelist=[503]),
)

Configuring the httpx client
^^^^^^^^^^^^^^^^^^^^^^^^^^^^

For control the options above do not cover, pass ``httpx_options``.
These are handed to the underlying httpx ``Client`` and take
precedence over the defaults the SDK sets:

.. code-block:: python

from httpx import Limits

seam = Seam(
api_key="your-api-key",
niquests_options={"pool_connections": 20, "pool_maxsize": 25},
httpx_options={
"limits": Limits(max_connections=25, max_keepalive_connections=20),
},
)

Development and Testing
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ license-files = ["LICENSE.txt"]
readme = "README.rst"
requires-python = ">=3.10"
dependencies = [
"dataclasses-json>=0.6.4,<0.7",
"niquests>=3.6.4,<4",
"httpx>=0.23.0,<1",
"httpx-retries>=0.6.0,<1",
"svix>=1.24.0,<2",
]

Expand Down
1 change: 1 addition & 0 deletions seam/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from .seam import Seam
from .seam_without_workspace import SeamWithoutWorkspace
from httpx_retries import Retry
from .options import SeamInvalidOptionsError
from .auth import SeamInvalidTokenError
from .exceptions import (
Expand Down
66 changes: 24 additions & 42 deletions seam/client.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
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

import httpx
from httpx import Response
from httpx_retries import Retry, RetryTransport

from .constants import DEFAULT_TIMEOUT, LTS_VERSION
from .exceptions import (
SeamHttpApiError,
Expand All @@ -21,10 +21,6 @@

DEFAULT_RETRIES = Retry()

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


class AbstractSeamHttpClient(abc.ABC):
@abc.abstractmethod
Expand All @@ -36,83 +32,69 @@ def request(self, method: str, url: str, *args, **kwargs):
raise NotImplementedError

@abc.abstractmethod
def _handle_response(self, response: requests.Response):
def _handle_response(self, response: Response):
raise NotImplementedError

@abc.abstractmethod
def _handle_error_response(self, response: requests.Response, status_code: int):
def _handle_error_response(self, response: Response):
raise NotImplementedError


class SeamHttpClient(requests.Session, AbstractSeamHttpClient):
class SeamHttpClient(httpx.Client, AbstractSeamHttpClient):
def __init__(
self,
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,
httpx_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.
options = {
"retries": DEFAULT_RETRIES if retries is None else retries,
"base_url": base_url,
"timeout": timeout,
**kwargs,
**(niquests_options or {}),
**(httpx_options or {}),
}

custom_headers = options.pop("headers", {})

super().__init__(**options)

self.base_url = base_url
if "transport" not in options:
options["transport"] = RetryTransport(
retry=DEFAULT_RETRIES if retries is None else retries
)

self.timeout = timeout
super().__init__(**options)

headers = {**auth_headers, **custom_headers, **SDK_HEADERS}
self.headers.update(headers)

# request returns the decoded body rather than the Response that
# niquests.Session promises, so the verb helpers routed through it have to
# httpx.Client promises, so the verb helpers routed through it have to
# say so too. Without these overrides callers see the inherited Response
# type and indexing the returned payload does not type check.
def get(self, url, **kwargs) -> Any:
return self.request("GET", url, **kwargs)

# data and json are named rather than collected into *args because
# Session.request takes params in the position Session.post gives data.
def post(self, url, data=None, json=None, **kwargs) -> Any:
return self.request("POST", url, data=data, json=json, **kwargs)

def request(self, method, url, *args, **kwargs) -> Any:
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)

def _handle_response(self, response: requests.Response):
# niquests types status_code as optional because a Response exists
# before it has one. Anything reaching here has been received, so a
# missing status is an error the SDK cannot classify itself.
status_code = response.status_code

if status_code is None:
response.raise_for_status()
elif not 200 <= status_code < 300:
self._handle_error_response(response, status_code)
def _handle_response(self, response: Response):
if not 200 <= response.status_code < 300:
self._handle_error_response(response)

if "application/json" in response.headers.get("content-type", ""):
return response.json()

return response.text

def _handle_error_response(self, response: requests.Response, status_code: int):
def _handle_error_response(self, response: Response):
status_code = response.status_code
request_id = response.headers.get("seam-request-id")

if status_code == 401:
Expand All @@ -138,7 +120,7 @@ def _handle_error_response(self, response: requests.Response, status_code: int):
raise SeamHttpApiError(error_details, status_code, request_id)


def is_api_error_response(response: requests.Response) -> bool:
def is_api_error_response(response: Response) -> bool:
try:
content_type = response.headers.get("content-type", "")

Expand All @@ -148,7 +130,7 @@ def is_api_error_response(response: requests.Response) -> bool:
return False

data = response.json()
except (ValueError, requests.exceptions.JSONDecodeError):
except ValueError:
return False

if not isinstance(data, dict):
Expand Down
22 changes: 10 additions & 12 deletions seam/paginator.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Callable, Dict, Any, Optional, Tuple, Generator, List, Union
from typing import Callable, Dict, Any, Optional, Tuple, Generator, List
from json import JSONDecodeError
from httpx import Response
from .client import SeamHttpClient
from niquests import PreparedRequest, Response, JSONDecodeError
from .pagination import Pagination


Expand Down Expand Up @@ -34,11 +35,11 @@ def __init__(

def first_page(self) -> Tuple[List[Any], Pagination | None]:
"""Fetches the first page of results."""
self.client.hooks["response"].append(
self.client.event_hooks["response"].append(
lambda response: self._cache_pagination(response, self._FIRST_PAGE)
)
data = self._request(**self._params)
self.client.hooks["response"].pop()
self.client.event_hooks["response"].pop()

pagination = self._pagination_cache.get(self._FIRST_PAGE)

Expand All @@ -56,11 +57,11 @@ def next_page(
"page_cursor": next_page_cursor,
}

self.client.hooks["response"].append(
self.client.event_hooks["response"].append(
lambda response: self._cache_pagination(response, next_page_cursor)
)
data = self._request(**params)
self.client.hooks["response"].pop()
self.client.event_hooks["response"].pop()

pagination = self._pagination_cache.get(next_page_cursor)

Expand Down Expand Up @@ -92,14 +93,11 @@ def flatten(self) -> Generator[Any, None, None]:
if current_items:
yield from current_items

def _cache_pagination(
self, response: Union[PreparedRequest, Response], page_key: str
) -> None:
def _cache_pagination(self, response: Response, page_key: str) -> None:
"""Extracts pagination dict from response, creates Pagination object, and caches it."""
if not isinstance(response, Response):
return

try:
# httpx response hooks fire before the response body is read.
response.read()
response_json = response.json()
pagination = response_json.get("pagination", {})
except JSONDecodeError:
Expand Down
22 changes: 11 additions & 11 deletions seam/seam.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Any, Optional, Union, Dict, Callable
from typing_extensions import Self
from urllib3.util.retry import Retry
from httpx_retries import Retry

from .constants import DEFAULT_TIMEOUT, LTS_VERSION
from .parse_options import parse_options
Expand Down Expand Up @@ -43,7 +43,7 @@ def __init__(
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,
httpx_options: Optional[Dict[str, Any]] = None,
):
"""Initialize a Seam client instance.

Expand Down Expand Up @@ -71,13 +71,13 @@ def __init__(
'timeout' and 'poll_interval' keys
: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]
:type retries: Optional[httpx_retries.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]]
:param httpx_options: Options passed through to the underlying
httpx Client, for control the other options do not cover
:type httpx_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 @@ -101,7 +101,7 @@ def __init__(
auth_headers=auth_headers,
retries=retries,
timeout=timeout,
niquests_options=niquests_options,
httpx_options=httpx_options,
)

# Seam and Routes are siblings under AbstractRoutes rather than parent
Expand Down Expand Up @@ -143,7 +143,7 @@ def from_api_key(
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,
httpx_options: Optional[Dict[str, Any]] = None,
) -> Self:
"""Create a Seam instance using an API key.

Expand Down Expand Up @@ -173,7 +173,7 @@ def from_api_key(
wait_for_action_attempt=wait_for_action_attempt,
retries=retries,
timeout=timeout,
niquests_options=niquests_options,
httpx_options=httpx_options,
)

@classmethod
Expand All @@ -186,7 +186,7 @@ def from_personal_access_token(
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,
httpx_options: Optional[Dict[str, Any]] = None,
) -> Self:
"""Create a Seam instance using a personal access token.

Expand Down Expand Up @@ -220,5 +220,5 @@ def from_personal_access_token(
wait_for_action_attempt=wait_for_action_attempt,
retries=retries,
timeout=timeout,
niquests_options=niquests_options,
httpx_options=httpx_options,
)
Loading
Loading