Part of the audit remediation umbrella #67. Milestone 4. Severity: MEDIUM. Requirements: RETRY-2, RETRY-11, RETRY-40, §10 item 17.
Current SDK behavior
- The cause-walk misses four I/O leaves.
classify.ts:73 narrows with
current instanceof IoError. After the 2026-09-04 tier flattening, EndOfStreamError,
SourceContractViolationError, ClosedResourceError and AllocationLimitError
(io/errors.ts:29,51,67,83) extend DexpaceError directly and are grouped by isIoError.
Only IoError itself and TransportFailureError match. A truncated response body
(EndOfStreamError) is not retryable; a no-response transport failure is.
docs/deviations.md item 17 gives "the cause-walk returns retryable for any IoError" as the
reason TransportFailureError must extend IoError.
- A non-finite
delayOverride is fatal and masks the real failure.
resolveDelay (retry/engine.ts:127-138) does not validate the override result.
overshootsBudget(NaN) is false, waitFor's delayMs <= 0 guard (:294) is false,
Clock.sleep rejects with RangeError, and :416-422 folds it into the terminal failure.
delayOverride: () => NaN with maxAttempts: 3 gives one send and surfaces
RangeError: Clock.sleep: durationMs must be a non-negative finite number, got NaN, with the
real TransportFailureError in .suppressed. A throwing override correctly gives three
sends (RETRY-40).
- Backoff is
NaN when initialDelayMs is 0 and the power overflows.
backoff.ts:74: 0 * Infinity. retrySettings({initialDelayMs: 0, multiplier: 1e200, maxAttempts: 4})
gives computeDelay(3) === NaN; so does {initialDelayMs: 0, multiplier: 2, maxAttempts: 2000}
at attempt 1100. The comment at :49-50 assumes initialDelayMs > 0.
Expected behavior
The retry classifier has one stated rule for I/O failures, and each of the five I/O error
classes has a test that pins whether it is retryable. A delayOverride that returns a
non-finite value is treated like one that throws: the computed backoff is used and the
attempt loop continues. computeDelay returns a finite non-negative number for every
accepted setting and attempt count.
Notes and leads
instanceof IoError is a decision. (a) Switch to isIoError(current) and decide per leaf;
AllocationLimitError and ClosedResourceError should probably not retry; an
EndOfStreamError raised while writing a request body means bytes went out, so the engine's
replayability gate must hold. (b) Keep instanceof IoError as "only transport-layer failures
retry" and rewrite item 17 and io/index.ts to say so. Either way, five classify tests.
resolveDelay: Number.isFinite(override) && override >= 0 ? override : computed.
computeDelay: short-circuit initialDelayMs === 0 to 0, or Number.isFinite guard
before Math.min.
- Subtask 5 (typed error surfaced) touches
engine.ts too; land that first to avoid a
conflict in withTrail.
- Patch changeset for
@dexpace/core. Update docs/deviations.md item 17.
Part of the audit remediation umbrella #67. Milestone 4. Severity: MEDIUM. Requirements: RETRY-2, RETRY-11, RETRY-40, §10 item 17.
Current SDK behavior
classify.ts:73narrows withcurrent instanceof IoError. After the 2026-09-04 tier flattening,EndOfStreamError,SourceContractViolationError,ClosedResourceErrorandAllocationLimitError(
io/errors.ts:29,51,67,83) extendDexpaceErrordirectly and are grouped byisIoError.Only
IoErroritself andTransportFailureErrormatch. A truncated response body(
EndOfStreamError) is not retryable; a no-response transport failure is.docs/deviations.mditem 17 gives "the cause-walk returns retryable for anyIoError" as thereason
TransportFailureErrormust extendIoError.delayOverrideis fatal and masks the real failure.resolveDelay(retry/engine.ts:127-138) does not validate the override result.overshootsBudget(NaN)is false,waitFor'sdelayMs <= 0guard (:294) is false,Clock.sleeprejects withRangeError, and:416-422folds it into the terminal failure.delayOverride: () => NaNwithmaxAttempts: 3gives one send and surfacesRangeError: Clock.sleep: durationMs must be a non-negative finite number, got NaN, with thereal
TransportFailureErrorin.suppressed. A throwing override correctly gives threesends (RETRY-40).
NaNwheninitialDelayMsis 0 and the power overflows.backoff.ts:74:0 * Infinity.retrySettings({initialDelayMs: 0, multiplier: 1e200, maxAttempts: 4})gives
computeDelay(3) === NaN; so does{initialDelayMs: 0, multiplier: 2, maxAttempts: 2000}at attempt 1100. The comment at
:49-50assumesinitialDelayMs > 0.Expected behavior
The retry classifier has one stated rule for I/O failures, and each of the five I/O error
classes has a test that pins whether it is retryable. A
delayOverridethat returns anon-finite value is treated like one that throws: the computed backoff is used and the
attempt loop continues.
computeDelayreturns a finite non-negative number for everyaccepted setting and attempt count.
Notes and leads
instanceof IoErroris a decision. (a) Switch toisIoError(current)and decide per leaf;AllocationLimitErrorandClosedResourceErrorshould probably not retry; anEndOfStreamErrorraised while writing a request body means bytes went out, so the engine'sreplayability gate must hold. (b) Keep
instanceof IoErroras "only transport-layer failuresretry" and rewrite item 17 and
io/index.tsto say so. Either way, five classify tests.resolveDelay:Number.isFinite(override) && override >= 0 ? override : computed.computeDelay: short-circuitinitialDelayMs === 0to0, orNumber.isFiniteguardbefore
Math.min.engine.tstoo; land that first to avoid aconflict in
withTrail.@dexpace/core. Updatedocs/deviations.mditem 17.