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.
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
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
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:
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:
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
-
Configurar o agente com ociosidade abaixo da do servidor.
src/lib/http.tsimport { 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. -
Colocar prazo em toda chamada.
src/lib/catalogo.tsexport 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(); } -
Repetir só o que é seguro repetir.
ECONNRESETnuma leitura é o caso ideal de retry: a requisição comprovadamente não foi processada, porque nem chegou.src/lib/http.tsconst 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
POSTque cria recurso, o retry precisa vir com chave de idempotência — caso contrário você troca um erro por uma duplicata. -
Registrar o código, não a mensagem.
src/lib/catalogo.tscatch (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 failedidê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:
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
APIs & Resiliência
Retry com backoff, jitter e circuit breaker em TypeScript
Repetir na hora piora a queda que você está tentando contornar. As quatro regras que separam um retry útil de um ataque contra o seu próprio fornecedor.
APIs & Resiliência
Cortando o custo de API de LLM: medir, rotear e cachear
A fatura triplicou sem o uso triplicar. Como atribuir custo por funcionalidade e quais alavancas realmente rendem, na ordem de retorno.
APIs & Resiliência
Fila no Postgres com SKIP LOCKED, ou serviço de fila: qual escolher
Dois workers pegando a mesma tarefa é o defeito clássico de fila caseira. A cláusula que resolve, e quando vale trocar o banco por um serviço.