Pular para o conteúdo
upgbp

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.

9 min de leitura

O erro tratado aqui

504: GATEWAY_TIMEOUT Code: FUNCTION_INVOCATION_TIMEOUT

Ambiente testado

  • Next.js 15.3
  • Vercel
  • Prisma 6.5
  • Postgres 15
Neste artigo (9)

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

resposta da requisiçãoexit 1
504: GATEWAY_TIMEOUT
Code: FUNCTION_INVOCATION_TIMEOUT
ID: gru1::7xk2p-1774281600000-9c4a1b2d3e5f

E, do lado do servidor, nada de útil:

vercel logs --prodexit 1
Task timed out after 60.02 seconds

Sem 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:

src/app/api/importar/route.ts
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.

Acima, uma única invocação processando 5.400 linhas até bater no limite de 60 segundos. Abaixo, a rota devolvendo 202 na hora, o trabalho virando registro com cursor e invocações sucessivas de 200 linhas, com um cron recolhendo o que travou.
O tamanho do arquivo deixa de influenciar o tempo de resposta. O cursor é o que permite parar e continuar.

Onde o tempo estava indo

Antes de reescrever, vale medir — às vezes o trabalho não é grande, é desperdiçado. Um log por etapa mostrou:

vercel logs --prod
parse do CSV ............   340 ms
validação ...............   180 ms
consulta de preço .......  52140 ms   ← 5400 chamadas sequenciais
gravação ................  4210 ms

Praticamente 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

  1. Transformar a importação num registro no banco.

    O trabalho vira uma linha com posição. É ela que permite parar e continuar.

    prisma/schema.prisma
    model 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])
    }
  2. 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.ts
    export 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 });
    }
  3. 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.ts
    export 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 upsert por SKU, e não um create.

  4. 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.ts
    export 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 tentativas existe para um job defeituoso não se reagendar para sempre e consumir orçamento silenciosamente.

  5. Consertar a lentidão que causou tudo.

    Chamadas sequenciais viram concorrentes, com teto:

    src/lib/importar/processar-lote.ts
    async 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);
  6. Mostrar progresso ao usuário.

    O 202 precisa de um destino. Uma rota de leitura do job basta:

    src/app/api/importar/[jobId]/route.ts
    export 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