Skip to content

Commit 08a5bf0

Browse files
authored
feat: add timeout parameter to Python API functions (#597)
#### Relevant issue or PR n/a #### Description of changes In pathological cases, HTTP requests can hang forever (e.g. when a container runs out of memory or just hangs indefinitely). This is hard to detect and work around if it happens deep within a call stack. This PR introduces a `timeout` parameter to `Tesseract.from_url` and `Tesseract.from_image` so users can at least fail gracefully if requests hang. #### Testing done CI
1 parent f488e85 commit 08a5bf0

3 files changed

Lines changed: 129 additions & 7 deletions

File tree

tesseract_core/sdk/tesseract.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,19 +59,28 @@ class Tesseract:
5959
_lastlog: str | None = None
6060
_client: HTTPClient | LocalClient | None = None
6161
_stream_logs: BoolOrCallable = False
62+
_timeout: float | tuple[float, float] | None = None
6263

63-
def __init__(self, url: str, server_output_path: str | Path | None = None) -> None:
64+
def __init__(
65+
self,
66+
url: str,
67+
server_output_path: str | Path | None = None,
68+
timeout: float | tuple[float, float] | None = None,
69+
) -> None:
6470
warnings.warn(
6571
"Direct instantiation of Tesseract is deprecated. "
6672
"Use Tesseract.from_url(), Tesseract.from_image(), or Tesseract.from_tesseract_api() instead.",
6773
UserWarning,
6874
stacklevel=2,
6975
)
70-
self._client = HTTPClient(url, output_path=server_output_path)
76+
self._client = HTTPClient(url, output_path=server_output_path, timeout=timeout)
7177

7278
@classmethod
7379
def from_url(
74-
cls, url: str, server_output_path: str | Path | None = None
80+
cls,
81+
url: str,
82+
server_output_path: str | Path | None = None,
83+
timeout: float | tuple[float, float] | None = None,
7584
) -> Tesseract:
7685
"""Create a Tesseract instance from a URL.
7786
@@ -84,12 +93,17 @@ def from_url(
8493
Must be a path accessible from the client machine (e.g., via a shared or
8594
mounted filesystem), since the server writes .bin files there and the
8695
client reads them from the same path.
96+
timeout: Request timeout in seconds. Can be a float for both connect and
97+
read timeouts, or a ``(connect, read)`` tuple for separate control.
98+
``None`` (the default) disables timeouts. See the `requests documentation
99+
<https://requests.readthedocs.io/en/latest/user/advanced/#timeouts>`_
100+
for details.
87101
88102
Returns:
89103
A Tesseract instance.
90104
"""
91105
obj = cls.__new__(cls)
92-
obj._client = HTTPClient(url, output_path=server_output_path)
106+
obj._client = HTTPClient(url, output_path=server_output_path, timeout=timeout)
93107
return obj
94108

95109
@classmethod
@@ -113,6 +127,7 @@ def from_image(
113127
docker_args: list[str] | None = None,
114128
runtime_config: dict[str, Any] | None = None,
115129
stream_logs: BoolOrCallable = False,
130+
timeout: float | tuple[float, float] | None = None,
116131
) -> Tesseract:
117132
"""Create a Tesseract instance from a Docker image.
118133
@@ -151,6 +166,12 @@ def from_image(
151166
`{"profiling": True}` enables profiling via TESSERACT_PROFILING=true.
152167
stream_logs: If True, stream logs to stdout while endpoints run.
153168
If a callable, stream logs to that callable instead.
169+
timeout: Request timeout in seconds for HTTP calls to the Tesseract.
170+
Can be a float for both connect and read timeouts, or a
171+
``(connect, read)`` tuple for separate control. ``None`` (the default)
172+
disables timeouts. See the `requests documentation
173+
<https://requests.readthedocs.io/en/latest/user/advanced/#timeouts>`_
174+
for details.
154175
155176
Returns:
156177
A Tesseract instance.
@@ -171,6 +192,7 @@ def from_image(
171192
output_path = Path(tempfile.mkdtemp(prefix="tesseract_output_"))
172193

173194
obj._stream_logs = stream_logs
195+
obj._timeout = timeout
174196
obj._spawn_config = dict(
175197
image_name=image_name,
176198
volumes=volumes,
@@ -321,6 +343,7 @@ def serve(self) -> None:
321343
self._client = HTTPClient(
322344
f"http://{host_ip}:{container.host_port}",
323345
output_path=Path(output_path) if output_path else None,
346+
timeout=self._timeout,
324347
)
325348

326349
# Ensure that the Tesseract is torn down once the object is garbage collected,
@@ -666,9 +689,15 @@ def _decode_array(
666689
class HTTPClient:
667690
"""HTTP Client for Tesseracts."""
668691

669-
def __init__(self, url: str, output_path: str | Path | None = None) -> None:
692+
def __init__(
693+
self,
694+
url: str,
695+
output_path: str | Path | None = None,
696+
timeout: float | tuple[float, float] | None = None,
697+
) -> None:
670698
self._url = self._sanitize_url(url)
671699
self._output_path = output_path
700+
self._timeout = timeout
672701
self._session = requests.Session()
673702
self._session.headers["Content-Type"] = "application/json"
674703

@@ -709,15 +738,15 @@ def _request(
709738
data = orjson.dumps(encoded_payload)
710739
try:
711740
response = self._session.request(
712-
method=method, url=url, data=data, params=params
741+
method=method, url=url, data=data, params=params, timeout=self._timeout
713742
)
714743
except requests.ConnectionError:
715744
# Retry once on stale keep-alive connections. There is a race
716745
# between urllib3's is_connection_dropped check and the server
717746
# closing idle connections (uvicorn timeout_keep_alive) that
718747
# can cause ConnectionError on an otherwise healthy server.
719748
response = self._session.request(
720-
method=method, url=url, data=data, params=params
749+
method=method, url=url, data=data, params=params, timeout=self._timeout
721750
)
722751

723752
if response.status_code == requests.codes.unprocessable_entity:

tests/endtoend_tests/test_tesseract_sdk.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,8 @@ def test_signature_consistency():
214214
"output_format",
215215
# stream_logs is SDK-only for streaming logs to a callback
216216
"stream_logs",
217+
# timeout is a client-side HTTP parameter, not relevant for serve
218+
"timeout",
217219
]
218220

219221
from_image_sig = dict(inspect.signature(Tesseract.from_image).parameters)

tests/sdk_tests/test_tesseract.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,70 @@ def test_HTTPClient_run_tesseract(mocker, run_id):
200200
url="http://somehost/apply",
201201
data=orjson.dumps({"inputs": {"a": 1}}),
202202
params=expected_params,
203+
timeout=None,
203204
)
204205

205206

207+
def test_HTTPClient_timeout_passed_to_request(mocker):
208+
"""HTTPClient passes its timeout to every requests.Session.request call."""
209+
mock_response = mocker.Mock()
210+
mock_response.content = b'{"status": "ok"}'
211+
mock_response.ok = True
212+
mock_response.status_code = 200
213+
214+
mocked_request = mocker.patch(
215+
"requests.Session.request",
216+
return_value=mock_response,
217+
)
218+
219+
client = HTTPClient("somehost", timeout=42)
220+
client.run_tesseract("health")
221+
222+
mocked_request.assert_called_with(
223+
method="GET",
224+
url="http://somehost/health",
225+
data=b"null",
226+
params={},
227+
timeout=42,
228+
)
229+
230+
231+
def test_HTTPClient_default_timeout(mocker):
232+
"""HTTPClient has no timeout by default."""
233+
mock_response = mocker.Mock()
234+
mock_response.content = b'{"status": "ok"}'
235+
mock_response.ok = True
236+
mock_response.status_code = 200
237+
238+
mocked_request = mocker.patch(
239+
"requests.Session.request",
240+
return_value=mock_response,
241+
)
242+
243+
client = HTTPClient("somehost")
244+
client.run_tesseract("health")
245+
246+
assert mocked_request.call_args.kwargs["timeout"] is None
247+
248+
249+
def test_HTTPClient_timeout_tuple(mocker):
250+
"""HTTPClient accepts a (connect, read) timeout tuple."""
251+
mock_response = mocker.Mock()
252+
mock_response.content = b'{"status": "ok"}'
253+
mock_response.ok = True
254+
mock_response.status_code = 200
255+
256+
mocked_request = mocker.patch(
257+
"requests.Session.request",
258+
return_value=mock_response,
259+
)
260+
261+
client = HTTPClient("somehost", timeout=(5, 300))
262+
client.run_tesseract("health")
263+
264+
assert mocked_request.call_args.kwargs["timeout"] == (5, 300)
265+
266+
206267
def test_HTTPClient_run_tesseract_raises_validation_error(mocker):
207268
error_detail = {
208269
"detail": [
@@ -632,3 +693,33 @@ def _lie_once(self):
632693
finally:
633694
server.should_exit = True
634695
server_thread.join(timeout=5)
696+
697+
698+
def test_HTTPClient_timeout_fires(free_port):
699+
"""HTTPClient raises a timeout error when the server takes too long to respond."""
700+
import http.server
701+
import socketserver
702+
import threading
703+
704+
shutdown = threading.Event()
705+
706+
class SlowHandler(http.server.BaseHTTPRequestHandler):
707+
def do_GET(self):
708+
shutdown.wait(timeout=10)
709+
710+
def log_message(self, *args):
711+
pass # suppress stderr noise
712+
713+
httpd = socketserver.TCPServer(("127.0.0.1", free_port), SlowHandler)
714+
httpd.daemon_threads = True
715+
server_thread = threading.Thread(target=httpd.serve_forever, daemon=True)
716+
server_thread.start()
717+
718+
try:
719+
client = HTTPClient(f"http://127.0.0.1:{free_port}", timeout=0.5)
720+
with pytest.raises(requests.exceptions.ReadTimeout):
721+
client._request("health")
722+
finally:
723+
shutdown.set()
724+
httpd.shutdown()
725+
server_thread.join(timeout=5)

0 commit comments

Comments
 (0)