Pular para o conteúdo
upgbp

fetch failed com ECONNRESET no Node: undici, keep-alive e timeouts

A mensagem do fetch nativo não diz nada sozinha. O erro real está em err.cause, e a causa quase sempre é uma conexão reaproveitada tarde demais.

8 min de leitura

O erro tratado aqui

TypeError: fetch failed { cause: Error: read ECONNRESET (code: ECONNRESET, syscall: read) }

Ambiente testado

  • Node 22.14
  • undici 7
  • Next.js 15.3
Neste artigo (10)

Contexto: o que estava rodando

Uma rota do Next.js que consulta um serviço interno de catálogo. Chamada servidor-para-servidor, mesma nuvem, latência de poucos milissegundos.

Funcionava. Só que uma vez a cada duzentas ou trezentas requisições, sem padrão de horário e sem relação com carga, a chamada quebrava.

O erro

vercel logs --prodexit 1
TypeError: fetch failed
    at node:internal/deps/undici/undici:13510:13
    at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
    at async buscarCatalogo (/var/task/.next/server/app/api/catalogo/route.js:1:842)

fetch failed e mais nada. Sem status, sem host, sem motivo. Foi preciso imprimir o erro inteiro para descobrir o que estava acontecendo:

console.error(err) completoexit 1
TypeError: fetch failed
  cause: Error: read ECONNRESET
      at TCP.onStreamRead (node:internal/stream_base_commons:216:20)
    errno: -104,
    code: 'ECONNRESET',
    syscall: 'read'

Diagnóstico

O erro de verdade mora em cause

O fetch global do Node é o undici, e ele embrulha qualquer falha de transporte num TypeError: fetch failed. A mensagem é sempre a mesma, seja DNS que não resolveu, TLS que falhou, socket que caiu ou timeout interno.

Logar só err.message apaga toda a informação útil. Este ajudante resolveu isso em primeiro lugar:

src/lib/erros.ts
export function descreverErroFetch(err: unknown): string {
  if (!(err instanceof Error)) return String(err);

  const partes = [err.message];
  let atual: unknown = err.cause;

  // A cadeia de causas pode ter mais de um nível — undici aninha.
  while (atual instanceof Error) {
    const codigo = (atual as NodeJS.ErrnoException).code;
    partes.push(codigo ? `${codigo}: ${atual.message}` : atual.message);
    atual = atual.cause;
  }

  return partes.join(' ← ');
}

Com ele, fetch failed virou fetch failed ← ECONNRESET: read ECONNRESET. Só aí o diagnóstico começa.

A corrida do socket ocioso

O undici mantém conexões abertas para reaproveitar — é o keep-alive, e é o que torna chamadas repetidas rápidas. O problema está no fim de vida dessas conexões.

O servidor do outro lado — ou o balanceador na frente dele — fecha conexões ociosas depois de um tempo. Se o cliente pegar da sua reserva um socket no exato instante em que o outro lado mandou o FIN, ele escreve numa conexão que já morreu. O sistema operacional responde ECONNRESET.

t=0s     requisição A → abre conexão, responde, socket volta para a reserva
t=60s    balanceador fecha o socket ocioso (política dele)
t=60,01s requisição B → pega o mesmo socket da reserva → ECONNRESET

Isso explica todas as características do problema: intermitente, sem relação com carga, mais frequente em tráfego irregular — porque tráfego irregular é justamente o que deixa sockets envelhecendo na reserva.

Os outros erros que aparecem embrulhados

Vale reconhecer os vizinhos, porque a mensagem externa é idêntica:

cause.code O que aconteceu Onde mexer
ECONNRESET Conexão reaproveitada depois de fechada do outro lado keepAliveTimeout
UND_ERR_SOCKET Socket caiu no meio da resposta Repetir; verificar rede
UND_ERR_HEADERS_TIMEOUT Servidor demorou a mandar os cabeçalhos headersTimeout
UND_ERR_BODY_TIMEOUT Corpo parou de chegar no meio bodyTimeout
UND_ERR_CONNECT_TIMEOUT Não conseguiu abrir a conexão Rede, DNS, firewall
ENOTFOUND DNS não resolveu Host errado ou DNS interno
ECONNREFUSED Ninguém escutando na porta Serviço fora do ar

O fetch não tem timeout

Detalhe que só aparece no pior dia: sem signal, uma chamada pode ficar pendurada até o timeout de headers do undici, que é longo. Numa função serverless, isso significa consumir todo o tempo de execução esperando um servidor que não vai responder.

A solução

  1. Configurar o agente com ociosidade abaixo da do servidor.

    src/lib/http.ts
    import { Agent, setGlobalDispatcher } from 'undici';
    
    setGlobalDispatcher(
      new Agent({
        // Balanceadores costumam fechar conexão ociosa em 60 s. Ficando em 10 s,
        // quem descarta é sempre este lado — e a corrida deixa de existir.
        keepAliveTimeout: 10_000,
    
        // Teto absoluto, mesmo que o servidor peça mais no cabeçalho Keep-Alive.
        keepAliveMaxTimeout: 30_000,
    
        // Margem de segurança subtraída do valor negociado.
        keepAliveTimeoutThreshold: 1_000,
    
        // Sem estes dois, uma resposta travada segura a requisição indefinidamente.
        headersTimeout: 10_000,
        bodyTimeout: 30_000,
    
        connections: 64,
      }),
    );

    Importe este arquivo uma vez, no ponto de entrada — em instrumentation.ts, no caso do Next.js — para o dispatcher valer em todo o processo.

  2. Colocar prazo em toda chamada.

    src/lib/catalogo.ts
    export async function buscarCatalogo(id: string) {
      const res = await fetch(`${process.env.CATALOGO_URL}/itens/${id}`, {
        // Corta a chamada inteira, incluindo abertura de conexão e leitura.
        signal: AbortSignal.timeout(5_000),
        headers: { accept: 'application/json' },
      });
    
      if (!res.ok) {
        // Sem consumir o corpo, o socket fica preso até o coletor de lixo passar.
        await res.body?.cancel();
        throw new Error(`catálogo respondeu ${res.status}`);
      }
    
      return res.json();
    }
  3. Repetir só o que é seguro repetir.

    ECONNRESET numa leitura é o caso ideal de retry: a requisição comprovadamente não foi processada, porque nem chegou.

    src/lib/http.ts
    const CODIGOS_TRANSITORIOS = new Set([
      'ECONNRESET',
      'UND_ERR_SOCKET',
      'UND_ERR_CONNECT_TIMEOUT',
    ]);
    
    function codigoDe(err: unknown): string | undefined {
      let atual: unknown = err;
      while (atual instanceof Error) {
        const codigo = (atual as NodeJS.ErrnoException).code;
        if (codigo) return codigo;
        atual = atual.cause;
      }
    }
    
    export async function getComRetry(url: string, tentativas = 3) {
      for (let i = 0; i < tentativas; i++) {
        try {
          return await fetch(url, { signal: AbortSignal.timeout(5_000) });
        } catch (err) {
          const codigo = codigoDe(err);
          if (!codigo || !CODIGOS_TRANSITORIOS.has(codigo) || i === tentativas - 1) {
            throw err;
          }
          // Espera curta com jitter: socket morto se resolve rápido.
          await new Promise((r) => setTimeout(r, Math.random() * 100 * 2 ** i));
        }
      }
      throw new Error('inalcançável');
    }

    Para POST que cria recurso, o retry precisa vir com chave de idempotência — caso contrário você troca um erro por uma duplicata.

  4. Registrar o código, não a mensagem.

    src/lib/catalogo.ts
    catch (err) {
      console.error('catálogo falhou', {
        codigo: codigoDe(err),
        detalhe: descreverErroFetch(err),
        url: `${process.env.CATALOGO_URL}/itens/${id}`,
      });
      throw err;
    }

    Sem isso, o painel de erros mostra centenas de fetch failed idênticos e nenhuma pista de qual é qual.

Como confirmar que resolveu

Reproduza a corrida de propósito. Um servidor que fecha conexão ociosa em dois segundos torna o problema constante em vez de raro:

scripts/servidor-instavel.mjs
import { createServer } from 'node:http';

const servidor = createServer((_, res) => res.end(JSON.stringify({ ok: true })));
// Fecha rápido: com keepAliveTimeout do cliente maior que isto, o ECONNRESET
// aparece em quase toda requisição depois da primeira pausa.
servidor.keepAliveTimeout = 2_000;
servidor.listen(9100);
node scripts/servidor-instavel.mjs &
# Uma chamada, espera de 3 s, outra chamada: a segunda pega o socket morto.
node -e "
  const bater = () => fetch('http://localhost:9100').then(r => r.json());
  bater().then(() => new Promise(r => setTimeout(r, 3000))).then(bater)
    .then(console.log).catch(e => console.error(e.cause?.code ?? e));
"

Antes do ajuste, isso imprime ECONNRESET. Com keepAliveTimeout abaixo dos 2 s do servidor, imprime { ok: true }.

Confirme o timeout. Aponte para um endereço que aceita conexão e nunca responde; a chamada tem que morrer no prazo configurado, não no padrão do undici.

Acompanhe a taxa depois do deploy. O erro era intermitente, então “não vi mais” não é evidência. Conte ocorrências por hora no painel de logs, antes e depois.

Armadilhas que sobram depois disso

Em serverless, a reserva de conexões dura pouco. Instâncias são recicladas, e o keep-alive vale só enquanto a instância viver. Isso reduz a frequência do problema, mas não o elimina — e faz o diagnóstico demorar mais, porque o erro fica ainda mais esparso.

setGlobalDispatcher afeta todo o processo. Bibliotecas de terceiros que usam fetch passam a herdar essas configurações. Em geral é o que você quer; quando não for, crie um Agent dedicado e passe em dispatcher na chamada.

Corpo não lido vaza conexão. Toda resposta que você descarta precisa de res.body?.cancel(). Sem isso, a conexão fica presa até o coletor de lixo passar, e a reserva se esgota sob carga.

AbortSignal.timeout conta desde a criação. Se você criar o signal e só usar depois — dentro de uma fila, por exemplo — o prazo já estará correndo. Crie no momento da chamada.

Node antigo se comporta diferente. O undici mudou padrões de timeout entre versões maiores. Se o mesmo código se comporta de forma diferente em duas máquinas, compare o node -v antes de procurar em qualquer outro lugar.

Continue por aqui