Pular para o conteúdo
upgbp

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.

9 min de leitura

O erro tratado aqui

Cron job failed: GET /api/cron/faturas returned 401 Unauthorized

Ambiente testado

  • Vercel Cron
  • Supabase pg_cron 1.6
  • Cloudflare Workers
  • Postgres 15
Neste artigo (11)

Contexto: o que estava rodando

Uma rotina que fecha faturas: todo dia, à meia-noite, ela soma os consumos do dia anterior, gera a fatura e dispara o e-mail.

Em servidor tradicional isso é uma linha no crontab. Em serverless não existe servidor para hospedar o crontab, e as alternativas se comportam de maneiras bem diferentes.

O erro

A primeira versão usou Vercel Cron. O painel mostrava a execução acontecendo — e falhando:

vercel · cron jobsexit 1
/api/cron/faturas    0 0 * * *    Last run: 21:00 UTC    Status: 401

Dois problemas numa linha só: o 401, e o horário — a rotina de “meia-noite” estava rodando às 21:00 no horário de Brasília.

Diagnóstico

Uma rota de cron é uma URL pública

Este é o 401, e ele veio de uma correção anterior. Ao perceber que /api/cron/faturas podia ser chamada por qualquer pessoa na internet — gerando faturas duplicadas à vontade — eu tinha protegido a rota. Só que protegi contra o próprio agendador também.

A Vercel envia um cabeçalho de autorização com o valor da variável CRON_SECRET. A verificação precisa aceitar exatamente isso.

Expressão cron é sempre UTC

O 0 0 * * * significa meia-noite UTC, que é 21:00 no horário de Brasília. Uma rotina que fecha “o dia anterior” rodando às 21:00 fecha o dia errado, com três horas de consumo faltando.

Não existe configuração de fuso na expressão. O ajuste é aritmético: para meia- noite em Brasília (UTC−3), a expressão é 0 3 * * *. E ela quebra se o país voltar a adotar horário de verão — um bom motivo para a rotina calcular o intervalo por data explícita, e não por “as últimas 24 horas”.

As três opções não são intercambiáveis

Vercel Cron Supabase pg_cron Cloudflare Workers
Onde roda Função HTTP sua Dentro do Postgres Runtime do Workers
Como agenda vercel.json cron.schedule() em SQL wrangler.toml
Granularidade Depende do plano Por minuto Por minuto
Duração Limite da função Sem limite prático Limite do Worker
Acessa seu código Sim Não (só SQL, ou HTTP via pg_net) Sim, com outro runtime
Funciona se a app cair Não Sim Sim
Repete se falhar Não Não Não
Roda em preview Não Sim Sim

O critério de escolha é o que a tarefa precisa tocar:

Manutenção de dados — apagar registros antigos, recalcular agregados, vacuum, atualizar materialized view — é pg_cron. Está no lugar dos dados, não depende da aplicação estar de pé e não gasta invocação.

Regra de negócio que usa seu código — gerar fatura, mandar e-mail, chamar API externa — é Vercel Cron ou Workers, porque é onde a lógica vive.

Tarefa frequente e curta — a cada minuto, poucos segundos de trabalho — pesa para Workers, que foi desenhado para isso e custa menos por execução.

Ninguém repete por você

Nenhuma das três tem retry automático. Se a execução falhar — deploy no ar, banco indisponível, erro de código — aquela janela simplesmente não aconteceu. A próxima execução precisa perceber isso sozinha.

Duas execuções ao mesmo tempo

Uma rotina que passa a demorar mais que o intervalo eventualmente encontra a anterior ainda rodando. Sem trava, as duas processam as mesmas faturas.

A solução

  1. Autenticar a rota do agendador.

    src/app/api/cron/faturas/route.ts
    export const maxDuration = 300;
    
    export async function GET(req: Request) {
      const esperado = `Bearer ${process.env.CRON_SECRET}`;
      // Comparação simples basta aqui: o segredo tem entropia alta e a rota não
      // devolve nada que ajude a adivinhá-lo.
      if (req.headers.get('authorization') !== esperado) {
        return new Response('não autorizado', { status: 401 });
      }
    
      const processadas = await fecharFaturasDoDia();
      return Response.json({ processadas });
    }
    vercel.json
    {
      "crons": [{ "path": "/api/cron/faturas", "schedule": "0 3 * * *" }]
    }

    O CRON_SECRET precisa existir nas variáveis de ambiente do projeto. Sem ela, process.env.CRON_SECRET é undefined, o valor esperado vira "Bearer undefined" e nada passa.

  2. Trabalhar com data explícita, nunca com “as últimas 24 horas”.

    src/lib/faturas.ts
    /**
     * O dia é calculado no fuso do negócio, não no do servidor. Assim a rotina
     * fecha o mesmo período independentemente da hora em que rodar — inclusive
     * numa reexecução manual às 10h da manhã.
     */
    export function diaAnteriorEmSaoPaulo(agora = new Date()) {
      const formatador = new Intl.DateTimeFormat('en-CA', {
        timeZone: 'America/Sao_Paulo',
        year: 'numeric', month: '2-digit', day: '2-digit',
      });
      const hoje = formatador.format(agora);          // 'AAAA-MM-DD'
      const ontem = new Date(`${hoje}T00:00:00-03:00`);
      ontem.setUTCDate(ontem.getUTCDate() - 1);
      return ontem;
    }
  3. Registrar cada execução e impedir sobreposição.

    Uma tabela com restrição de unicidade resolve as duas coisas de uma vez:

    prisma/schema.prisma
    model ExecucaoCron {
      id         String    @id @default(cuid())
      tarefa     String
      competencia DateTime               // o dia que esta execução fecha
      iniciadoEm DateTime  @default(now())
      concluidoEm DateTime?
      erro       String?
    
      // Uma execução por tarefa por competência. O banco recusa a segunda.
      @@unique([tarefa, competencia])
    }
    src/lib/faturas.ts
    export async function fecharFaturasDoDia() {
      const competencia = diaAnteriorEmSaoPaulo();
    
      const reserva = await prisma.execucaoCron.createMany({
        data: [{ tarefa: 'faturas', competencia }],
        skipDuplicates: true,
      });
    
      if (reserva.count === 0) {
        return { pulado: 'já processado ou em andamento' };
      }
    
      try {
        const processadas = await gerarFaturas(competencia);
        await prisma.execucaoCron.update({
          where: { tarefa_competencia: { tarefa: 'faturas', competencia } },
          data: { concluidoEm: new Date() },
        });
        return { processadas };
      } catch (err) {
        // Libera a competência para a próxima tentativa.
        await prisma.execucaoCron.delete({
          where: { tarefa_competencia: { tarefa: 'faturas', competencia } },
        });
        throw err;
      }
    }
  4. Recuperar janelas perdidas.

    Com o registro de execuções, achar o que faltou é uma consulta. A rotina passa a processar todas as competências pendentes, não só a de ontem:

    src/lib/faturas.ts
    export async function fecharPendentes(limiteDias = 7) {
      const feitas = await prisma.execucaoCron.findMany({
        where: { tarefa: 'faturas', concluidoEm: { not: null } },
        select: { competencia: true },
      });
      const jaFeitas = new Set(feitas.map((f) => f.competencia.toISOString()));
    
      for (let i = 1; i <= limiteDias; i++) {
        const dia = diaAnteriorEmSaoPaulo(new Date(Date.now() - (i - 1) * 86_400_000));
        if (!jaFeitas.has(dia.toISOString())) {
          await fecharFaturasDoDia();
        }
      }
    }

    O limiteDias evita que uma tabela vazia — num ambiente novo — dispare o processamento do histórico inteiro.

  5. Deixar manutenção de dados no banco.

    O que é puramente SQL não precisa passar pela aplicação:

    migracao/01-pg-cron.sql
    create extension if not exists pg_cron;
    
    -- Limpeza diária às 4h UTC (1h em Brasília), fora do horário de pico.
    select cron.schedule(
      'limpar-sessoes-expiradas',
      '0 4 * * *',
      $$ delete from sessoes where expira_em < now() - interval '7 days' $$
    );
    
    -- Agregado horário para o painel não recalcular a cada acesso.
    select cron.schedule(
      'atualizar-resumo-diario',
      '5 * * * *',
      $$ refresh materialized view concurrently resumo_diario $$
    );

    Consultar o que está agendado e como foi:

    select jobid, schedule, jobname, active from cron.job;
    
    select jobid, status, return_message, start_time
    from cron.job_run_details
    order by start_time desc
    limit 20;

Como confirmar que resolveu

Chame a rota como o agendador chamaria:

curl -i -H "authorization: Bearer $CRON_SECRET" \
  https://seu-app.vercel.app/api/cron/faturas

200 com o resumo. Sem o cabeçalho, 401.

Chame duas vezes seguidas. A segunda tem que responder já processado, sem gerar nada. É o teste da trava de sobreposição.

Confira o horário real da execução no painel depois da primeira noite. A coluna de última execução mostra UTC — some ou subtraia o fuso antes de concluir que está errado.

Simule uma janela perdida. Apague a linha de ExecucaoCron de dois dias atrás e rode a recuperação. Ela deve reprocessar exatamente aquele dia, sem tocar nos outros.

Armadilhas que sobram depois disso

Cron da Vercel não roda em preview. Ele só é registrado no deploy de produção. Testar exige chamar a rota manualmente — e é por isso que a autenticação por segredo, e não por origem, é o desenho certo.

Planos gratuitos limitam a frequência. Uma expressão de minuto em minuto pode ser silenciosamente reduzida para uma vez ao dia. Confira o que o painel diz que está agendado, não o que você escreveu.

pg_cron não enxerga o seu código. Ele executa SQL. Para chamar um endpoint, precisa da extensão pg_net, e aí você tem uma chamada HTTP disparada do banco — sem retry, sem log da resposta e difícil de depurar.

Workers usam outro runtime. Bibliotecas que dependem de APIs do Node precisam da flag de compatibilidade, e algumas não funcionam mesmo assim. Reaproveitar o código da aplicação nem sempre é possível.

Rotina longa esbarra no limite da função. Fechar faturas de dez mil clientes não cabe numa invocação. O cron passa a ser o gatilho, e o processamento precisa ser fatiado.

Continue por aqui