php 161 lines · 4 tabs

Rate-Limited HTTP Client Wrapper With Redis Token Bucket and a Queued Dispatcher

Shared by codesnips Sep 2026
4 tabs
<?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)
        );
    }
}
4 files · php Explain with highlit

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

typescript
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

typescript reliability retry
by codesnips 2 tabs
typescript
export type Settled<R> =
  | { status: 'fulfilled'; value: R }
  | { status: 'rejected'; reason: unknown };

export interface ConcurrencyOptions {
  limit: number;

Simple concurrency limiter for batch operations

node concurrency async
by codesnips 2 tabs
ruby
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

rails caching performance
by Alex Kumar 1 tab
php
<?php

namespace App\Providers;

use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;

Laravel service container and dependency injection

laravel dependency-injection service-container
by Carlos Mendez 2 tabs
typescript
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)

security node jwt
by codesnips 3 tabs
json
{
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },

Laravel mix/Vite for asset compilation

laravel vite assets
by Carlos Mendez 4 tabs

Share this code

Here's the card — post it anywhere.

Rate-Limited HTTP Client Wrapper With Redis Token Bucket and a Queued Dispatcher — share card
Link copied