nodejs/node · error · RequestRetryError

ETag mismatch

Error message

ETag mismatch

What it means

Thrown by RetryHandler.onResponseStart during a retry resume when the original response had a strong ETag recorded but the resumed response carries a different ETag. The retry handler checkpoints the body by byte offset and uses If-Match to guarantee the same resource; an ETag change means the resource changed under us, so resuming would corrupt the downloaded file. This is a safety guard, not a bug.

Source

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

          headers,
          data: { count: this.retryCount }
        })
      }

      const contentRange = parseRangeHeader(headers['content-range'])
      // If no content range
      if (!contentRange) {
        // We always throw here as we want to indicate that we entred unexpected path
        throw new RequestRetryError('Content-Range mismatch', statusCode, {
          headers,
          data: { count: this.retryCount }
        })
      }

      // Let's start with a weak etag check
      if (this.etag != null && this.etag !== headers.etag) {
        // We always throw here as we want to indicate that we entred unexpected path
        throw new RequestRetryError('ETag mismatch', statusCode, {
          headers,
          data: { count: this.retryCount }
        })
      }

      validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount)

      const { start, size, end = size ? size - 1 : null } = contentRange

      assert(this.start === start, 'content-range mismatch')
      assert(this.end == null || this.end === end, 'content-range mismatch')

      return
    }

    if (this.end == null) {
      if (statusCode === 206) {
        // First time we receive 206

View on GitHub (pinned to 1b2de5e052)

Solutions

  1. Restart the download from byte 0 when the resource is mutable (do not resume).
  2. Pin the version via a strong ETag/If-Match that the server honors, or via an immutable content-addressed URL.
  3. Disable transparent retry-resume for endpoints whose content can change between requests.

Example fix

// before (resume across a version change)
client = client.compose(interceptors.retry())
await client.request({ path: '/mutable-file' })

// after
// fetch atomically; on ETag mismatch restart from the beginning
async function stableDownload(path) {
  let etag, buf = []
  while (true) {
    const res = await agent.request({ path, headers: etag ? { 'if-match': etag } : {} })
    if (res.statusCode === 412) { buf = []; continue }
    etag = res.headers.etag
    buf.push(await res.body.arrayBuffer())
    return Buffer.concat(buf.map(Buffer.from))
  }
}
Defensive patterns

Strategy: retry

Try / catch

try { await retryClient.request({ path }) } catch (e) { if (e.code === 'UND_ERR_REQ_RETRY' && /ETag mismatch/.test(e.message)) { /* resource changed: restart from 0 */ await baseClient.request({ path }) } else throw e }

Prevention

When it happens

Trigger: A ranged retry resume request where the origin object was overwritten between the original and the resume response, producing a new ETag; the If-Match check is bypassed or the server ignores If-Match and returns the new representation anyway.

Common situations: Mutable objects in object storage overwritten mid-download; CDN cache misses returning a newer version; deployments happening during a long download; servers that do not honor If-Match.

Related errors


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