Retry surfaces the typed error (#72) - #88
Merged
Wahbeh-Mohammad merged 5 commits intoSep 5, 2026
Merged
Conversation
The retry engine wrapped its terminal failure in `suppress(error, folded, 'retry attempts exhausted')`, which made the surfaced CLASS a function of how many attempts ran: one attempt gave `TransportFailureError`, three gave a wrapper with it at `.error`. The row that catches it is XCUT-1's conformance clause -- "assert the surfaced error is the cancellation type" -- because a cancellation during backoff always has a non-empty trail, so `abortToSdkError` mapped the abort to `CancellationError` at engine.ts:385 and the wrapper undid the mapping at :386. RETRY-34 asks for the prior failures to be "attached to the surfaced exception as suppressed", which is the JVM's `addSuppressed`: the exception stays what it is and grows a list. It does not ask for the exception to be replaced by a container. So `withTrail` becomes `attachTrail`: the outcome's error is returned unchanged, and the priors go into a module-private WeakMap in the new `retry/attempt-trail.ts`, read back through a `@public` `retryAttempts(error)`. A side table rather than an own property, because the engine did not construct the throwable it is surfacing: it may be frozen (so `defineProperty` in the failure path would itself throw and replace the failure the caller cares about), it may be a primitive (which carries nothing at all, and passes through unannotated), and `.suppressed` already means "the one secondary" on `SuppressedErrorLike`. `suppress()` keeps its RECOV-12 job -- `withReleaseFailure` still pairs a release failure with the primary it must not mask. RETRY-34's skip-self guard is unchanged, and an empty trail now DELETES any entry a previous run left, so a transport reusing one error instance reports the run that just surfaced it rather than a stale one. Decision D10 of docs/audit-67-decisions.md. Refs #72, #67.
Four rows across the two suites the issue names, all four red against the
parent commit's engine and green against this one:
retry-safety.conformance.test.ts
- one GET at a closed port surfaces `TransportFailureError` for
maxAttempts 1 AND for 3; the dispatch count is what proves the budgets
actually differed (XCUT-1)
- the two priors are reachable through `retryAttempts()`, oldest first,
with the surfaced instance excluded (RETRY-34)
cancellation-and-timeout.conformance.test.ts
- `expect(surfaced).toBeInstanceOf(CancellationError)` at the TOP level,
which is XCUT-1's conformance clause unqualified. The existing row
asserted only on a chain walk, and its own comment said why: the top
level was the `SuppressedError`. A caller writing `catch` has no walk.
- the 500 that provoked the retry is still reachable in the trail as a
buffered `HttpStatusError` (RETRY-34/RECOV-16)
`chainOf`'s doc comment is rewritten rather than deleted: the walk still earns
its place proving the raw abort survived as `cause` and that no `TimeoutError`
hides one hop down (XCUT-3), but it is no longer the only assertion.
Refs #72, #67.
…mpts reads the rest - pipelines.md gains the note as the FIRST of the retry bullets, with a worked example that `check-fences.mjs` typechecks against the built package. The lead sentence said "two notes each"; it is three now. - errors.md: the `CancellationError`/`TransportFailureError` narrowing section states outright that installing a retry pillar does not change either check, which is the whole point of #72. The `RetryDiscardedResponseError` row at :188 stops calling the trail "suppressed". - write-a-response-handler.md: RECOV-12's release pairing is now documented as the ONLY reason this SDK builds a `SuppressedError`. Verified against every `suppress()` and `withReleaseFailure()` call site in packages/core/src, all of which are a close() that threw with an error already in flight. Both new cross-links resolve against pipelines.md's real headings (checked by script, not by eye). Refs #72, #67.
…pper" `tests/node-conformance/retry.test.mjs` names four runtime-divergent points in its header, and point 2 was RETRY-34's trail going through `suppress()` -- whose native-vs-fallback branch is decided by the runtime, and whose two legs this suite's matrix actually runs (`lts/*` has the global, the pinned `20.3.0` floor does not). #72 takes that branch off the retry path, so the case that asserted "the wrapper has the same shape on either runtime" would now be asserting a shape nothing builds. It becomes the stronger claim: neither leg produces a wrapper, `outcome.error` IS the last attempt's error, and `retryAttempts()` reads the trail through the `@dexpace/core` specifier and the built `dist/`, as a consumer does. The real-timer abort case gains one line: `outcome.error instanceof CancellationError`, over a real `AbortSignal` and a real `setTimeout`, which is the half the unit suite's injected clock cannot reach. OUT OF THE TASK FILE'S STATED PARTITION, deliberately and reported: the partition lists the two `tests/conformance/xcut/` files but not the Node tree, and this case asserts the exact behaviour D10 changes, so `test:node` (and therefore the preflight) is red without it. No wave-3 sibling touches this file -- D2 gives #74 auth plus transport-conformance and #75 rx. Refs #72, #67.
…metic `retryAttempts(caught).length + 1` was documented as "how many sends the pillar made". It is not, and it is wrong on exactly the path #72 exists for: a cancellation observed at the RETRY-32 gate is synthesized by the engine (`engine.ts:394`), never raised by a send, so the trail already covers every attempt and the sum overstates by one. Two more reachable paths make the same sum wrong, both in the engine's own catch: `stampAttempt` throws before `dispatch` is called (`runAttempt` stamps first), and a `Clock.sleep` rejecting for something other than an abort fails after the attempt it followed is already in the trail. And narrowing the catch does not rescue it. `abortToSdkError` branches on `isTimeoutSignal` (`cancellation.ts:37-39`), so the RETRY-32 gate can synthesize a `TransportFailureError` too — which means `pipelines.md`'s worked example was wrong under the very class it narrowed to. That example no longer counts anything; it iterates the priors, which is what the accessor is for. Three prose sites reworded to state what the trail actually holds — one entry per attempt that failed BEFORE the surfaced error — plus the caveat: the surfaced error is an attempt's own only when it came from one. Two new engine cases pin it mechanically rather than by assertion in prose: a caller abort and a timeout abort each after ONE send, both asserting `dispatch.sends === 1` beside `retryAttempts(...).length === 1`, so any future `+ 1` claim has a red test under it. `core.api.md` unchanged: TSDoc prose only, api-extractor reports no signature change. Round 2 of #72. Refs #72, #67.
Wahbeh-Mohammad
force-pushed
the
audit/67/72-retry-typed-error
branch
from
September 5, 2026 07:48
68f904c to
4578841
Compare
This was referenced Sep 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #72. Part of the audit remediation umbrella #67, milestone 3, wave 3. Decision D10 of
docs/audit-67-decisions.md.What changed
retryStepanddispatchWithRetrynow surface the final attempt's own error, class andidentity untouched.
withTrailbecameattachTrail(
packages/core/src/retry/engine.ts:269-279): it returns the outcome unchanged and records theprior attempts in a side table instead of wrapping the error in
suppress(outcome.error, folded, 'retry attempts exhausted').The wrapper made the surfaced class a function of how many attempts ran — one attempt gave
TransportFailureError, three gave aSuppressedErrorholding it at.error. The sharp edge isthe cancellation row:
abortToSdkErrormaps a backoff abort toCancellationErroratengine.ts:394, and the wrapper undid that mapping on the very next line, soinstanceof CancellationErrorwas false for every reachable backoff cancellation — a cancelledwait always has a non-empty trail. That is exactly what XCUT-1's conformance clause ("assert the
surfaced error is the cancellation type") is for.
This is a correction rather than a deviation. RETRY-34 says the prior failures are "attached to the
surfaced exception as suppressed", which is the JVM's
addSuppressed: the exception stays what itis and grows a list. It never asked for the exception to be replaced by a container. No
docs/deviations.mdrow (D10).The trail
New
@publicexport from@dexpace/core:Oldest first, the surfaced instance excluded (RETRY-34's skip-self clause), frozen,
[]foranything that never went through a retry loop. Backed by a module-private
WeakMapin the newpackages/core/src/retry/attempt-trail.ts, written once per terminal failure.A side table rather than an own property on the error, because the engine did not construct the
throwable it is surfacing: it may be frozen or non-extensible (so
definePropertyin the failurepath would itself throw and replace the failure the caller cares about), it may be a primitive
(which carries nothing at all — those pass through unannotated, with no trail entry), and
.suppressedalready means "the one secondary" onSuppressedErrorLike. AWeakMaprather than aMapso the entry dies with the error instead of pinning every failed request's error graph.One behaviour beyond the letter of D10, flagged: an empty trail deletes any entry a previous run
left. Error singletons are ordinary in fakes and in transports that reuse one instance, and
RETRY-34's "on eventual success the prior trail MUST be discarded" buys nothing if the next run to
surface that same instance still reports the old one.
suppress()keeps its RECOV-12 job —withReleaseFailurestill pairs a release failure with theprimary it must not mask, in this file and in six other subsystems.
Test rows added
Every row below was confirmed red against the parent commit's engine and green against this one
(measured by checking out
HEAD~1'sengine.ts, rebuilding, and running the suites).packages/core/src/retry/attempt-trail.test.ts(new, 13 cases)causewalk), primitives, frozen errors, thrown functions, trail copied, empty clears, latest winspackages/core/src/retry/engine.test.tspackages/core/src/retry/engine.test.tsinstanceof TransportFailureErrorformaxAttempts1 and 3, with the dispatch count proving the budgets differedpackages/core/src/retry/engine.test.tsmaxAttempts: 5→instanceof CancellationError, trail[first]packages/core/src/retry/engine.test.ts[]; three attempts list flat, oldest first, not a nested pair chaintests/conformance/xcut/retry-safety.conformance.test.tsTransportFailureErroratmaxAttempts1 and 3tests/conformance/xcut/retry-safety.conformance.test.tsretryAttempts(), self excludedtests/conformance/xcut/cancellation-and-timeout.conformance.test.tsexpect(surfaced).toBeInstanceOf(CancellationError)at the top level — the existing row asserted only on a chain walk, and its own comment said why: the top level was the wrapper. A caller writingcatchhas no walktests/conformance/xcut/cancellation-and-timeout.conformance.test.tsHttpStatusError(RECOV-16)tests/node-conformance/retry.test.mjsinstanceof CancellationErrorover a realAbortSignaland a real timerchainOf's doc comment in the cancellation suite is rewritten rather than deleted: the walk stillearns its place proving the raw abort survived as
causeand that noTimeoutErrorhides one hopdown (XCUT-3), but it is no longer the only assertion.
Docs and API surface
packages/core/etc/core.api.mdregenerated (bun run api:localinpackages/core) — one addedline,
retryAttempts.retryStep's TSDoc,retry-dispatch.ts's@throwsprose, andRetryDiscardedResponseError's "suppressed trail" wording.docs/sdk-documentation/pipelines.md— a new first retry bullet with a worked example thatcheck-fences.mjstypechecks against the built package.docs/sdk-documentation/errors.md—:188's "suppressed trail" wording, plus a statement in theCancellationError/TransportFailureErrorsection that installing a retry pillar does not changeeither
instanceofcheck.docs/sdk-documentation/write-a-response-handler.md— RECOV-12's release pairing is nowdocumented as the only reason this SDK builds a
SuppressedError. Verified against everysuppress()andwithReleaseFailure()call site inpackages/core/src.Deviations from the task file
One file edited outside the stated partition:
tests/node-conformance/retry.test.mjs. Itsheader names RETRY-34's
suppress()branch as runtime-divergent point 2, and its case assertedoutcome.error.name === 'SuppressedError'. That case is red the moment D10 lands, sotest:nodeand the preflight cannot be green without it. No wave-3 sibling touches the Node tree (D2 gives #74
auth/plustransport-conformance, #75rx/), so the conflict risk the partition exists toprevent is nil here. Reported rather than silently absorbed.
Found stale, NOT fixed (outside the partition)
docs/deviations.md:144-146, item 3, carries threefile:linecitations this PR shifts:packages/core/src/retry/engine.ts:358(runWithRetry):367packages/core/src/retry/retry-step.ts:142:150packages/core/src/retry/retry-dispatch.ts:53:55docs/deviations.mdis explicitly not this task's, and it is the wave-3 merge seam #74 and #75append to. Item 3 sits mid-file, so a fix here would add a second, different conflict surface.
Flagging for the supervisor. Nothing gates these citations —
probe.mjsreports "no drift found"and no CI step reads them.
docs/audit-67-decisions.md:157likewise citesretry/engine.ts:385andwithTrailat:386;that is the supervisor-owned ledger and is now historical rather than current.
Deferred — release machinery
Suspended for this run under D1. Recoverable:
@dexpace/core. Two consumer-visible changes: (a) a new@publicexport,retryAttempts(error: unknown): readonly unknown[]; (b) what a retrying pipeline throws once itgives up changes class —
TransportFailureError/CancellationError/HttpStatusErrorwhere aSuppressedErrorused to be. Breaking for anyone reading.erroroff the caught throwable,which the shipped docs told them to do for the retry trail;
retryAttempts()is the replacement.Minor rather than major only because the package is pre-1.0.
.d.tsprose changed onretryStepandRetryDiscardedResponseError.docs/first-release.mduntouched, though "what the retry pillar throws" is squarely in its"free before the first version bump" class.
Gate
node .claude/skills/ci-preflight/run-ci.mjs --clean, once, at the end, no--node-floor(concurrent agents). Bun pinned to 1.3.14 from
.bun-version.test:noderan on Node v26.2.0. CI also runs the20.3.0floor leg, which is the one that takessuppress()'s fallback branch — the branch this PR removes from the retry path.