<?php
namespace App\Services\RateLimit;
use Illuminate\Support\Facades\Redis;
class RedisTokenBucket
{
private const SCRIPT = <<<'LUA'
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_per_ms = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
local elapsed = math.max(0, now - ts)
tokens = math.min(capacity, tokens + elapsed * refill_per_ms)
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, 60000)
return 0
end
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, 60000)
return math.ceil((1 - tokens) / refill_per_ms)
LUA;
public function __construct(
private string $key,
private int $capacity,
private float $refillRatePerSecond
) {
}
public function take(): int
{
$refillPerMs = $this->refillRatePerSecond / 1000;
return (int) Redis::eval(
self::SCRIPT,
1,
"ratebucket:{$this->key}",
$this->capacity,
$refillPerMs,
(int) (microtime(true) * 1000)
);
}
}
<?php
namespace App\Services;
use App\Exceptions\RateLimitExceeded;
use App\Services\RateLimit\RedisTokenBucket;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
class ThrottledApiClient
{
private RedisTokenBucket $bucket;
public function __construct(private string $baseUrl, private string $token)
{
$this->bucket = new RedisTokenBucket(
key: 'provider:acme',
capacity: (int) config('services.acme.burst', 20),
refillRatePerSecond: (float) config('services.acme.rate', 8)
);
}
public function post(string $path, array $payload): Response
{
$retryAfterMs = $this->bucket->take();
if ($retryAfterMs > 0) {
throw new RateLimitExceeded($retryAfterMs);
}
$response = Http::baseUrl($this->baseUrl)
->withToken($this->token)
->timeout(10)
->acceptJson()
->post($path, $payload);
if ($response->status() === 429) {
$serverDelay = (int) $response->header('Retry-After', '1');
throw new RateLimitExceeded(max($serverDelay * 1000, 1000));
}
return $response->throw();
}
}
<?php
namespace App\Jobs;
use App\Exceptions\RateLimitExceeded;
use App\Services\ThrottledApiClient;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class DispatchApiCall implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 25;
public function __construct(
public string $path,
public array $payload
) {
}
public function backoff(): array
{
return [1, 2, 5, 10];
}
public function retryUntil(): Carbon
{
return now()->addMinutes(15);
}
public function handle(ThrottledApiClient $client): void
{
try {
$client->post($this->path, $this->payload);
} catch (RateLimitExceeded $e) {
$seconds = (int) ceil($e->retryAfterMs() / 1000);
$this->release(max($seconds, 1));
}
}
}
<?php
namespace App\Exceptions;
use RuntimeException;
class RateLimitExceeded extends RuntimeException
{
public function __construct(private int $retryAfterMs)
{
parent::__construct("Rate limited; retry after {$retryAfterMs}ms");
}
public function retryAfterMs(): int
{
return $this->retryAfterMs;
}
}
This snippet shows how outbound API calls to a third-party service are kept under a provider's rate limit without dropping work, by combining a Redis-backed token bucket, a thin HTTP client wrapper, and a queued dispatcher that reschedules itself when tokens run out.
In RedisTokenBucket, the limiter is implemented as a classic token bucket evaluated atomically in Lua. The script in take reads the stored token count and last-refill timestamp, adds tokens proportional to elapsed time up to capacity, and only decrements when at least one token is available. Running the whole read-modify-write inside EVAL guarantees atomicity across concurrent workers, which is the entire point: without it, two processes could both see one remaining token and both spend it. The method returns the number of milliseconds a caller should wait when the bucket is empty, so the caller can back off precisely instead of guessing.
ThrottledApiClient wraps Laravel's Http client and consults the bucket before every request. When bucket->take() returns a positive retryAfterMs, it throws a RateLimitExceeded exception carrying that delay rather than blocking the worker thread — an important trade-off, since sleeping inside a queue worker wastes a slot that could process other jobs. It also honours a server-sent Retry-After header on 429 responses, preferring the provider's own guidance over the local estimate. The refillRate and capacity are tuned to sit just under the documented provider limit to leave headroom for clock skew.
DispatchApiCall is the queued job that ties it together. It calls the client inside a try/catch; on RateLimitExceeded it uses release() to requeue itself after the suggested delay instead of failing, so no request is lost and ordering pressure is relieved. backoff() and retryUntil() bound the retries so a permanently throttled endpoint eventually surfaces as a failure rather than looping forever. This design pushes throttling to the edge of the system while keeping business logic in jobs simple, and it scales horizontally because the shared Redis bucket coordinates all workers.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
json.array! @posts do |post|
json.cache! ['v1', post], expires_in: 1.hour do
json.id post.id
json.title post.title
json.excerpt post.excerpt
json.published_at post.published_at
Fragment caching for expensive JSON serialization
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";
const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";
JWT access + refresh token rotation (conceptual)
{
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build"
},
Laravel mix/Vite for asset compilation
Share this code
Here's the card — post it anywhere.