Skip to content

Commit 2bfc496

Browse files
snopokeclaude
andcommitted
feat(sdk): default HTTP timeout of 5s, overridable
The generated client was constructed without a timeout, which httpx reads as "no timeout", so a stalled request could block a task indefinitely. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3e1fcbd commit 2bfc496

4 files changed

Lines changed: 80 additions & 4 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,19 @@ $ export TASKBADGER_API_KEY=***
4242
$ taskbadger run "nightly-backup" -- ./backup.sh
4343
```
4444

45+
### Request timeout
46+
47+
API requests time out after 5 seconds by default. Override it with the `timeout` argument
48+
(seconds), or with the `TASKBADGER_HTTP_TIMEOUT` environment variable, which the CLI also
49+
honours:
50+
51+
```python
52+
taskbadger.init(token="***", timeout=30)
53+
```
54+
55+
Pass an [`httpx.Timeout`](https://www.python-httpx.org/advanced/timeouts/) for finer control,
56+
or `httpx.Timeout(None)` to disable timeouts entirely.
57+
4558
### Procrastinate Integration
4659

4760
The SDK includes optional support for the [Procrastinate](https://procrastinate.readthedocs.io/) task queue.

taskbadger/mug.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@
44
from contextvars import ContextVar
55
from copy import deepcopy
66

7+
import httpx
8+
79
from taskbadger.context_providers import ContextProvider
810
from taskbadger.internal import AuthenticatedClient
911
from taskbadger.systems import System
1012

1113
_local = ContextVar("taskbadger_client")
1214

15+
#: Default timeout (seconds) applied to all API requests.
16+
DEFAULT_HTTP_TIMEOUT = 5.0
17+
1318

1419
Callback = str | Callable[[dict], dict | None]
1520

@@ -23,9 +28,10 @@ class Settings:
2328
systems: dict[str, System] = dataclasses.field(default_factory=dict)
2429
before_create: Callback = None
2530
context_providers: list[ContextProvider] = dataclasses.field(default_factory=list)
31+
timeout: float | httpx.Timeout | None = DEFAULT_HTTP_TIMEOUT
2632

2733
def get_client(self):
28-
return AuthenticatedClient(self.base_url, self.token)
34+
return AuthenticatedClient(self.base_url, self.token, timeout=httpx.Timeout(self.timeout))
2935

3036
def as_kwargs(self):
3137
return {

taskbadger/sdk.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import warnings
66
from typing import Any
77

8+
import httpx
9+
810
from taskbadger._error_context import capture_error_data
911
from taskbadger.context_providers import ContextProvider
1012
from taskbadger.exceptions import (
@@ -29,7 +31,7 @@
2931
TaskRequest,
3032
)
3133
from taskbadger.internal.types import UNSET
32-
from taskbadger.mug import Badger, Callback, Session, Settings
34+
from taskbadger.mug import DEFAULT_HTTP_TIMEOUT, Badger, Callback, Session, Settings
3335
from taskbadger.systems import System
3436
from taskbadger.utils import import_string
3537

@@ -58,6 +60,17 @@ def _parse_token(token):
5860
return None
5961

6062

63+
def _timeout_from_env():
64+
"""Read the request timeout from ``TASKBADGER_HTTP_TIMEOUT``, falling back to the default."""
65+
raw = os.environ.get("TASKBADGER_HTTP_TIMEOUT")
66+
if not raw:
67+
return DEFAULT_HTTP_TIMEOUT
68+
try:
69+
return float(raw)
70+
except ValueError as e:
71+
raise ConfigurationError(f"TASKBADGER_HTTP_TIMEOUT must be a number, got {raw!r}") from e
72+
73+
6174
def init(
6275
organization_slug: str = None,
6376
project_slug: str = None,
@@ -66,6 +79,7 @@ def init(
6679
tags: dict[str, str] = None,
6780
before_create: Callback = None,
6881
context_providers: list[ContextProvider] = None,
82+
timeout: float | httpx.Timeout = None,
6983
):
7084
"""Initialize Task Badger client.
7185
@@ -79,10 +93,13 @@ def init(
7993
Arguments:
8094
context_providers: Providers consulted when a tracked task errors, to attach extra
8195
context (e.g. a Sentry issue link) to the task's `data`. See `taskbadger.context_providers`.
96+
timeout: Timeout (seconds) for API requests. Defaults to the ``TASKBADGER_HTTP_TIMEOUT``
97+
environment variable if set, otherwise 5 seconds. Pass an `httpx.Timeout` for finer
98+
control, or ``httpx.Timeout(None)`` to disable timeouts.
8299
83100
Call this function once per thread.
84101
"""
85-
_init(_TB_HOST, organization_slug, project_slug, token, systems, tags, before_create, context_providers)
102+
_init(_TB_HOST, organization_slug, project_slug, token, systems, tags, before_create, context_providers, timeout)
86103

87104

88105
def _init(
@@ -94,11 +111,14 @@ def _init(
94111
tags: dict[str, str] = None,
95112
before_create: Callback = None,
96113
context_providers: list[ContextProvider] = None,
114+
timeout: float | httpx.Timeout = None,
97115
):
98116
host = host or os.environ.get("TASKBADGER_HOST", "https://taskbadger.net")
99117
organization_slug = organization_slug or os.environ.get("TASKBADGER_ORG")
100118
project_slug = project_slug or os.environ.get("TASKBADGER_PROJECT")
101119
token = token or os.environ.get("TASKBADGER_API_KEY")
120+
if timeout is None:
121+
timeout = _timeout_from_env()
102122

103123
if token:
104124
parsed = _parse_token(token)
@@ -127,6 +147,7 @@ def _init(
127147
systems={system.identifier: system for system in systems},
128148
before_create=before_create,
129149
context_providers=context_providers or [],
150+
timeout=timeout,
130151
)
131152
Badger.current.bind(settings, tags)
132153
else:

tests/test_init.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import warnings
22

3+
import httpx
34
import pytest
45

56
from taskbadger import Badger, init
67
from taskbadger.exceptions import ConfigurationError
7-
from taskbadger.mug import _local
8+
from taskbadger.mug import DEFAULT_HTTP_TIMEOUT, _local
89

910

1011
@pytest.fixture(autouse=True)
@@ -34,5 +35,40 @@ def test_init_import_before_create_fail():
3435
init("org", "project", "token", before_create="missing")
3536

3637

38+
def test_init_default_timeout():
39+
_init_token()
40+
assert Badger.current.settings.timeout == DEFAULT_HTTP_TIMEOUT
41+
assert Badger.current.client().get_httpx_client().timeout == httpx.Timeout(DEFAULT_HTTP_TIMEOUT)
42+
43+
44+
def test_init_timeout_override():
45+
_init_token(timeout=30)
46+
assert Badger.current.client().get_httpx_client().timeout == httpx.Timeout(30)
47+
48+
49+
def test_init_timeout_from_env(monkeypatch):
50+
monkeypatch.setenv("TASKBADGER_HTTP_TIMEOUT", "12.5")
51+
_init_token()
52+
assert Badger.current.settings.timeout == 12.5
53+
54+
55+
def test_init_timeout_arg_beats_env(monkeypatch):
56+
monkeypatch.setenv("TASKBADGER_HTTP_TIMEOUT", "12.5")
57+
_init_token(timeout=httpx.Timeout(1, connect=2))
58+
assert Badger.current.client().get_httpx_client().timeout == httpx.Timeout(1, connect=2)
59+
60+
61+
def test_init_timeout_from_env_invalid(monkeypatch):
62+
monkeypatch.setenv("TASKBADGER_HTTP_TIMEOUT", "soon")
63+
with pytest.raises(ConfigurationError):
64+
_init_token()
65+
66+
67+
def _init_token(**kwargs):
68+
with warnings.catch_warnings():
69+
warnings.simplefilter("ignore", DeprecationWarning)
70+
init("org", "project", "token", **kwargs)
71+
72+
3773
def _before_create(_):
3874
pass

0 commit comments

Comments
 (0)