Skip to content

fix(sdks/python): handle empty CREATE payload in durable waits#4444

Open
STiFLeR7 wants to merge 1 commit into
hatchet-dev:mainfrom
STiFLeR7:fix/issue-4349-durable-wait-empty-payload
Open

fix(sdks/python): handle empty CREATE payload in durable waits#4444
STiFLeR7 wants to merge 1 commit into
hatchet-dev:mainfrom
STiFLeR7:fix/issue-4349-durable-wait-empty-payload

Conversation

@STiFLeR7

Copy link
Copy Markdown

Summary

Fixes #4349aio_sleep_for and aio_wait_for_event on DurableContext crash with RuntimeError: coroutine raised StopIteration (PEP 479) when a durable wait completes with an empty CREATE payload, instead of falling back gracefully.

Root cause

Both methods extracted the first match via:

matches: dict[str, list[dict[str, Any]]] = res.get("CREATE", {})
_, raw_matches = next(iter(matches.items()))
sleep = raw_matches[0]

next(iter(matches.items())) raises StopIteration when matches is empty (e.g. CREATE missing, {}, or a key mapping to an empty list) — and since this executes inside a coroutine, Python 3.7+'s PEP 479 turns that StopIteration into a RuntimeError instead of letting it propagate as a normal exception. aio_wait_for already treats an empty proto payload as valid and falls back to {}, so a durable wait genuinely can complete with no payload — this wasn't an edge case, it was a reachable path that just crashed instead of degrading gracefully.

Fix

Added a shared helper, _first_wait_match_or_none, used by both aio_sleep_for and aio_wait_for_event, that returns None for any empty-payload shape (missing CREATE, empty dict, or empty match list) instead of raising. Both call sites now do _first_wait_match_or_none(res) or {}.

Testing

  • Added tests/unit/test_durable_wait_empty_payload.py: parametrized tests for _first_wait_match_or_none covering empty/missing payload shapes and normal match extraction, plus async tests constructing DurableContext with a mocked aio_wait_for to verify aio_sleep_for/aio_wait_for_event no longer crash and still correctly extract a payload when one is present.
  • poetry run python -m pytest tests/unit/test_durable_wait_empty_payload.py — 7 passed.
  • poetry run ruff check / poetry run ruff format --check — clean.
  • poetry run mypy — no issues.
  • Rebased onto current upstream/main; confirmed no upstream commits touched context.py since this branch was created, so no merge risk.

Pre-existing, unrelated: 6 tests in test_shutdown_ordering.py fail locally on Windows (loop.add_signal_handler raises NotImplementedErrorSIGINT handler registration isn't supported by Windows' asyncio event loop). Verified via git stash that this reproduces identically on unmodified upstream/main, so it's a pre-existing platform limitation, not a regression from this change.

Disclosure

Prepared with AI assistance (Claude Code). I read the surrounding Context/DurableContext code and the PEP 479 mechanics before making the change, wrote the fix and its regression tests, and verified ruff/mypy/pytest all pass locally, including confirming the pre-existing Windows-only test failures are unrelated and reproduce on a clean upstream/main checkout.

aio_sleep_for and aio_wait_for_event both assumed the engine's wait-for
result always contains at least one match, calling next(iter(matches))
and indexing into the match list unconditionally. An empty CREATE
payload (a legitimate response - aio_wait_for already falls back to {}
for an empty proto payload) raised StopIteration inside a coroutine,
which PEP 479 turns into RuntimeError: coroutine raised StopIteration.

Factor the shared extraction logic into _first_wait_match_or_none,
which treats a missing/empty CREATE dict or an empty match list as
"no match" instead of raising, and use it at both call sites.

Fixes hatchet-dev#4349
@vercel

vercel Bot commented Jul 16, 2026

Copy link
Copy Markdown

@STiFLeR7 is attempting to deploy a commit to the Hatchet Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added the sdk-py Related to the Python sdk label Jul 16, 2026
## the list of matches will only have one item, so we can extract and parse it.
## An empty payload is a legitimate response (see `_first_wait_match_or_none`),
## so fall back to an empty dict rather than raising.
sleep = _first_wait_match_or_none(res) or {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needing to fall back here with or {} feels a bit odd - can we have _first_wait_match_or_none return only dict[str, Any] instead, and return {} in the None cases?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sdk-py Related to the Python sdk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Python SDK: durable aio_wait_for_event / aio_sleep_for crash on empty payloads (RuntimeError from PEP 479)

2 participants