nodejs/node · error · RequestRetryError

Request failed

Error message

Request failed

What it means

Thrown by RetryHandler.onResponseStart in the else branch: a new response is starting, the handler already knows an end boundary (this.end != null, set from a prior 206/Content-Length) but headers have not been sent yet, and the code path fell through to the else. This indicates an internal state inconsistency in the retry/resume flow rather than a normal user error; the handler surfaces it as a RequestRetryError carrying the status code so the caller sees the failure instead of a silent stall.

Source

Thrown at deps/undici/src/lib/handler/retry-handler.js:371

      // for instance not safe to assume if the response is byte-per-byte
      // equal
      if (
        this.etag != null &&
        this.etag[0] === 'W' &&
        this.etag[1] === '/'
      ) {
        this.etag = null
      }

      this.headersSent = true
      this.handler.onResponseStart?.(
        this.controllerProxy,
        statusCode,
        headers,
        statusMessage
      )
    } else {
      throw new RequestRetryError('Request failed', statusCode, {
        headers,
        data: { count: this.retryCount }
      })
    }
  }

  onResponseData (_controller, chunk) {
    if (this.error) {
      return
    }

    this.start += chunk.length

    this.handler.onResponseData?.(this.controllerProxy, chunk)
  }

  onResponseEnd (_controller, trailers) {
    if (this.error && this.retryOpts.throwOnError) {

View on GitHub (pinned to 1b2de5e052)

Solutions

  1. Capture statusCode and headers from the error's data and verify the server returns consistent statuses across retries.
  2. Reduce retry aggressiveness for the affected route (lower maxRetries, narrow statusCodes/errorCodes).
  3. If reproducible, report it with the response sequence (status codes + headers) so the retry contract can be reconciled with the origin.
  4. For endpoints that cannot be safely resumed, fetch atomically without the retry interceptor.

Example fix

// before
client = client.compose(interceptors.retry({ maxRetries: 5 }))

// after (constrain retry to idempotent, range-honoring responses)
client = client.compose(interceptors.retry({
  maxRetries: 2,
  statusCodes: [500, 502, 503, 504],
  errorCodes: ['ECONNRESET', 'EPIPE']
}))
Defensive patterns

Strategy: try-catch

Try / catch

try { await retryClient.request(opts) } catch (e) { if (e.code === 'UND_ERR_REQ_RETRY' && e.message === 'Request failed') { const status = e.data?.statusCode ?? e.statusCode; /* narrow retry policy or fetch atomically */ await baseClient.request(opts) } else throw e }

Prevention

When it happens

Trigger: A retry attempt produces a fresh 2xx response after an end boundary was already computed from a previous attempt, in a combination of statuses the handler did not expect (e.g. an initial 206 set this.end, then a retry returned a 200 without going through the resume branch).

Common situations: Servers whose retry behavior is non-deterministic across attempts (206 then 200, or vice versa); intercepted/proxied responses that alter status codes; edge cases in CDN failover. Usually surfaces as 'Request failed' with the offending status code.

Related errors


AI-assisted analysis of nodejs/node@1b2de5e052 (2026-08-13). Data as JSON: /api/errors/b6fee12420c44bf2. Report an issue: GitHub.