Para respeitar o rate limit de uma API de pagamento a partir de jobs do Laravel, coloque o limite em um middleware de job (RateLimited ou Redis::throttle) com chave por credencial. Troque $tries por retryUntil(), porque cada release conta como tentativa. Trate o 429 lendo o header Retry-After e gravando um cooldown compartilhado no Redis, para que os outros workers parem de bater na API ao mesmo tempo. Por último, isole esses jobs numa fila própria no Horizon, para que eles não atrasem webhooks.

No tráfego normal ninguém lembra que o gateway tem limite de requisições. O problema aparece quando você dispara um reprocessamento, uma conciliação retroativa ou uma sincronização de extrato com milhares de jobs de uma vez. A seguir está o que costumo montar em integrações com Asaas, Pagar.me, Pluggy ou Zoop, com as decisões e os trade-offs de cada peça.

Por que o 429 aparece justamente nos jobs em lote?

Rate limit é o teto de requisições que um provedor aceita por janela de tempo, geralmente por token ou por conta. Quando você passa do teto, a API responde 429 Too Many Requests. Muitas vezes a resposta vem com um header Retry-After dizendo quantos segundos esperar.

No fluxo do dia a dia, as chamadas chegam espalhadas: uma cobrança aqui, uma consulta de status ali. O cenário que estoura o limite é outro, e costuma ser um destes:

  1. Um comando que reconsulta o status de todas as cobranças pendentes dos últimos dias.
  2. A conciliação diária que bate o extrato do provedor com o seu banco, quando ela precisa buscar detalhes item a item.
  3. Uma sincronização inicial de contas bancárias via Open Finance, em que cada conta gera várias chamadas de transações.
  4. O Horizon escalando workers automaticamente porque a fila encheu. Mais workers significam mais concorrência contra a mesma API.

O quarto item é o mais traiçoeiro. O autoscaling do Horizon resolve fila cheia adicionando processos, e isso piora o rate limit. Escalar workers aumenta a vazão de consumo da fila, mas não a capacidade do provedor.

RateLimited ou Redis::throttle: qual usar?

O Laravel oferece mais de uma ferramenta, e elas resolvem problemas diferentes:

Ferramenta O que limita Quando uso
Middleware RateLimited / RateLimitedWithRedis Execuções de job por janela, com chave dinâmica Quando cada job faz uma chamada (ou um número fixo de chamadas) à API
Redis::throttle() Ações por janela, em qualquer ponto do código Dentro do cliente HTTP, quando o número de chamadas por job varia
Redis::funnel() Concorrência: quantos executam ao mesmo tempo Quando o provedor limita requisições simultâneas, não por minuto
Middleware ThrottlesExceptions Tentativas após exceções seguidas Quando o provedor está instável e você quer recuar

O ThrottlesExceptions lida com instabilidade e fica bem perto do que discuti no post sobre circuit breaker com Redis para isolar um gateway fora do ar. Rate limit é outro problema: o provedor está saudável, você é que está pedindo demais.

Como configurar o middleware RateLimited por credencial?

O limite quase nunca é global. Em plataformas com subcontas, cada lojista costuma ter o próprio token, e o provedor conta o limite por token. Uma chave global trava todos os clientes por causa de um. Por isso a chave do limiter precisa ser a credencial.

// AppServiceProvider::boot()
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('asaas', function (object $job) {
    // Consulte o limite real na documentação do provedor e deixe margem.
    return Limit::perMinute(config('services.asaas.rate_per_minute'))
        ->by('asaas:' . $job->credentialId);
});
use Illuminate\Queue\Middleware\RateLimitedWithRedis;

class ConsultarStatusCobranca implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public int $credentialId,
        public string $chargeId,
    ) {}

    public function middleware(): array
    {
        return [new RateLimitedWithRedis('asaas')];
    }

    public function retryUntil(): DateTime
    {
        return now()->addHours(6);
    }

    public function handle(AsaasClient $client): void
    {
        $status = $client->buscarCobranca($this->credentialId, $this->chargeId);
        // atualiza a cobrança local...
    }
}

Prefiro o RateLimitedWithRedis ao RateLimited quando a fila já roda em Redis. O primeiro fala direto com o Redis em vez de passar pela camada de cache, que é mais pesada. O valor do limite vem de configuração: vale deixar 70 ou 80% do teto documentado, porque outras partes do sistema (requisições síncronas da API, por exemplo) também consomem a mesma cota.

Por que meu job com rate limit vai parar no failed_jobs?

Quando o limiter barra um job, o middleware chama release() com o tempo restante da janela. O job volta para a fila e, na próxima execução, o contador de tentativas já subiu. Com public $tries = 3, um job que foi barrado três vezes falha sem nunca ter chamado a API.

A saída é usar retryUntil(), que define um prazo absoluto em vez de um número de tentativas. Com o limite bem configurado, o job vai sendo adiado até conseguir vaga. O prazo serve de rede de segurança para algo que ficou preso de verdade.

Se você também quer limitar as falhas reais (exceção lançada no handle), combine com $maxExceptions:

public int $maxExceptions = 3;

public function retryUntil(): DateTime
{
    return now()->addHours(6);
}

Com essa combinação, adiamentos por limite não contam como falha, mas três exceções de verdade encerram o job. É a diferença entre "esperando a vez" e "quebrado".

Tem outra decisão aqui: se o job barrado deve voltar para a fila ou ser descartado. O ->dontRelease() descarta. Só uso em jobs redundantes, como um refresh de status que outro job vai repetir em minutos. Em qualquer coisa que mexa com dinheiro, o job precisa voltar.

Como respeitar o header Retry-After do provedor?

O limiter local é uma estimativa. O provedor pode contar diferente, pode ter outras partes do seu sistema gastando a mesma cota ou pode ter um limite mais baixo do que a documentação diz. Então o 429 vai acontecer de qualquer jeito, e é preciso tratar.

O erro comum é tratar o 429 só no job que o recebeu. Esse job faz release(30), mas os outros quarenta workers continuam batendo na API e recebendo 429 também. O que funciona é transformar o 429 em um cooldown compartilhado: uma chave no Redis que, enquanto existir, faz todos os jobs daquela credencial esperarem sem chamar a API.

class RespeitaCooldownDoProvedor
{
    public function __construct(private string $provider) {}

    public function handle(object $job, Closure $next): void
    {
        $key = "cooldown:{$this->provider}:{$job->credentialId}";
        $ate = Cache::get($key);

        if ($ate && $ate > now()->timestamp) {
            $job->release($ate - now()->timestamp + random_int(1, 5));
            return;
        }

        try {
            $next($job);
        } catch (ProviderRateLimited $e) {
            $segundos = max($e->retryAfter, 5);
            Cache::put($key, now()->timestamp + $segundos, $segundos);
            $job->release($segundos + random_int(1, 5));
        }
    }
}

E no cliente HTTP, o 429 vira uma exceção de domínio com o tempo de espera:

$response = Http::withToken($token)->get($url);

if ($response->status() === 429) {
    throw new ProviderRateLimited(
        retryAfter: (int) ($response->header('Retry-After') ?: 30)
    );
}

$response->throw();

Três detalhes que fazem diferença:

  1. Jitter: o random_int(1, 5) evita que todos os jobs adiados voltem no mesmo segundo e estourem o limite de novo. Jitter é um atraso aleatório somado ao tempo de espera para espalhar as retentativas.
  2. Retry-After pode vir como data HTTP em vez de segundos, dependendo do provedor. Se for o caso, converta antes de usar.
  3. Ordem dos middlewares: coloque o de cooldown antes do RateLimitedWithRedis, para não gastar vaga do limiter enquanto a credencial está em pausa.
public function middleware(): array
{
    return [
        new RespeitaCooldownDoProvedor('asaas'),
        new RateLimitedWithRedis('asaas'),
    ];
}

Um cuidado que vale para qualquer chamada de escrita: se o 429 ou um timeout acontece num POST que cria cobrança, a retentativa só é segura com chave de idempotência. Detalhei isso no post sobre timeout ao chamar o gateway e Idempotency-Key. Com rate limit, o 429 normalmente significa que a requisição nem foi processada. Mesmo assim, não confio nisso para operações que movimentam dinheiro.

Devo limitar no job ou no cliente HTTP?

O middleware de job conta execuções de job, não requisições. Se ConsultarStatusCobranca faz uma chamada, a conta bate. Se um job de sincronização pagina a lista de transações e faz de 1 a 50 chamadas dependendo do volume, o limiter de job deixa de representar o consumo real.

Nesses casos movo o limite para dentro do cliente HTTP, antes de cada requisição, com Redis::throttle:

use Illuminate\Contracts\Redis\LimiterTimeoutException;
use Illuminate\Support\Facades\Redis;

private function aguardarVaga(int $credentialId): void
{
    Redis::throttle("http:pluggy:{$credentialId}")
        ->allow(config('services.pluggy.rate_per_minute'))
        ->every(60)
        ->block(10) // espera até 10s por uma vaga
        ->then(
            fn () => null,
            fn () => throw new ProviderRateLimited(retryAfter: 15),
        );
}

O block(10) faz o processo esperar até 10 segundos por uma vaga antes de desistir. Se não conseguir, a exceção cai no mesmo middleware de cooldown, e o job é adiado. O trade-off é que um worker parado em block está ocupado sem fazer nada. Por isso mantenho o tempo curto e deixo o adiamento longo para o release.

Na prática uso os dois níveis. O limite no cliente é a proteção correta, porque mede requisição de verdade. O middleware de job serve para não acordar jobs à toa quando a credencial já está saturada.

Como evitar que jobs limitados travem a fila de webhooks no Horizon?

Um job adiado por release não bloqueia o worker, mas uma fila cheia de jobs que ficam indo e voltando gasta ciclos de worker e atrasa quem está atrás. Se a conciliação divide fila com o processamento de webhooks, a confirmação de um Pix pode esperar atrás de milhares de consultas de status adiadas.

A configuração que uso no Horizon separa os supervisores:

// config/horizon.php
'environments' => [
    'production' => [
        'webhooks' => [
            'connection' => 'redis',
            'queue' => ['webhooks'],
            'balance' => 'auto',
            'minProcesses' => 2,
            'maxProcesses' => 10,
        ],
        'provider-api' => [
            'connection' => 'redis',
            'queue' => ['provider-api'],
            'balance' => 'simple',
            'minProcesses' => 1,
            'maxProcesses' => 3,
        ],
    ],
],

O supervisor provider-api tem teto baixo de processos de propósito: ali, mais workers só geram mais 429. Uma regra prática é dimensionar os processos pela capacidade do provedor, não pelo tamanho da fila. Já o supervisor de webhooks pode escalar à vontade, porque o trabalho dele é local: banco, ledger e eventos internos.

Também vale ficar de olho no tempo de espera da fila provider-api no Horizon. Se ela cresce todo dia no mesmo horário, o problema não é de configuração do limiter. O volume de chamadas passou do que o contrato com o provedor comporta, e a conversa passa a ser com o provedor ou sobre cache de respostas.

Quando é melhor espaçar o dispatch em vez de limitar?

Tudo o que vimos até aqui é reativo: os jobs entram na fila de uma vez e o limiter vai adiando. Funciona, mas um lote de dez mil jobs gera dezenas de milhares de execuções que só fazem release. Quando eu sei o tamanho do lote com antecedência, como num comando de reprocessamento, prefiro distribuir no próprio dispatch:

$porMinuto = (int) (config('services.asaas.rate_per_minute') * 0.7);

Cobranca::pendentes()
    ->where('credential_id', $credentialId)
    ->lazyById()
    ->each(function (Cobranca $c, int $i) use ($porMinuto) {
        ConsultarStatusCobranca::dispatch($c->credential_id, $c->provider_id)
            ->onQueue('provider-api')
            ->delay(now()->addSeconds(intdiv($i, $porMinuto) * 60));
    });

O delay pré-agenda cada bloco de jobs para o minuto certo. O limiter continua lá como rede de proteção, mas quase nunca precisa agir. O trade-off é que o lote demora o que tem que demorar, e o cálculo assume uma credencial por vez. Com várias credenciais, agrupe por credential_id e calcule o delay dentro de cada grupo, já que cada uma tem a própria cota.

O que eu configuro antes do primeiro lote rodar em produção

Quando uma integração nova vai para produção, esta é a ordem em que monto a proteção contra rate limit:

  1. Chave por credencial em todo limiter. Nunca uma chave global para um limite que o provedor conta por token.
  2. retryUntil() no lugar de $tries em todo job com limite, com $maxExceptions para separar adiamento de falha.
  3. Cooldown compartilhado acionado pelo 429, lendo o Retry-After e com jitter nas retentativas.
  4. Limite no cliente HTTP quando o número de chamadas por job varia, e no middleware de job quando é fixo.
  5. Fila e supervisor próprios no Horizon para chamadas externas, com maxProcesses dimensionado pela cota do provedor.
  6. Dispatch espaçado em comandos de lote, deixando o limiter como proteção e não como mecanismo principal.
  7. Margem de 20 a 30% abaixo do limite documentado, porque o seu tráfego síncrono também consome a cota.

Nenhum desses itens é complicado sozinho. O que dá trabalho é o incidente que aparece quando falta um deles: um lote de reprocessamento estoura o limite de uma credencial, os jobs vão para failed_jobs sem nunca terem chamado a API e alguém precisa descobrir quais cobranças ficaram sem status atualizado. Configurar isso antes do primeiro lote custa bem menos do que essa investigação.