Skip to content

Commit b0954a1

Browse files
authored
Merge pull request #94 from durable-workflow/fix/configured-http-timeout
Honor configured Client HTTP timeouts
2 parents e02afd2 + 05c8584 commit b0954a1

4 files changed

Lines changed: 103 additions & 4 deletions

File tree

‎CHANGELOG.md‎

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

77
## [Unreleased]
88

9+
## [2.3.9] - 2026-10-04
10+
11+
### Fixed
12+
- Honor the configured Client HTTP timeout for ordinary control, discovery and
13+
worker requests. Explicit long-poll timeouts retain their existing grace.
14+
915
## [2.3.8] - 2026-10-03
1016

1117
### Fixed

‎pyproject.toml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "durable-workflow"
7-
version = "2.3.8"
7+
version = "2.3.9"
88
description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
99
readme = "README.md"
1010
requires-python = ">=3.10"
@@ -71,8 +71,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
7171
durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
7272

7373
[tool.durable-workflow]
74-
product-train = "2.3.8"
75-
registry-version = "2.3.8"
74+
product-train = "2.3.9"
75+
registry-version = "2.3.9"
7676
supported-server-versions = "2.4.8"
7777
worker-protocol-version = "1.19"
7878
control-plane-version = "2"

‎src/durable_workflow/client.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1794,7 +1794,7 @@ async def _do_request() -> httpx.Response:
17941794
f"/api{path}",
17951795
headers=self._discovery_headers() if discovery else self._headers(worker=worker),
17961796
json=request_json,
1797-
timeout=timeout,
1797+
timeout=self.timeout if timeout is None else timeout,
17981798
)
17991799
# Raise HTTPStatusError for 4xx/5xx so retry policy can catch it
18001800
resp.raise_for_status()

‎tests/test_client_timeouts.py‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import json
5+
from collections.abc import AsyncIterator
6+
from contextlib import asynccontextmanager
7+
8+
import httpx
9+
import pytest
10+
11+
from durable_workflow.client import Client
12+
from durable_workflow.retry_policy import TransportRetryPolicy
13+
14+
15+
@asynccontextmanager
16+
async def response_server(*, delay: float | None) -> AsyncIterator[str]:
17+
"""A real HTTP peer, with response progress controlled by the test."""
18+
writers: list[asyncio.StreamWriter] = []
19+
handlers: set[asyncio.Task[None]] = set()
20+
21+
async def respond(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
22+
task = asyncio.current_task()
23+
assert task is not None
24+
handlers.add(task)
25+
writers.append(writer)
26+
try:
27+
headers = await reader.readuntil(b"\r\n\r\n")
28+
for header in headers.split(b"\r\n"):
29+
if header.lower().startswith(b"content-length:"):
30+
await reader.readexactly(int(header.split(b":", 1)[1]))
31+
if delay is None:
32+
await reader.read()
33+
return
34+
await asyncio.sleep(delay)
35+
body = json.dumps({"task": None, "poll_status": "empty"}).encode()
36+
writer.write(
37+
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
38+
+ f"Content-Length: {len(body)}\r\nConnection: close\r\n\r\n".encode()
39+
+ body
40+
)
41+
await writer.drain()
42+
finally:
43+
writer.close()
44+
await writer.wait_closed()
45+
handlers.discard(task)
46+
47+
server = await asyncio.start_server(respond, "127.0.0.1", 0)
48+
try:
49+
assert server.sockets
50+
yield f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}"
51+
finally:
52+
server.close()
53+
await server.wait_closed()
54+
for writer in writers:
55+
writer.close()
56+
if handlers:
57+
await asyncio.gather(*handlers)
58+
59+
60+
@pytest.mark.asyncio
61+
@pytest.mark.parametrize("operation", ["control", "discovery", "worker"])
62+
async def test_configured_timeout_bounds_a_stalled_response(operation: str) -> None:
63+
async with (
64+
response_server(delay=None) as endpoint,
65+
Client(endpoint, timeout=0.05, retry_policy=TransportRetryPolicy(max_attempts=1)) as client,
66+
):
67+
if operation == "control":
68+
request = client.health()
69+
elif operation == "discovery":
70+
request = client.get_cluster_info()
71+
else:
72+
request = client.complete_workflow_task(
73+
task_id="synthetic-timeout-task",
74+
lease_owner="synthetic-timeout-worker",
75+
workflow_task_attempt=1,
76+
commands=[],
77+
)
78+
with pytest.raises(httpx.ReadTimeout):
79+
await asyncio.wait_for(request, timeout=2)
80+
81+
82+
@pytest.mark.asyncio
83+
async def test_explicit_poll_timeout_overrides_a_shorter_client_default() -> None:
84+
async with (
85+
response_server(delay=0.1) as endpoint,
86+
Client(endpoint, timeout=0.01, retry_policy=TransportRetryPolicy(max_attempts=1)) as client,
87+
):
88+
task = await client.poll_workflow_task(
89+
worker_id="synthetic-timeout-worker",
90+
task_queue="synthetic-timeout-queue",
91+
timeout=0,
92+
)
93+
assert task is None

0 commit comments

Comments
 (0)