# Rate limit de APIs de pagamento nos jobs do Laravel: RateLimited, Redis::throttle e Retry-After

> Como impedir que jobs de conciliação e reprocessamento estourem o limite de requisições do gateway, sem mandar tudo para failed_jobs nem travar a fila de webhooks.

Por Lucas Roberto · Laravel · publicado em 2026-10-20 · 9 min de leitura
Versão web: https://lucasroberto.com/blog/rate-limit-de-apis-de-pagamento-nos-jobs-do-laravel-ratelimited-redisthrottle-e-retry-after

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](/blog/conciliacao-de-pagamentos-no-laravel-como-bater-o-extrato-do-provedor-com-o-seu-banco-todo-dia), 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](/blog/circuit-breaker-no-laravel-com-redis-como-isolar-um-gateway-de-pagamento-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.

```php
// 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);
});
```

```php
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`:

```php
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.

```php
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:

```php
$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.

```php
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](/blog/timeout-ao-chamar-o-gateway-de-pagamento-como-evitar-cobranca-duplicada-com-idempotency-key-no-laravel). 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`:

```php
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:

```php
// 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:

```php
$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.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
