Skip to content

Commit 303386c

Browse files
worker: accept compatible-MINOR worker_protocol versions instead of strict-equal
Previously the SDK rejected any worker_protocol.version that did not exactly match the SDK's own constant (1.1). When the server bumped to worker_protocol 1.2 (additive — new optional fields, new non-terminal command types per workflow:v2's WorkerProtocolVersion contract), every sdk-python worker started failing registration with "Server compatibility error: unsupported worker_protocol.version '1.2'; sdk-python requires '1.1'". Replace the strict equality with a major-equal + server-minor>=sdk-minor check. The SDK can talk to a newer server happily — it just won't exercise the new optional shapes. Major bumps still hard-reject as before. Add a positive test that a higher compatible MINOR is accepted, and rename the existing major-mismatch test for clarity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4bf469c commit 303386c

2 files changed

Lines changed: 54 additions & 6 deletions

File tree

src/durable_workflow/worker.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,38 @@ def _manifest_version(manifest: Any) -> str:
164164
return "missing"
165165

166166

167+
def _parse_protocol_version(value: str) -> tuple[int, int] | None:
168+
"""Parse "MAJOR.MINOR" into a tuple. Returns None for malformed inputs."""
169+
if not isinstance(value, str):
170+
return None
171+
parts = value.split(".")
172+
if len(parts) != 2:
173+
return None
174+
try:
175+
return int(parts[0]), int(parts[1])
176+
except ValueError:
177+
return None
178+
179+
180+
def _server_protocol_compatible(server_version: str, sdk_version: str) -> bool:
181+
"""A server's worker_protocol.version is compatible with this SDK when
182+
they share a major and the server's minor is at least the SDK's minor.
183+
184+
Per workflow:v2's WorkerProtocolVersion contract, MINOR bumps are
185+
additive (new optional fields, new non-terminal command types) and
186+
therefore safe for older SDKs to talk to newer servers — they just
187+
will not exercise the new optional shapes. MAJOR bumps are breaking
188+
and must always be rejected.
189+
"""
190+
server = _parse_protocol_version(server_version)
191+
sdk = _parse_protocol_version(sdk_version)
192+
if server is None or sdk is None:
193+
return server_version == sdk_version
194+
if server[0] != sdk[0]:
195+
return False
196+
return server[1] >= sdk[1]
197+
198+
167199
def _validate_server_compatibility(info: dict[str, Any]) -> None:
168200
control_plane = info.get("control_plane")
169201
if not isinstance(control_plane, dict):
@@ -206,10 +238,11 @@ def _validate_server_compatibility(info: dict[str, Any]) -> None:
206238
)
207239

208240
worker_protocol_version = _manifest_version(worker_protocol)
209-
if worker_protocol_version != PROTOCOL_VERSION:
241+
if not _server_protocol_compatible(worker_protocol_version, PROTOCOL_VERSION):
210242
raise RuntimeError(
211-
"Server compatibility error: unsupported worker_protocol.version "
212-
f"{worker_protocol_version!r}; sdk-python requires {PROTOCOL_VERSION!r}."
243+
"Server compatibility error: incompatible worker_protocol.version "
244+
f"{worker_protocol_version!r}; sdk-python requires major-equal and "
245+
f"minor>={PROTOCOL_VERSION!r}."
213246
)
214247

215248
auth_composition = info.get("auth_composition_contract")

tests/test_worker.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -332,15 +332,30 @@ async def test_register_rejects_missing_request_contract(self, mock_client: Asyn
332332
mock_client.register_worker.assert_not_called()
333333

334334
@pytest.mark.asyncio
335-
async def test_register_rejects_unsupported_worker_protocol_version(self, mock_client: AsyncMock) -> None:
335+
async def test_register_rejects_worker_protocol_major_mismatch(self, mock_client: AsyncMock) -> None:
336336
mock_client.get_cluster_info = AsyncMock(
337337
return_value=compatible_cluster_info(worker_protocol={"version": "2.0"})
338338
)
339339
worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow], activities=[])
340-
with pytest.raises(RuntimeError, match="unsupported worker_protocol.version"):
340+
with pytest.raises(RuntimeError, match="incompatible worker_protocol.version"):
341341
await worker._register()
342342
mock_client.register_worker.assert_not_called()
343343

344+
@pytest.mark.asyncio
345+
async def test_register_accepts_higher_compatible_minor_protocol(self, mock_client: AsyncMock) -> None:
346+
# Server is one minor ahead of the SDK. MINOR bumps in workflow:v2's
347+
# WorkerProtocolVersion are documented as additive (new optional
348+
# fields, new non-terminal command types) so the SDK must talk to a
349+
# newer server happily — the test pins that contract.
350+
major, minor = (int(p) for p in PROTOCOL_VERSION.split("."))
351+
future_version = f"{major}.{minor + 1}"
352+
mock_client.get_cluster_info = AsyncMock(
353+
return_value=compatible_cluster_info(worker_protocol={"version": future_version})
354+
)
355+
worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow], activities=[])
356+
await worker._register()
357+
mock_client.register_worker.assert_called_once()
358+
344359
@pytest.mark.asyncio
345360
async def test_register_rejects_worker_protocol_below_payload_codec_floor(
346361
self, mock_client: AsyncMock
@@ -349,7 +364,7 @@ async def test_register_rejects_worker_protocol_below_payload_codec_floor(
349364
return_value=compatible_cluster_info(worker_protocol={"version": "1.0"})
350365
)
351366
worker = Worker(mock_client, task_queue="q1", workflows=[TestWorkflow], activities=[])
352-
with pytest.raises(RuntimeError, match="requires '1.1'"):
367+
with pytest.raises(RuntimeError, match=r"minor>='1\.1'"):
353368
await worker._register()
354369
mock_client.register_worker.assert_not_called()
355370

0 commit comments

Comments
 (0)