test(wac): pin the failure record with one corpus both SDKs read

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-07-28 11:25:26 +00:00
co-authored by Claude Opus 5
parent aeaea57ca1
commit a558d876d8
4 changed files with 192 additions and 15 deletions
@@ -0,0 +1,80 @@
{
"_readme": [
"Cases the python and the typescript SDK must both satisfy when they serialize",
"a failed step() body into a `__wmill_error` marker.",
"",
"The two drifted apart twice while #10368 was in review: `name` was taken from",
"the constructor in one client and from `e.name` in the other, and `stack` was",
"written in two different formats. Each was caught by a reviewer reading the",
"diff, not by a test, because each suite only ever checked its own language.",
"A shared corpus turns the next divergence into a failure in both suites.",
"",
"It sits next to `wac_failure_record`, the function that decides the record's",
"final shape, because that is the contract these markers are the raw material",
"for. The SDK test suites read it by relative path.",
"",
"`thrown` describes a value each language constructs natively:",
" name the name the record must report",
" message the message the record must report",
" props own properties/attributes that must reach `extra`",
" circular_prop an own property holding a cycle, which must be dropped",
" without taking the rest of `extra` with it",
"`expect.stack` is only \"present\" or \"absent\": its text is language-specific",
"(each client matches its own executor's format) and is asserted there.",
"",
"Known divergence, deliberately not covered here: a non-finite float reaches",
"`extra` as the string \"NaN\" in python and as null in typescript, because",
"JSON.stringify has no hook for it. The executors differ the same way."
],
"cases": [
{
"case": "a named error keeps its name and message",
"thrown": { "name": "HttpError", "message": "429 too many requests" },
"expect": {
"name": "HttpError",
"message": "429 too many requests",
"stack": "present",
"absent": ["extra", "extra_omitted"]
}
},
{
"case": "custom properties reach extra",
"thrown": {
"name": "HttpError",
"message": "429",
"props": { "code": 429, "retry_after": 5, "endpoint": "/v1/jobs" }
},
"expect": {
"name": "HttpError",
"message": "429",
"stack": "present",
"extra": { "code": 429, "retry_after": 5, "endpoint": "/v1/jobs" }
}
},
{
"case": "a property that cannot be serialized is dropped on its own",
"thrown": {
"name": "RequestError",
"message": "socket hang up",
"props": { "code": "ECONNRESET" },
"circular_prop": "request"
},
"expect": {
"name": "RequestError",
"message": "socket hang up",
"stack": "present",
"extra": { "code": "ECONNRESET" }
}
},
{
"case": "an error carrying nothing of its own has no extra",
"thrown": { "name": "ValueError", "message": "nope" },
"expect": {
"name": "ValueError",
"message": "nope",
"stack": "present",
"absent": ["extra"]
}
}
]
}
@@ -2,6 +2,7 @@
import asyncio
import json
import pathlib
import pytest
from datetime import datetime, timezone
@@ -968,6 +969,64 @@ class TestErrorPropagation:
assert "step failed" in r["result"]["caught"]
class TestFailureRecordCorpus:
"""The cases both SDKs must agree on, read from one shared file.
`name` and `stack` drifted between the two clients twice while the record
was being unified, and each time only a reviewer noticed: a suite that only
knows its own language cannot see a divergence. The corpus is the same file
the typescript suite reads.
"""
CORPUS = json.loads(
(
pathlib.Path(__file__).resolve().parents[3]
/ "backend/windmill-common/src/wac_failure_corpus.json"
).read_text()
)
@staticmethod
def _construct(spec: dict) -> BaseException:
exc = type(spec["name"], (Exception,), {})(spec["message"])
for key, value in (spec.get("props") or {}).items():
setattr(exc, key, value)
if spec.get("circular_prop"):
cyclic: dict = {}
cyclic["self"] = cyclic
setattr(exc, spec["circular_prop"], cyclic)
return exc
@pytest.mark.parametrize("case", CORPUS["cases"], ids=lambda c: c["case"])
def test_marker_matches_the_shared_contract(self, case):
exc = self._construct(case["thrown"])
def raiser():
raise exc
# through the real step path, so the stack is the one a failure actually
# records rather than one this test happens to construct
async def run():
ctx = WorkflowCtx({})
try:
await ctx._run_inline_step("k", raiser)
except _StepSuspend as suspended:
return suspended.dispatch_info["result"]
raise AssertionError("a raising step did not suspend")
error = asyncio.run(run())["result"]["error"]
expect = case["expect"]
assert error["name"] == expect["name"]
assert error["message"] == expect["message"]
assert ("stack" in error) == (expect["stack"] == "present")
if "extra" in expect:
assert error["extra"] == expect["extra"]
for absent in expect.get("absent", []):
assert absent not in error
# whatever it kept has to survive the trip to the checkpoint
json.dumps(error, allow_nan=False)
class TestRaisingInlineStepIsCheckpointed:
"""A ``step()`` whose body raises must still land in ``completed_steps``.
+21 -15
View File
@@ -2784,21 +2784,27 @@ def _step_error_marker(key: str, exc: BaseException) -> dict:
# connection raises in turn. This runs inside the ``except`` that is
# reporting the user's failure, so an escape here would replace their
# error with an unrelated one and skip the checkpoint entirely.
# Narrow: what json raises for something it cannot represent. A broader
# catch would hide a mistake in this function as a silently missing
# field, which is how it read before.
try:
# ``parse_constant`` catches the one thing ``default`` cannot: a
# float is serializable, so ``NaN``/``Infinity`` pass through as
# bare literals that are not JSON and that the backend's extractor
# rejects — taking the whole checkpoint down with them. Kept as
# their text rather than dropped, so the attribute still says
# something.
error["extra"] = json.loads(
json.dumps(extra, default=_safe_str), parse_constant=lambda c: c
)
except (TypeError, ValueError, RecursionError):
pass
#
# Per attribute, so one that cannot be represented does not take the
# rest with it — an ``AxiosError``-shaped exception carrying both a
# ``code`` and a cyclic ``request`` keeps the ``code``. The typescript
# client admits properties one at a time for the same reason.
safe_extra = {}
for _k, _v in extra.items():
# Narrow: what json raises for something it cannot represent. A
# broader catch would hide a mistake here as a silently missing
# field, which is how it read before. ``parse_constant`` catches the
# one thing ``default`` cannot: a float is serializable, so
# ``NaN``/``Infinity`` pass through as bare literals that are not
# JSON and that the backend's extractor rejects.
try:
safe_extra[_k] = json.loads(
json.dumps(_v, default=_safe_str), parse_constant=lambda c: c
)
except (TypeError, ValueError, RecursionError):
pass
if safe_extra:
error["extra"] = safe_extra
return {
"__wmill_error": True,
"message": _safe_str(exc),
+32
View File
@@ -10,6 +10,38 @@ import { expect, test, describe } from "bun:test";
// backend's shape before, so a copy of them here would guard nothing.
import { isSuspendSignal, stepErrorMarker, taskErrorFromMarker } from "../wacError";
// The cases both SDKs must agree on, read from the same file the python suite
// reads. `name` and `stack` drifted between the two clients twice while the
// record was being unified, and a suite that only knows its own language cannot
// see that.
import corpus from "../../backend/windmill-common/src/wac_failure_corpus.json";
describe("shared failure-record corpus", () => {
const construct = (spec: any): any => {
const e: any = Object.assign(new Error(spec.message), { name: spec.name });
for (const [k, v] of Object.entries(spec.props ?? {})) e[k] = v;
if (spec.circular_prop) {
const cyclic: any = {};
cyclic.self = cyclic;
e[spec.circular_prop] = cyclic;
}
return e;
};
for (const c of (corpus as any).cases) {
test(c.case, () => {
const error = stepErrorMarker("k", construct(c.thrown)).result.error;
expect(error.name).toBe(c.expect.name);
expect(error.message).toBe(c.expect.message);
expect("stack" in error).toBe(c.expect.stack === "present");
if (c.expect.extra) expect(error.extra).toEqual(c.expect.extra);
for (const absent of c.expect.absent ?? []) expect(absent in error).toBe(false);
// whatever it kept has to survive the trip to the checkpoint
expect(() => JSON.stringify(error)).not.toThrow();
});
}
});
// --- Inline SDK (mirrors client.ts implementation) ---
class StepSuspend extends Error {