FUNCTION_INVOCATION_TIMEOUT: quebrando trabalho longo em lotes
Aumentar o maxDuration só empurra a falha para um arquivo maior. A correção é a requisição iniciar o trabalho em vez de executá-lo até o fim.
O erro tratado aqui
504: GATEWAY_TIMEOUT Code: FUNCTION_INVOCATION_TIMEOUTAmbiente testado
- Next.js 15.3
- Vercel
- Prisma 6.5
- Postgres 15
Contexto: o que estava rodando
Uma rota de importação de CSV. O usuário sobe uma planilha de produtos, a rota lê linha a linha, valida, consulta preço numa API externa e grava no banco.
Testado com o arquivo de exemplo de 50 linhas: perfeito. O primeiro cliente real subiu 5.400 linhas.
O erro
504: GATEWAY_TIMEOUT
Code: FUNCTION_INVOCATION_TIMEOUT
ID: gru1::7xk2p-1774281600000-9c4a1b2d3e5fE, do lado do servidor, nada de útil:
Task timed out after 60.02 secondsSem stack trace, sem indicação de qual linha do CSV. A função foi encerrada no meio — e, pior, com parte dos produtos já gravados. Reenviar o arquivo duplicava tudo que tinha passado.
Diagnóstico
O limite é de tempo de parede, não de processamento
Toda função serverless tem um teto de duração. Ele existe porque o modelo cobra por tempo de execução e porque o gateway na frente precisa devolver alguma coisa ao navegador.
O primeiro reflexo é subir o teto:
export const maxDuration = 300;Isso funcionou até o cliente seguinte, com 20 mil linhas. É o problema com essa correção: ela não muda a natureza do código, só o tamanho do arquivo que ainda cabe. Enquanto o tempo de execução crescer junto com a entrada, existe uma entrada que estoura qualquer teto.

Onde o tempo estava indo
Antes de reescrever, vale medir — às vezes o trabalho não é grande, é desperdiçado. Um log por etapa mostrou:
parse do CSV ............ 340 ms
validação ............... 180 ms
consulta de preço ....... 52140 ms ← 5400 chamadas sequenciais
gravação ................ 4210 msPraticamente todo o tempo era rede: uma chamada HTTP por linha, uma depois da outra. Isso ainda precisava ser resolvido, mas nem com concorrência perfeita o trabalho caberia numa requisição indefinidamente.
Estado parcial é o defeito mais caro
O timeout em si é chato. O que realmente machuca é o processo morrer com metade do arquivo gravado e nenhum registro de onde parou. Sem esse registro, não existe retomada — só recomeço, e recomeço duplica.
A solução
-
Transformar a importação num registro no banco.
O trabalho vira uma linha com posição. É ela que permite parar e continuar.
prisma/schema.prismamodel ImportJob { id String @id @default(cuid()) arquivo String status String @default("pendente") // pendente | rodando | concluido | falhou cursor Int @default(0) // quantas linhas já foram processadas total Int tentativas Int @default(0) erro String? criadoEm DateTime @default(now()) atualizadoEm DateTime @updatedAt @@index([status, atualizadoEm]) } -
A rota de upload devolve na hora.
Ela só guarda o arquivo, cria o registro e responde
202 Accepted— o código HTTP que significa exatamente “aceitei, ainda não terminei”.src/app/api/importar/route.tsexport async function POST(req: Request) { const form = await req.formData(); const arquivo = form.get('arquivo') as File; const caminho = await salvarNoStorage(arquivo); const total = await contarLinhas(caminho); const job = await prisma.importJob.create({ data: { arquivo: caminho, total }, }); // Dispara o processamento sem esperar. Se a chamada falhar, o cron da // etapa 4 pega o job assim mesmo — por isso o erro é só registrado. void dispararProcessamento(job.id).catch(console.error); return Response.json({ jobId: job.id, total }, { status: 202 }); } -
O trabalhador processa um lote e devolve o controle.
Cada invocação tem orçamento próprio. O tamanho do lote é escolhido para caber com folga, e a posição é gravada antes de a função terminar.
src/app/api/importar/processar/route.tsexport const maxDuration = 60; const LOTE = 200; export async function POST(req: Request) { if (req.headers.get('authorization') !== `Bearer ${process.env.JOB_SECRET}`) { return new Response('não autorizado', { status: 401 }); } const { jobId } = await req.json(); const job = await prisma.importJob.findUniqueOrThrow({ where: { id: jobId } }); if (job.status === 'concluido') return Response.json({ pronto: true }); const linhas = await lerLinhas(job.arquivo, job.cursor, LOTE); if (linhas.length === 0) { await prisma.importJob.update({ where: { id: jobId }, data: { status: 'concluido' }, }); return Response.json({ pronto: true }); } try { await processarLote(linhas); } catch (err) { await prisma.importJob.update({ where: { id: jobId }, data: { status: 'falhou', erro: String(err) }, }); return Response.json({ erro: true }, { status: 500 }); } const cursor = job.cursor + linhas.length; await prisma.importJob.update({ where: { id: jobId }, // O cursor avança só depois do lote gravado. Uma morte súbita aqui // reprocessa o último lote, nunca pula um. data: { cursor, status: 'rodando', tentativas: { increment: 1 } }, }); // Encadeia a próxima fatia sem esperar por ela. void dispararProcessamento(jobId).catch(console.error); return Response.json({ cursor, total: job.total }); }O reprocessamento do último lote em caso de queda é aceitável desde que a gravação seja idempotente — um
upsertpor SKU, e não umcreate. -
Um cron como rede de segurança.
O encadeamento é rápido, mas depende de uma chamada HTTP que pode falhar. Um cron periódico recolhe o que ficou parado:
vercel.json{ "crons": [{ "path": "/api/importar/retomar", "schedule": "*/5 * * * *" }] }src/app/api/importar/retomar/route.tsexport async function GET() { const parados = await prisma.importJob.findMany({ where: { status: { in: ['pendente', 'rodando'] }, // Sem avanço há mais de dois minutos: a cadeia se rompeu. atualizadoEm: { lt: new Date(Date.now() - 2 * 60_000) }, tentativas: { lt: 500 }, }, take: 10, }); await Promise.all(parados.map((job) => dispararProcessamento(job.id))); return Response.json({ retomados: parados.length }); }O limite de
tentativasexiste para um job defeituoso não se reagendar para sempre e consumir orçamento silenciosamente. -
Consertar a lentidão que causou tudo.
Chamadas sequenciais viram concorrentes, com teto:
src/lib/importar/processar-lote.tsasync function comLimite<T, R>(itens: T[], limite: number, fn: (item: T) => Promise<R>) { const resultados: R[] = []; for (let i = 0; i < itens.length; i += limite) { resultados.push(...(await Promise.all(itens.slice(i, i + limite).map(fn)))); } return resultados; } // 10 em paralelo: rápido o bastante e longe do rate limit da API de preços. const precos = await comLimite(linhas, 10, buscarPreco); -
Mostrar progresso ao usuário.
O
202precisa de um destino. Uma rota de leitura do job basta:src/app/api/importar/[jobId]/route.tsexport async function GET(_: Request, { params }: { params: Promise<{ jobId: string }> }) { const { jobId } = await params; const job = await prisma.importJob.findUniqueOrThrow({ where: { id: jobId } }); return Response.json({ status: job.status, progresso: Math.round((job.cursor / job.total) * 100), erro: job.erro, }); }
Como confirmar que resolveu
Suba o arquivo que quebrava. A resposta deve chegar em menos de um segundo,
com 202 e um jobId. O tamanho do arquivo deixa de influenciar o tempo de
resposta — que é o objetivo real.
Acompanhe o cursor avançando:
select id, status, cursor, total, tentativas, atualizadoem
from "ImportJob"
order by "criadoEm" desc
limit 5;
Mate o processo no meio de propósito. Cancele um deploy durante a importação, ou dispare um redeploy. O cron deve retomar em até cinco minutos, do cursor onde parou. Este é o teste que valida o desenho inteiro — sem ele, você só tem a esperança de que funcione.
Nenhum registro duplicado depois de duas execuções completas:
select sku, count(*) from produtos group by sku having count(*) > 1;
Armadilhas que sobram depois disso
waitUntil não é background job. O waitUntil do runtime permite continuar
trabalho depois de responder, mas dentro do mesmo tempo de vida da invocação. Ele
resolve mandar um e-mail depois do return, não processar 20 mil linhas.
Resposta em streaming trata outro problema. Manter bytes fluindo evita o timeout do gateway, mas não o limite de execução da função, e não sobrevive ao usuário fechar a aba. Serve para relatório que o usuário está olhando, não para importação.
Dois trabalhadores no mesmo job duplicam trabalho. Se o cron disparar
enquanto o encadeamento ainda roda, dois processos avançam o mesmo cursor. A
proteção robusta é select ... for update skip locked na leitura do
job.
Cron da Vercel não roda em preview. Ele só é registrado no deploy de produção. Testar retomada em ambiente de preview exige chamar a rota na mão.
Guarde o arquivo original. Se o storage apagar por política de retenção
antes do job terminar, o lerLinhas falha no meio e não há como retomar.
Continue por aqui
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.
APIs & Resiliência
Cron em serverless: Vercel Cron, pg_cron ou Cloudflare Workers
As três opções resolvem agendamento de formas incompatíveis. O critério de escolha, e os quatro erros que aparecem em qualquer uma delas.
APIs & Resiliência
429 em API de IA: fila com concorrência e orçamento de tokens
Promise.all sobre quinhentos itens não é paralelismo, é um ataque. Como respeitar os dois limites que essas APIs impõem sem serializar tudo.