diff --git a/README.md b/README.md index 828da7b..5242dce 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,19 @@ $ export TASKBADGER_API_KEY=*** $ taskbadger run "nightly-backup" -- ./backup.sh ``` +### Request timeout + +API requests time out after 5 seconds by default. Override it with the `timeout` argument +(seconds), or with the `TASKBADGER_HTTP_TIMEOUT` environment variable, which the CLI also +honours: + +```python +taskbadger.init(token="***", timeout=30) +``` + +Pass an [`httpx.Timeout`](https://www.python-httpx.org/advanced/timeouts/) for finer control, +or `httpx.Timeout(None)` to disable timeouts entirely. + ### Procrastinate Integration The SDK includes optional support for the [Procrastinate](https://procrastinate.readthedocs.io/) task queue. diff --git a/taskbadger/mug.py b/taskbadger/mug.py index 6bf8b90..f9bf515 100644 --- a/taskbadger/mug.py +++ b/taskbadger/mug.py @@ -4,12 +4,17 @@ from contextvars import ContextVar from copy import deepcopy +import httpx + from taskbadger.context_providers import ContextProvider from taskbadger.internal import AuthenticatedClient from taskbadger.systems import System _local = ContextVar("taskbadger_client") +#: Default timeout (seconds) applied to all API requests. +DEFAULT_HTTP_TIMEOUT = 5.0 + Callback = str | Callable[[dict], dict | None] @@ -23,9 +28,10 @@ class Settings: systems: dict[str, System] = dataclasses.field(default_factory=dict) before_create: Callback = None context_providers: list[ContextProvider] = dataclasses.field(default_factory=list) + timeout: float | httpx.Timeout | None = DEFAULT_HTTP_TIMEOUT def get_client(self): - return AuthenticatedClient(self.base_url, self.token) + return AuthenticatedClient(self.base_url, self.token, timeout=httpx.Timeout(self.timeout)) def as_kwargs(self): return { diff --git a/taskbadger/sdk.py b/taskbadger/sdk.py index a276b26..4564580 100644 --- a/taskbadger/sdk.py +++ b/taskbadger/sdk.py @@ -5,6 +5,8 @@ import warnings from typing import Any +import httpx + from taskbadger._error_context import capture_error_data from taskbadger.context_providers import ContextProvider from taskbadger.exceptions import ( @@ -29,7 +31,7 @@ TaskRequest, ) from taskbadger.internal.types import UNSET -from taskbadger.mug import Badger, Callback, Session, Settings +from taskbadger.mug import DEFAULT_HTTP_TIMEOUT, Badger, Callback, Session, Settings from taskbadger.systems import System from taskbadger.utils import import_string @@ -58,6 +60,17 @@ def _parse_token(token): return None +def _timeout_from_env(): + """Read the request timeout from ``TASKBADGER_HTTP_TIMEOUT``, falling back to the default.""" + raw = os.environ.get("TASKBADGER_HTTP_TIMEOUT") + if not raw: + return DEFAULT_HTTP_TIMEOUT + try: + return float(raw) + except ValueError as e: + raise ConfigurationError(f"TASKBADGER_HTTP_TIMEOUT must be a number, got {raw!r}") from e + + def init( organization_slug: str = None, project_slug: str = None, @@ -66,6 +79,7 @@ def init( tags: dict[str, str] = None, before_create: Callback = None, context_providers: list[ContextProvider] = None, + timeout: float | httpx.Timeout = None, ): """Initialize Task Badger client. @@ -79,10 +93,13 @@ def init( Arguments: context_providers: Providers consulted when a tracked task errors, to attach extra context (e.g. a Sentry issue link) to the task's `data`. See `taskbadger.context_providers`. + timeout: Timeout (seconds) for API requests. Defaults to the ``TASKBADGER_HTTP_TIMEOUT`` + environment variable if set, otherwise 5 seconds. Pass an `httpx.Timeout` for finer + control, or ``httpx.Timeout(None)`` to disable timeouts. Call this function once per thread. """ - _init(_TB_HOST, organization_slug, project_slug, token, systems, tags, before_create, context_providers) + _init(_TB_HOST, organization_slug, project_slug, token, systems, tags, before_create, context_providers, timeout) def _init( @@ -94,11 +111,14 @@ def _init( tags: dict[str, str] = None, before_create: Callback = None, context_providers: list[ContextProvider] = None, + timeout: float | httpx.Timeout = None, ): host = host or os.environ.get("TASKBADGER_HOST", "https://taskbadger.net") organization_slug = organization_slug or os.environ.get("TASKBADGER_ORG") project_slug = project_slug or os.environ.get("TASKBADGER_PROJECT") token = token or os.environ.get("TASKBADGER_API_KEY") + if timeout is None: + timeout = _timeout_from_env() if token: parsed = _parse_token(token) @@ -127,6 +147,7 @@ def _init( systems={system.identifier: system for system in systems}, before_create=before_create, context_providers=context_providers or [], + timeout=timeout, ) Badger.current.bind(settings, tags) else: diff --git a/tests/test_init.py b/tests/test_init.py index 81d57ea..1d78766 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,10 +1,11 @@ import warnings +import httpx import pytest from taskbadger import Badger, init from taskbadger.exceptions import ConfigurationError -from taskbadger.mug import _local +from taskbadger.mug import DEFAULT_HTTP_TIMEOUT, _local @pytest.fixture(autouse=True) @@ -34,5 +35,40 @@ def test_init_import_before_create_fail(): init("org", "project", "token", before_create="missing") +def test_init_default_timeout(): + _init_token() + assert Badger.current.settings.timeout == DEFAULT_HTTP_TIMEOUT + assert Badger.current.client().get_httpx_client().timeout == httpx.Timeout(DEFAULT_HTTP_TIMEOUT) + + +def test_init_timeout_override(): + _init_token(timeout=30) + assert Badger.current.client().get_httpx_client().timeout == httpx.Timeout(30) + + +def test_init_timeout_from_env(monkeypatch): + monkeypatch.setenv("TASKBADGER_HTTP_TIMEOUT", "12.5") + _init_token() + assert Badger.current.settings.timeout == 12.5 + + +def test_init_timeout_arg_beats_env(monkeypatch): + monkeypatch.setenv("TASKBADGER_HTTP_TIMEOUT", "12.5") + _init_token(timeout=httpx.Timeout(1, connect=2)) + assert Badger.current.client().get_httpx_client().timeout == httpx.Timeout(1, connect=2) + + +def test_init_timeout_from_env_invalid(monkeypatch): + monkeypatch.setenv("TASKBADGER_HTTP_TIMEOUT", "soon") + with pytest.raises(ConfigurationError): + _init_token() + + +def _init_token(**kwargs): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + init("org", "project", "token", **kwargs) + + def _before_create(_): pass