A supplier lets you hold five connections open. You run twenty workers, and a stock-sync job goes onto the queue for every product you carry. Twenty jobs start at once, twenty connections open, and the supplier starts refusing them. Your queue is doing exactly what you built it to do, which is the problem.
Adding workers makes it worse, and lowering your worker count throttles everything else on the queue too. What you want is a limit that applies to this one integration.
Two different limits
People reach for "rate limiting" as one idea, but there are two, and picking the wrong one leaves the problem in place.
Concurrency is how many run at the same moment. Five connections, five jobs, no matter how fast each one finishes. This is the right frame when the constraint is a connection pool, a licence seat count, or a database that falls over past a certain parallelism.
Rate is how many run per period. Sixty calls a minute, whether that is one at a time or all sixty in the first second. This is the right frame when someone publishes a quota.
Laravel gives you one tool for each, both backed by Redis.
Concurrency: Redis::funnel
funnel holds a fixed number of slots. A job takes a slot, does the work, and gives it back:
use Illuminate\Support\Facades\Redis;
public function handle(SupplierApi $api): void
{
Redis::funnel('supplier-api')
->limit(5)
->then(
fn () => $api->pushStock($this->product),
fn () => $this->release(10),
);
}
Five jobs run against the supplier at a time. The sixth cannot get a slot, so the second closure runs and the job goes back on the queue to try again shortly. Everything else on your queue keeps moving.
Rate: Redis::throttle
throttle counts executions in a window instead of holding slots:
Redis::throttle('supplier-api')
->allow(60)
->every(60)
->then(
fn () => $api->pushStock($this->product),
fn () => $this->release(10),
);
Sixty per minute. Whether those run one after another or all at once does not matter to it.
Which one to reach for
| Their limit is stated as | Use |
|---|---|
| "5 concurrent connections" | funnel()->limit(5) |
| "60 requests per minute" | throttle()->allow(60)->every(60) |
| "don't overload our staging box" | funnel, pick a small number |
| Documented quota with a 429 response | throttle, plus handle the 429 anyway |
When both are stated, apply both. They nest cleanly, and they are guarding against different failures.
releaseAfter is a lock TTL, not a timeout
This is the gotcha that quietly breaks a funnel. Each slot is a lock with an expiry, and releaseAfter sets that expiry. It defaults to a minute.
If your job holds a slot for longer than that, Redis expires the lock while the work is still running, another job grabs the freed slot, and now you have six jobs against a five-connection API. The limit looks configured and is not being honoured.
Set it above the slowest run you would tolerate:
Redis::funnel('supplier-api')
->limit(5)
->releaseAfter(120)
->then(...);
Waiting instead of bouncing
By default a job that cannot get a slot fails through to the second closure immediately. block() makes it wait a few seconds first:
Redis::funnel('supplier-api')->limit(5)->block(5)->then(...);
That is worth a little tuning. A short block smooths out bursts and avoids a storm of release-and-retry. A long block ties up a worker doing nothing, which is the thing you were trying to avoid. A few seconds is usually the sweet spot.
Releases still consume attempts
Every release() counts as an attempt. A busy funnel can burn through $tries on nothing but failed slot acquisitions and land a perfectly good job in failed_jobs.
Expire on a clock instead:
public function retryUntil(): DateTime
{
return now()->addHours(2);
}
The same trap shows up whenever a job releases itself, and it is covered in job has been attempted too many times or run too long.
The middleware alternative
If all you need is a plain rate limit, RateLimited middleware moves it out of handle() and keeps the job body about the work:
use Illuminate\Queue\Middleware\RateLimited;
public function middleware(): array
{
return [new RateLimited('supplier-api')];
}
That pairs with a named limiter defined in a service provider. Reach for the Redis limiters directly when you need concurrency, or when only part of the job should be limited rather than the whole thing.
Wrapping up
Decide whether the constraint is "how many at once" or "how many per minute", because they need different tools. Use funnel for concurrency and throttle for rate, set releaseAfter longer than your slowest job so slots are not handed out early, block for a couple of seconds rather than bouncing immediately, and switch the job to retryUntil() so waiting does not exhaust its attempts.
All comments ()
No comments yet
Be the first to leave a comment on this post.