Skip to content

Retry engine and error classification: instanceof IoError after the flat tier, non-finite delayOverride, backoff NaN #78

Description

@Wahbeh-Mohammad

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.

Activity

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

Metadata

Metadata

Labels

area:coreCore HTTP, IO, body, context, encoding: HTTP-* IO-* BODY-* CTX-* UTF-*area:resilienceRetry, recovery, redirects: RETRY-* RECOV-* REDIR-*type:bugDefect in shipped behavior

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions