pnpm/pnpm · error · FetchError

ERR_PNPM_FETCH_${response.status}

ERR_PNPM_FETCH_${response.status}

Error message

GET ${redactUrlCredentials(request.url)}: ${response.statusText} - ${response.status}

What it means

A remote tarball request returned a non-200 status; the response is wrapped in a FetchError whose code embeds the HTTP status (ERR_PNPM_FETCH_401, ERR_PNPM_FETCH_404, ERR_PNPM_FETCH_429, ...) and whose message shows the credential-redacted URL, statusText, and status. The downloader retries transient failures twice (factor-10 backoff, 10s min / 60s max) but fails fast on 401/403/404 — those are deterministic.

Source

Thrown at pnpm11/fetching/tarball-fetcher/src/remoteTarballFetcher.ts:138

      let data: Buffer
      try {
        const res = await fetchFromRegistry(url, {
          authHeaderValue,
          // Tarballs are already compressed; ask the server not to apply an additional
          // Content-Encoding so Content-Length matches the body we receive and we don't
          // waste CPU on round-trip re-compression. See https://github.com/pnpm/pnpm/issues/11506
          headers: { 'accept-encoding': 'identity' },
          // The fetch library can retry requests on bad HTTP responses.
          // However, it is not enough to retry on bad HTTP responses only.
          // Requests should also be retried when the tarball's integrity check fails.
          // Hence, we tell fetch to not retry,
          // and we perform the retries from this function instead.
          retry: { retries: 0 },
          timeout: gotOpts.timeout,
        })

        if (res.status !== 200) {
          throw new FetchError({ url, authHeaderValue }, res)
        }

        // When Content-Encoding is present, Content-Length refers to the encoded form
        // of the data, not the decoded bytes that the fetch implementation yields.
        // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Encoding
        const isEncoded = isContentEncoded(res.headers.get('content-encoding'))
        const contentLength = !isEncoded && res.headers.has('content-length') && res.headers.get('content-length')
        const parsedLength = typeof contentLength === 'string' ? parseInt(contentLength, 10) : NaN
        const size = Number.isFinite(parsedLength) && parsedLength >= 0 ? parsedLength : null
        if (opts.onStart != null) {
          opts.onStart(size, currentAttempt)
        }
        // In order to reduce the amount of logs, we only report the download progress of big tarballs
        const onProgress = (size != null && size >= BIG_TARBALL_SIZE && opts.onProgress)
          ? throttle(opts.onProgress, 500)
          : undefined
        const startTime = Date.now()
        let downloaded = 0

View on GitHub (pinned to 6261b7f388)

Solutions

  1. Decode the embedded status: 401/403 means fix registry auth (set the token in .npmrc for that registry host)
  2. 404 means verify the package/version exists (npm view pkg versions) and that the registry URL in .npmrc is correct
  3. 429/5xx are transient and already retried — check the registry status page or your proxy, then retry after backoff
  4. If a corporate proxy is in play, bypass it for the registry host or fix its TLS interception

Example fix

# before
$ pnpm install @corp/private-pkg
# ERR_PNPM_FETCH_404 / ERR_PNPM_FETCH_401

# after: point auth at the right registry
$ cat .npmrc
@corp:registry=https://registry.corp.com/
//registry.corp.com/:_authToken=${NPM_TOKEN}
$ pnpm install @corp/private-pkg
Defensive patterns

Strategy: retry

Validate before calling

// Probe auth and existence before a full install of a private package
import { execa } from 'execa'

async function assertTarballFetchable (registry: string, pkgName: string, authHeader?: string): Promise<void> {
  const res = await fetch(`${registry}/${encodeURIComponent(pkgName).replace('%40', '@')}`, {
    headers: authHeader ? { authorization: authHeader } : {},
  })
  if (res.status === 401 || res.status === 403) throw new Error('registry auth missing/invalid — set the token in .npmrc')
  if (res.status === 404) throw new Error('package not found on registry — check name/version/registry URL')
}

Try / catch

function parseFetchStatus (code: string): number | null {
  const m = /^ERR_PNPM_FETCH_(\d+)$/.exec(code)
  return m ? parseInt(m[1], 10) : null
}

try {
  await downloader(url, opts)
} catch (err) {
  const status = parseFetchStatus((err as any).code ?? '')
  if (status != null && (status === 429 || status >= 500)) {
    return retryWithBackoff(() => downloader(url, opts), { attempts: 5, factor: 2 })
  }
  if (status === 401 || status === 403) throw new Error('fix registry credentials', { cause: err })
  throw err // 404 and friends are deterministic
}

Prevention

When it happens

Trigger: GET of a tarball URL returns non-200: unpublished package or wrong version (404), missing/invalid registry token (401/403), rate limiting (429), registry or proxy outage (5xx), corporate proxy interception.

Common situations: Missing .npmrc auth token for private registries; package unpublished or version typo; publish-propagation delay (404 right after publish); npm rate limits in CI; proxy returning 502/503.

Related errors


AI-assisted analysis of pnpm/pnpm@6261b7f388 (2026-08-17). Data as JSON: /api/errors/3f961bb0bbdd7bed. Report an issue: GitHub.