Skip to content

Commit 197f282

Browse files
authored
feat(client): default request_timeout applied at the REST seam (#41)
The generated SDK sends _request_timeout=None (urllib3 no-timeout) on every call unless a caller passes one per call — and the helper methods (load_managed_table, list_managed_tables, ...) expose no such knob — so a stalled or black-holed server blocks the calling thread indefinitely, and the configured urllib3 read retries never fire because no read timeout ever triggers them. HotdataClient (and ManagedDatabaseClient, passing through) now accept request_timeout: seconds or a (connect, read) pair, installed once at construction by wrapping rest_client.request to default the per-call _request_timeout. Explicit per-call values still win; the default stays None so existing behavior is unchanged unless opted in. A construction- time deadline means stalled calls fail at the socket and the connection tears down — no caller-side thread tricks, and new call sites are bounded by construction. Co-authored-by: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com>
1 parent c336c5f commit 197f282

4 files changed

Lines changed: 117 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- `HotdataClient` and `ManagedDatabaseClient` accept `request_timeout` (seconds, or a `(connect, read)` pair). The generated SDK otherwise issues every HTTP request with urllib3's no-timeout default, so a stalled or unreachable server blocks the calling thread indefinitely; the new parameter applies a socket-level deadline to every call through the client while still honoring an explicit per-call `_request_timeout`. Also exported as `apply_default_request_timeout(api_client, timeout)` for callers holding a raw generated client. Default remains no timeout (behavior unchanged unless opted in).
1013

1114
## [0.6.2] - 2026-07-08
1215

hotdata_framework/client.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import functools
34
import time
45
from collections.abc import Iterator
56
from dataclasses import asdict, dataclass
@@ -71,6 +72,45 @@ def to_dict(self) -> dict[str, Any]:
7172
return asdict(self)
7273

7374

75+
76+
def apply_default_request_timeout(
77+
api_client: ApiClient, timeout: float | tuple[float, float]
78+
) -> None:
79+
"""Give every request through this client a socket-level deadline.
80+
81+
The generated client forwards ``_request_timeout=None`` — urllib3's
82+
no-timeout — on every call unless the caller passes one explicitly, and
83+
most helper methods expose no such knob. A stalled or black-holed server
84+
therefore blocks the calling thread indefinitely. Wrapping the REST seam
85+
applies ``timeout`` (seconds, or a ``(connect, read)`` pair) as the
86+
default while still honoring an explicit per-call ``_request_timeout``.
87+
"""
88+
rest_client = api_client.rest_client
89+
original = rest_client.request
90+
91+
@functools.wraps(original)
92+
def request_with_default_timeout(
93+
method,
94+
url,
95+
headers=None,
96+
body=None,
97+
post_params=None,
98+
_request_timeout=None,
99+
):
100+
if _request_timeout is None:
101+
_request_timeout = timeout
102+
return original(
103+
method,
104+
url,
105+
headers=headers,
106+
body=body,
107+
post_params=post_params,
108+
_request_timeout=_request_timeout,
109+
)
110+
111+
rest_client.request = request_with_default_timeout
112+
113+
74114
class HotdataClient:
75115
"""Thin wrapper around the Hotdata Python SDK with query polling helpers."""
76116

@@ -81,6 +121,7 @@ def __init__(
81121
*,
82122
host: str | None = None,
83123
session_id: str | None = None,
124+
request_timeout: float | tuple[float, float] | None = None,
84125
) -> None:
85126
self._host = normalize_host(host) if host else default_host()
86127
self._api_key = api_key
@@ -94,6 +135,8 @@ def __init__(
94135
retries=default_http_retries(),
95136
)
96137
self._api = ApiClient(self._config)
138+
if request_timeout is not None:
139+
apply_default_request_timeout(self._api, request_timeout)
97140

98141
@classmethod
99142
def from_env(cls) -> HotdataClient:

hotdata_framework/managed_client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,15 @@ def __init__(
6161
api_base_url: str,
6262
max_retries: int,
6363
retry_backoff_seconds: float,
64+
request_timeout: float | tuple[float, float] | None = None,
6465
) -> None:
6566
self._max_retries = max_retries
6667
self._retry_backoff_seconds = retry_backoff_seconds
6768
self._runtime = RuntimeClient(
6869
api_key,
6970
workspace_id,
7071
host=api_base_url.rstrip("/"),
72+
request_timeout=request_timeout,
7173
)
7274

7375
def close(self) -> None:

tests/test_request_timeout.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""The default request timeout must reach every call through the client.
2+
3+
The generated client sends ``_request_timeout=None`` (urllib3 no-timeout)
4+
unless a caller passes one per call — and the helper methods expose no such
5+
knob — so a stalled server blocks the calling thread indefinitely.
6+
``apply_default_request_timeout`` wraps the REST seam once at construction.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from hotdata_framework.client import HotdataClient, apply_default_request_timeout
12+
from hotdata_framework.managed_client import ManagedDatabaseClient
13+
14+
15+
class _FakeRest:
16+
def __init__(self) -> None:
17+
self.seen: list[object] = []
18+
19+
def request(self, method, url, headers=None, body=None, post_params=None, _request_timeout=None):
20+
self.seen.append(_request_timeout)
21+
return "resp"
22+
23+
24+
class _FakeApi:
25+
def __init__(self) -> None:
26+
self.rest_client = _FakeRest()
27+
28+
29+
def test_default_applied_when_no_per_call_timeout() -> None:
30+
api = _FakeApi()
31+
rest = api.rest_client
32+
apply_default_request_timeout(api, (10.0, 60.0))
33+
34+
assert api.rest_client.request("GET", "http://x") == "resp"
35+
assert rest.seen == [(10.0, 60.0)]
36+
37+
38+
def test_explicit_per_call_timeout_wins() -> None:
39+
api = _FakeApi()
40+
rest = api.rest_client
41+
apply_default_request_timeout(api, 60.0)
42+
43+
api.rest_client.request("GET", "http://x", _request_timeout=5.0)
44+
assert rest.seen == [5.0]
45+
46+
47+
def test_hotdata_client_wraps_its_rest_seam() -> None:
48+
client = HotdataClient("k", "w", host="https://example.test", request_timeout=7.0)
49+
# The wrapper is installed in place of the generated bound method.
50+
assert client.api.rest_client.request.__name__ == "request"
51+
assert client.api.rest_client.request.__wrapped__ is not None
52+
53+
54+
def test_hotdata_client_without_timeout_is_untouched() -> None:
55+
client = HotdataClient("k", "w", host="https://example.test")
56+
assert not hasattr(client.api.rest_client.request, "__wrapped__")
57+
58+
59+
def test_managed_client_threads_timeout_through() -> None:
60+
client = ManagedDatabaseClient(
61+
api_key="k",
62+
workspace_id="w",
63+
api_base_url="https://example.test",
64+
max_retries=1,
65+
retry_backoff_seconds=0.0,
66+
request_timeout=(5.0, 30.0),
67+
)
68+
assert client._runtime.api.rest_client.request.__wrapped__ is not None
69+
client.close()

0 commit comments

Comments
 (0)