Skip to content

Commit 6c5ad45

Browse files
Make Python first-run URL and workflow identity mistakes fail clearly
1 parent 347048c commit 6c5ad45

5 files changed

Lines changed: 142 additions & 32 deletions

File tree

‎CHANGELOG.md‎

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

77
## [Unreleased]
88

9-
The Python SDK advances to the synchronized Durable Workflow `2.0.0-rc.7`
10-
product train. PyPI displays the normalized PEP 440 identity `2.0.0rc7`. Earlier SDK
11-
versions remain historical releases and are not alternate supported 2.0
12-
baselines; no prerelease compatibility shim is provided.
9+
The Python SDK advances to prerelease `2.0.0-rc.8`. PyPI displays the normalized
10+
PEP 440 identity `2.0.0rc8`. This SDK release is qualified with Server
11+
`2.0.0-rc.12`; SDK and Server prerelease sequence numbers advance independently.
12+
Earlier SDK versions remain historical releases and are not alternate supported
13+
2.0 baselines; no prerelease compatibility shim is provided.
1314

1415
### Added
1516
- The optimized `fastavro` payload path now uses the fixed recursive
@@ -50,6 +51,10 @@ baselines; no prerelease compatibility shim is provided.
5051
`NamespaceDescription.deleted`.
5152

5253
### Fixed
54+
- Client construction now rejects base URLs with a terminal SDK-owned `/api`
55+
suffix, query, or fragment while preserving self-hosted and managed-runtime
56+
path prefixes. The quickstart also uses unique durable workflow identities
57+
and documents typed duplicate-start handling.
5358
- Condition-timeout history is now identified by its durable wait identity as
5459
well as its timer kind, keeping internal timeout rows out of the ordinary
5560
activity cursor when updates and signals resolve a timed condition.

‎README.md‎

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@
22

33
A Python SDK for the [Durable Workflow server](https://github.com/durable-workflow/server). Speaks the server's language-neutral HTTP/JSON worker protocol — no PHP runtime required.
44

5-
Status: **Beta** — this SDK is part of the synchronized Durable Workflow
6-
`2.0.0-rc.7` product train. Core features include workflows, activities,
5+
Status: **Beta** — this is Python SDK prerelease `2.0.0-rc.8` in the Durable
6+
Workflow 2.0 line. Core features include workflows, activities,
77
schedules, signals, timers, child workflows, continue-as-new, side effects,
88
version markers, worker-applied accepted updates, replay verification, the
99
in-process `WorkflowEnvironment` test harness, and invocable activity carriers.
1010

1111
## Install
1212

1313
```bash
14-
pip install durable-workflow==2.0.0-rc.7
14+
pip install durable-workflow==2.0.0-rc.8
1515
```
1616

1717
Or for development:
@@ -24,6 +24,7 @@ pip install -e '.[dev]'
2424

2525
```python
2626
import asyncio
27+
from uuid import uuid4
2728

2829
from durable_workflow import Client, Worker, workflow, activity
2930

@@ -38,27 +39,46 @@ class GreeterWorkflow:
3839
return result
3940

4041
async def main():
41-
client = Client("http://server:8080", token="dev-token-123", namespace="default")
42-
worker = Worker(
43-
client,
44-
task_queue="python-workers",
45-
workflows=[GreeterWorkflow],
46-
activities=[greet],
47-
)
48-
handle = await client.start_workflow(
49-
workflow_type="greeter",
50-
workflow_id="greet-1",
51-
task_queue="python-workers",
52-
input=["world"],
53-
)
54-
await worker.run_until(workflow_id="greet-1", timeout=30.0)
55-
result = await client.get_result(handle)
56-
print(result) # "hello, world"
42+
workflow_id = f"greet-{uuid4().hex}"
43+
async with Client(
44+
"http://server:8080",
45+
token="dev-token-123",
46+
namespace="default",
47+
) as client:
48+
worker = Worker(
49+
client,
50+
task_queue="python-workers",
51+
workflows=[GreeterWorkflow],
52+
activities=[greet],
53+
)
54+
handle = await client.start_workflow(
55+
workflow_type="greeter",
56+
workflow_id=workflow_id,
57+
task_queue="python-workers",
58+
input=["world"],
59+
)
60+
await worker.run_until(workflow_id=workflow_id, timeout=30.0)
61+
result = await client.get_result(handle)
62+
print(result) # "hello, world"
5763

5864
if __name__ == "__main__":
5965
asyncio.run(main())
6066
```
6167

68+
Pass the server or managed-runtime base URL to `Client`; the SDK appends its
69+
own `/api` routes. For example, use `http://server:8080`, not
70+
`http://server:8080/api`. Managed-runtime paths such as
71+
`https://cloud.example/api/runtime/v1/namespaces/acme` are valid as written.
72+
73+
A workflow ID is the durable identity of an instance, not a per-attempt request
74+
ID. Starting the same ID again with the default `reject` duplicate policy raises
75+
the typed `WorkflowAlreadyStarted` exception. Generate a unique ID for each new
76+
instance, as the quickstart does. For an intentionally idempotent start, catch
77+
`WorkflowAlreadyStarted` and reconnect with
78+
`client.get_workflow_handle(workflow_id)`; choose `allow` or
79+
`terminate_existing` only when creating another run or replacing the current
80+
instance is the intended behavior.
81+
6282
For a fuller deployable example, see
6383
[`examples/order_processing`](examples/order_processing), which runs a
6484
multi-activity order workflow against a local server with Docker Compose.
@@ -585,8 +605,11 @@ Full documentation is available at
585605

586606
## Compatibility
587607

588-
SDK version `2.0.0-rc.7` is supported with server `2.0.0-rc.7`. The server
589-
must advertise these protocol manifests from `GET /api/cluster/info`:
608+
SDK version `2.0.0-rc.8` is qualified with
609+
`durableworkflow/server:2.0.0-rc.12`. The SDK package and Server release
610+
identities advance independently; matching RC sequence numbers are not
611+
required. The server must advertise these protocol manifests from
612+
`GET /api/cluster/info`:
590613

591614
- `control_plane.version: "2"`
592615
- `control_plane.request_contract.schema: durable-workflow.v2.control-plane-request.contract` version `1`

‎pyproject.toml‎

Lines changed: 4 additions & 4 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.0.0-rc.7"
7+
version = "2.0.0-rc.8"
88
description = "Python SDK for the Durable Workflow server (language-neutral HTTP protocol)"
99
readme = "README.md"
1010
requires-python = ">=3.10"
@@ -64,9 +64,9 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
6464
durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
6565

6666
[tool.durable-workflow]
67-
product-train = "2.0.0-rc.7"
68-
registry-version = "2.0.0rc7"
69-
supported-server-versions = "2.0.0-rc.7"
67+
product-train = "2.0.0-rc.8"
68+
registry-version = "2.0.0rc8"
69+
supported-server-versions = "2.0.0-rc.12"
7070
worker-protocol-version = "1.1"
7171
control-plane-version = "2"
7272

‎src/durable_workflow/client.py‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from importlib.metadata import PackageNotFoundError
3030
from importlib.metadata import version as _pkg_version
3131
from typing import Any
32-
from urllib.parse import quote, urlencode
32+
from urllib.parse import quote, urlencode, urlsplit
3333

3434
import httpx
3535

@@ -97,6 +97,21 @@ def _protocol_version_from_env(name: str, default: str) -> str:
9797
return value.strip()
9898

9999

100+
def _normalize_base_url(base_url: str) -> str:
101+
parsed = urlsplit(base_url)
102+
if parsed.query or parsed.fragment:
103+
raise ValueError("base_url must not include a query or fragment; pass only the server or managed-runtime URL")
104+
105+
normalized_path = parsed.path.rstrip("/")
106+
if normalized_path.endswith("/api"):
107+
raise ValueError(
108+
"base_url must be the server or managed-runtime root without the SDK-owned '/api' suffix; "
109+
"remove the trailing '/api' because Client appends it automatically"
110+
)
111+
112+
return base_url.rstrip("/")
113+
114+
100115
def _route_for_metrics(path: str) -> str:
101116
clean_path = path.split("?", 1)[0]
102117
parts = [part for part in clean_path.strip("/").split("/") if part]
@@ -1232,6 +1247,9 @@ def iter_history(
12321247
class Client:
12331248
"""Async HTTP client for Durable Workflow control-plane and worker APIs.
12341249
1250+
``base_url`` is the server or managed-runtime prefix. Do not append the
1251+
SDK-owned ``/api`` route prefix or include a query string or fragment.
1252+
12351253
The client owns one `httpx.AsyncClient` connection pool. Use it as an async
12361254
context manager or call `aclose()` when finished.
12371255
"""
@@ -1254,7 +1272,7 @@ def __init__(
12541272
external_storage_threshold_bytes: int | None = None,
12551273
external_storage_cache: ExternalPayloadCache | None = None,
12561274
) -> None:
1257-
self.base_url = base_url.rstrip("/")
1275+
self.base_url = _normalize_base_url(base_url)
12581276
self.token = token
12591277
self.control_token = control_token
12601278
self.worker_token = worker_token
@@ -1676,7 +1694,7 @@ async def execute_nexus_operation(
16761694

16771695
# ── Health ─────────────────────────────────────────────────────────
16781696
async def health(self) -> dict[str, Any]:
1679-
"""Call the server's ``/health`` endpoint and return the JSON response."""
1697+
"""Call the server's ``/api/health`` endpoint and return the JSON response."""
16801698
result = await self._request("GET", "/health")
16811699
if not isinstance(result, dict):
16821700
raise ServerError(

‎tests/test_client.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,70 @@ def client() -> Client:
4848
return Client("http://localhost:8080", token="test-token", namespace="ns1")
4949

5050

51+
class TestClientBaseUrl:
52+
@pytest.mark.asyncio
53+
@pytest.mark.parametrize(
54+
("base_url", "expected"),
55+
[
56+
pytest.param(
57+
"http://localhost:8080/",
58+
"http://localhost:8080",
59+
id="self-hosted-root",
60+
),
61+
pytest.param(
62+
"https://self-hosted.example/durable-workflow/",
63+
"https://self-hosted.example/durable-workflow",
64+
id="self-hosted-path-prefix",
65+
),
66+
pytest.param(
67+
"https://cloud.example/api/runtime/v1/namespaces/acme/",
68+
"https://cloud.example/api/runtime/v1/namespaces/acme",
69+
id="managed-runtime-prefix",
70+
),
71+
],
72+
)
73+
async def test_accepts_server_and_runtime_prefixes(self, base_url: str, expected: str) -> None:
74+
client = Client(base_url)
75+
76+
assert client.base_url == expected
77+
assert str(client._http.build_request("GET", "/api/health").url) == f"{expected}/api/health"
78+
79+
await client.aclose()
80+
81+
@pytest.mark.parametrize(
82+
"base_url",
83+
[
84+
pytest.param("http://localhost:8080/api", id="self-hosted"),
85+
pytest.param("http://localhost:8080/api/", id="self-hosted-trailing-slash"),
86+
pytest.param(
87+
"https://cloud.example/api/runtime/v1/namespaces/acme/api",
88+
id="managed-runtime",
89+
),
90+
pytest.param(
91+
"https://cloud.example/api/runtime/v1/namespaces/acme/api/",
92+
id="managed-runtime-trailing-slash",
93+
),
94+
],
95+
)
96+
def test_rejects_sdk_owned_api_suffix(self, base_url: str) -> None:
97+
with pytest.raises(ValueError, match=r"remove the trailing '/api'"):
98+
Client(base_url)
99+
100+
@pytest.mark.parametrize(
101+
"base_url",
102+
[
103+
pytest.param("http://localhost:8080?region=local", id="self-hosted-query"),
104+
pytest.param(
105+
"https://cloud.example/api/runtime/v1/namespaces/acme#workers",
106+
id="managed-runtime-fragment",
107+
),
108+
],
109+
)
110+
def test_rejects_query_and_fragment(self, base_url: str) -> None:
111+
with pytest.raises(ValueError, match="query or fragment"):
112+
Client(base_url)
113+
114+
51115
class TestHeaders:
52116
def test_control_plane_headers(self, client: Client) -> None:
53117
h = client._headers(worker=False)

0 commit comments

Comments
 (0)