Token Bucket Rate Limiting for Webhook Senders

A consumer that publishes “100 requests per second, bursts to 200” has given you a contract, and a dispatcher that ignores it will be throttled, blocked, or de-provisioned. Sending as fast as your worker pool allows and treating the resulting 429 responses as retryable failures is the worst possible strategy: it converts every rate-limit rejection into extra retry traffic aimed at the endpoint that just told you to slow down. This guide implements the producer-side control described in Webhook Rate Limiting & Backpressure as running code — a token bucket held in Redis, refilled and consumed by a single atomic Lua script, with one bucket per destination endpoint. It is the outbound mirror of applying backpressure to webhook consumers: there you protect yourself from an over-eager sender, here you are the sender.

The distributed part is what makes this non-trivial. A token bucket inside a single Python process is thirty lines and a time.monotonic() call. A token bucket that stays correct while twelve dispatcher replicas draw from it concurrently, across restarts and rolling deploys, needs shared state and an operation that cannot interleave.

Shared token bucket across dispatcher replicas Three dispatcher replicas call one Redis Lua script to acquire a token for an endpoint before sending, and a 429 response feeds back to lower the stored rate. Dispatcher A 8 workers Dispatcher B 8 workers Dispatcher C 8 workers Redis consume.lua refill + take atomically one hash per endpoint tokens, ts, rate consumer endpoint token granted 429 + Retry-After lowers the rate
Correctness comes from every replica drawing on one bucket per endpoint, not from each process politely limiting itself.

Prerequisites

Step 1: Translate the published limit into rate and capacity

A token bucket has exactly two knobs, and they answer two different questions. rate is tokens added per second and sets the long-run average — it maps directly onto the consumer’s documented sustained limit. capacity is the maximum number of tokens the bucket can hold and therefore the largest burst you may fire after a quiet period.

The mistake is setting capacity equal to the burst number the consumer publishes. A consumer that advertises “bursts to 200” usually means its own limiter tolerates 200 in flight; if you also allow 200 and another sender is active, you jointly exceed it. Size capacity to a fraction of the published burst — 50% is a sane default when you are not the only sender — and leave headroom.

Parameter Typical value Derived from Effect if set too high
rate 0.8 × published sustained limit Consumer documentation Sustained 429s and eventual key suspension
capacity 0.5 × published burst Consumer documentation One burst blows the limit, later requests all reject
max_wait Half the per-attempt timeout Your dispatch timeout budget Workers block on tokens instead of doing work
Key TTL capacity / rate + 60 s Refill maths Stale buckets accumulate for deleted endpoints
Poll interval 25 ms – 250 ms Latency budget Waiters wake in lockstep and stampede the script

A capacity equal to one second of rate (capacity = rate) makes the bucket behave almost like a strict pacer with no burst tolerance. Capacity equal to ten seconds of rate lets a batch of ten seconds’ worth of events leave instantly after idle time, which is usually what you want for webhook fan-out — events arrive clumped, and a small burst allowance is the difference between “delivered immediately” and “queued behind an artificial pacer”.

Step 2: Make refill and consume atomic with a Lua script

The bucket operation is read tokens, compute refill from elapsed time, compare against the request, decrement, and write back. Doing that as separate Redis commands from multiple replicas is a textbook lost-update race: two dispatchers both read tokens = 1, both decide they may send, both write tokens = 0, and two requests leave against one token. Redis runs a Lua script to completion without interleaving other commands, which makes the whole read-modify-write one atomic step.

-- consume.lua
-- KEYS[1] = bucket hash key, e.g. wh:bucket:ep_42
-- ARGV[1] = refill rate in tokens per second
-- ARGV[2] = bucket capacity in tokens
-- ARGV[3] = tokens requested (normally 1)
-- Returns: {allowed(0|1), tokens_remaining, seconds_until_enough}
local rate      = tonumber(ARGV[1])
local capacity  = tonumber(ARGV[2])
local requested = tonumber(ARGV[3])

-- Use the Redis server clock so replica clock skew cannot corrupt the refill.
local t   = redis.call('TIME')
local now = tonumber(t[1]) + (tonumber(t[2]) / 1000000)

local state  = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1])
local ts     = tonumber(state[2])

if tokens == nil or ts == nil then
  tokens = capacity          -- a fresh bucket starts full
  ts = now
end

local elapsed = now - ts
if elapsed < 0 then
  elapsed = 0                -- guard against a clock step backwards
end
tokens = math.min(capacity, tokens + (elapsed * rate))

local allowed = 0
local wait = 0.0
if tokens >= requested then
  tokens = tokens - requested
  allowed = 1
else
  wait = (requested - tokens) / rate
end

redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) + 60)

-- Lua numbers are truncated to integers on the wire; send floats as strings.
return {allowed, tostring(tokens), tostring(wait)}

Three details in that script are load-bearing. Reading the clock with redis.call('TIME') gives every replica one authoritative clock, so a pod whose NTP has drifted two seconds cannot mint or destroy tokens. Returning wait lets a caller sleep for exactly as long as the deficit needs instead of polling blindly. And the EXPIRE on every call means a bucket for an endpoint you deleted last month evaporates on its own rather than living in Redis forever.

Anatomy of a bucket hash The bucket for one endpoint is a Redis hash with four fields: current tokens, the refill timestamp, the sustained rate and the burst capacity. HGETALL wh:bucket:ep_42 tokens = 18.4 ts = 1721898000.12 rate = 25 tokens/sec capacity = 50 current allowance server-clock refill stamp sustained send rate burst ceiling One hash per endpoint. The Lua script refills and consumes in a single atomic round trip.
Storing rate and capacity in the hash alongside the counters is what lets Step 4 change an endpoint's limit at runtime without a deploy.

Step 3: Gate every dispatch through a per-endpoint bucket

Register the script once per client so redis-py uses EVALSHA and only ships the source on a NOSCRIPT miss. Key the bucket by endpoint identifier, never by URL string — URLs change, and two subscriptions pointing at the same host still deserve separate accounting unless the consumer’s limit is per host.

import asyncio
import random
import time
from dataclasses import dataclass
from pathlib import Path

import httpx
import redis.asyncio as aioredis

CONSUME_LUA = Path("consume.lua").read_text()


@dataclass(frozen=True)
class BucketConfig:
    rate: float          # sustained tokens per second
    capacity: float      # burst ceiling in tokens


class TokenBucketLimiter:
    def __init__(self, redis: aioredis.Redis, prefix: str = "wh:bucket:") -> None:
        self.redis = redis
        self.prefix = prefix
        self._script = redis.register_script(CONSUME_LUA)

    async def try_acquire(
        self, endpoint_id: str, cfg: BucketConfig, tokens: float = 1.0
    ) -> tuple[bool, float, float]:
        allowed, remaining, wait = await self._script(
            keys=[f"{self.prefix}{endpoint_id}"],
            args=[cfg.rate, cfg.capacity, tokens],
        )
        return bool(int(allowed)), float(remaining), float(wait)

    async def acquire(
        self,
        endpoint_id: str,
        cfg: BucketConfig,
        tokens: float = 1.0,
        max_wait: float = 5.0,
    ) -> float:
        """Block until a token is available. Returns seconds spent waiting."""
        started = time.monotonic()
        while True:
            ok, _remaining, wait = await self.try_acquire(endpoint_id, cfg, tokens)
            if ok:
                return time.monotonic() - started
            if time.monotonic() - started + wait > max_wait:
                raise TimeoutError(
                    f"no token for {endpoint_id} within {max_wait}s "
                    f"(needs {wait:.2f}s more)"
                )
            # Jitter the poll so concurrent waiters do not wake together.
            await asyncio.sleep(min(wait, 0.25) + random.uniform(0, 0.05))

Wrap the outbound call so no code path can send without a token:

from prometheus_client import Histogram

LIMITER_WAIT_SECONDS = Histogram(
    "webhook_limiter_wait_seconds",
    "Seconds a delivery spent waiting for a token",
    ["endpoint_id"],
)


class RateLimitedDispatcher:
    def __init__(self, limiter: TokenBucketLimiter, client: httpx.AsyncClient) -> None:
        self.limiter = limiter
        self.client = client

    async def deliver(
        self, endpoint_id: str, url: str, body: bytes, headers: dict[str, str],
        cfg: BucketConfig,
    ) -> httpx.Response:
        waited = await self.limiter.acquire(endpoint_id, cfg, max_wait=5.0)
        if waited > 0.5:
            # Sustained waiting means the queue is offered faster than the limit.
            LIMITER_WAIT_SECONDS.labels(endpoint_id=endpoint_id).observe(waited)
        return await self.client.post(url, content=body, headers=headers, timeout=10.0)

When acquire raises TimeoutError, do not drop the event and do not tighten the loop. Requeue it with a delay of roughly the reported deficit; the delivery has not failed, it simply has not started. Treating a token timeout as a delivery failure inflates your error rate and, if it feeds the retry counter, burns attempts on an event that never left the process.

Step 4: Adapt the rate from 429 and Retry-After

Static configuration goes stale. Consumers lower their limits, add per-tenant quotas, or throttle during their own incidents, and the first you hear about it is a 429. A static bucket keeps sending at the configured rate and collects rejections indefinitely; an adaptive bucket treats the 429 as the authoritative signal it is.

Two mechanisms work together. Retry-After is a hard instruction — set a cooldown key with exactly that TTL and refuse to acquire tokens for that endpoint until it expires. Separately, multiplicatively decrease the stored rate (halve it, with a floor) so that when the cooldown ends you are not immediately back at the rate that triggered the throttle. Recovery is additive: a clean streak grows the rate by a small percentage per interval, back toward the configured ceiling but never above it.

COOLDOWN_PREFIX = "wh:cooldown:"
RATE_PREFIX = "wh:rate:"


class AdaptiveRate:
    """Multiplicative decrease on 429, additive increase on a clean streak."""

    def __init__(
        self,
        redis: aioredis.Redis,
        ceiling: float,
        floor: float = 1.0,
        decrease: float = 0.5,
        increase: float = 1.10,
    ) -> None:
        self.redis = redis
        self.ceiling = ceiling
        self.floor = floor
        self.decrease = decrease
        self.increase = increase

    async def current(self, endpoint_id: str) -> float:
        raw = await self.redis.get(f"{RATE_PREFIX}{endpoint_id}")
        return float(raw) if raw else self.ceiling

    async def in_cooldown(self, endpoint_id: str) -> int:
        ttl = await self.redis.ttl(f"{COOLDOWN_PREFIX}{endpoint_id}")
        return max(0, ttl)

    async def on_throttled(self, endpoint_id: str, retry_after: str | None) -> float:
        seconds = 1
        if retry_after and retry_after.isdigit():
            seconds = max(1, min(int(retry_after), 300))
        await self.redis.setex(f"{COOLDOWN_PREFIX}{endpoint_id}", seconds, "1")
        new_rate = max(self.floor, await self.current(endpoint_id) * self.decrease)
        await self.redis.set(f"{RATE_PREFIX}{endpoint_id}", new_rate, ex=3600)
        return new_rate

    async def on_success_window(self, endpoint_id: str) -> float:
        """Call from a periodic task, e.g. once a minute per active endpoint."""
        if await self.in_cooldown(endpoint_id):
            return await self.current(endpoint_id)
        new_rate = min(self.ceiling, await self.current(endpoint_id) * self.increase)
        await self.redis.set(f"{RATE_PREFIX}{endpoint_id}", new_rate, ex=3600)
        return new_rate

Wire it into the dispatcher so the effective config is read per delivery:

    async def deliver_adaptive(
        self, endpoint_id: str, url: str, body: bytes, headers: dict[str, str],
        adaptive: AdaptiveRate, capacity: float,
    ) -> httpx.Response:
        cooldown = await adaptive.in_cooldown(endpoint_id)
        if cooldown:
            raise TimeoutError(f"{endpoint_id} in cooldown for {cooldown}s")
        cfg = BucketConfig(rate=await adaptive.current(endpoint_id), capacity=capacity)
        await self.limiter.acquire(endpoint_id, cfg, max_wait=5.0)
        response = await self.client.post(url, content=body, headers=headers, timeout=10.0)
        if response.status_code == 429:
            await adaptive.on_throttled(endpoint_id, response.headers.get("Retry-After"))
        return response

The rate floor is not optional. Without it, an endpoint that returns 429 for an unrelated reason — a misconfigured WAF rule, say — halves its way to a rate of 0.0001 tokens per second and effectively stops receiving events forever, with no error anywhere to explain it. Keep the floor high enough that a wrongly-throttled endpoint still trickles, and alert when any endpoint’s rate sits at the floor for more than a few minutes. Persistent throttling is also a legitimate breaker input; feed it into per-endpoint circuit breaker state machines so a consumer that is throttling everything gets stopped rather than trickled.

Adaptive rate state machine The stored rate moves between nominal, throttled and recovering states, halving on a 429 and climbing back after a clean interval. Adaptive rate control driven by 429 responses repeated 429 halves again Nominal rate = ceiling Throttled rate halved, cooldown Recovering rate +10% / minute 429 60 s clean rate restored to the configured ceiling
Decrease fast and multiplicatively, recover slowly and additively — the asymmetry is what keeps the sender out of a throttle loop.

Verification and testing

The property to assert is not “requests were limited” but “the observed rate over a window never exceeded the configured rate, no matter how many concurrent callers there were”.

import asyncio
import time

import fakeredis.aioredis
import pytest


@pytest.mark.asyncio
async def test_burst_is_bounded_by_capacity():
    redis = fakeredis.aioredis.FakeRedis()
    limiter = TokenBucketLimiter(redis)
    cfg = BucketConfig(rate=10.0, capacity=5.0)
    granted = 0
    for _ in range(20):                     # ask 20 times with no delay
        ok, _rem, _wait = await limiter.try_acquire("ep_burst", cfg)
        granted += int(ok)
    assert granted <= 6, f"burst leaked: {granted} tokens from a capacity of 5"


@pytest.mark.asyncio
async def test_sustained_rate_holds_under_concurrency():
    redis = fakeredis.aioredis.FakeRedis()
    limiter = TokenBucketLimiter(redis)
    cfg = BucketConfig(rate=20.0, capacity=20.0)
    started = time.monotonic()

    async def worker() -> int:
        sent = 0
        while time.monotonic() - started < 2.0:
            ok, _rem, _wait = await limiter.try_acquire("ep_rate", cfg)
            if ok:
                sent += 1
            await asyncio.sleep(0.005)
        return sent

    counts = await asyncio.gather(*[worker() for _ in range(8)])
    total = sum(counts)
    # 2 seconds at 20/s plus a full 20-token bucket = 60 ceiling, allow slack.
    assert total <= 66, f"eight workers overdrew the shared bucket: {total}"


@pytest.mark.asyncio
async def test_429_halves_the_rate_and_sets_cooldown():
    redis = fakeredis.aioredis.FakeRedis()
    adaptive = AdaptiveRate(redis, ceiling=100.0, floor=1.0)
    assert await adaptive.current("ep_1") == 100.0
    new_rate = await adaptive.on_throttled("ep_1", retry_after="30")
    assert new_rate == 50.0
    assert 0 < await adaptive.in_cooldown("ep_1") <= 30


@pytest.mark.asyncio
async def test_rate_never_falls_below_the_floor():
    redis = fakeredis.aioredis.FakeRedis()
    adaptive = AdaptiveRate(redis, ceiling=100.0, floor=2.0)
    for _ in range(20):
        await adaptive.on_throttled("ep_2", retry_after="1")
    assert await adaptive.current("ep_2") == 2.0

Against a live Redis, confirm the bucket is doing arithmetic you recognise:

# Watch a bucket drain and refill while the dispatcher runs.
watch -n 1 'redis-cli HGETALL wh:bucket:ep_42'

# Verify the script is cached and being called via EVALSHA, not shipped each time.
redis-cli INFO commandstats | grep -E 'cmdstat_(eval|evalsha)'

If cmdstat_eval grows in step with cmdstat_evalsha, something is re-registering the script per request — usually a limiter constructed inside the request handler rather than once at startup.

Failure modes and gotchas

Frequently Asked Questions

How much latency does the extra Redis round trip add to each delivery?

One EVALSHA against a Redis in the same availability zone lands well under a millisecond, which is noise beside an outbound HTTPS request measured in tens or hundreds of milliseconds. The cost that actually bites is placement: a limiter talking to a Redis two regions away pays 40 ms or more per acquire, so the throttle becomes more expensive than the thing it is throttling. Keep Redis local to the dispatcher and reuse one connection pool across the process.

What should the dispatcher do when Redis itself is unavailable?

Choose the behaviour deliberately, because the default is whatever your exception handling happens to do. Failing open sends at full worker speed exactly when you have lost the ability to observe the consequences, and failing closed converts a limiter outage into a total delivery outage. The workable middle is a per-process fallback bucket sized at the ceiling divided by the expected replica count, with a loud alert, so delivery degrades rather than stopping or stampeding.

Should a large payload consume more than one token?

Only if the consumer's limit is expressed in bytes or compute rather than requests, which is unusual for webhooks but common for the APIs behind them. The script already accepts a token count, so you can pass a weight derived from the serialized body size. Guard the caller though: a request for more tokens than the bucket's capacity can never be satisfied, and the acquire loop will spin until it hits max_wait rather than failing fast.

Do retry attempts draw tokens from the same bucket as first attempts?

They must, because the consumer's limiter counts requests and has no idea which of yours is a retry. That means the ceiling has to cover attempts rather than events, and during an incident retries can be the majority of your outbound traffic. If you size the rate against event volume alone, the first wave of failures pushes you over the published limit at precisely the moment the consumer is least able to absorb it.

Why a token bucket rather than a fixed window or a leaky bucket?

A fixed window lets you send a full window's worth at the end of one interval and again at the start of the next, so a consumer measuring a rolling second sees double the rate you configured. A leaky bucket paces strictly and never allows a burst, which needlessly delays the clumped fan-out that webhook traffic naturally produces. The token bucket expresses a sustained rate and a bounded burst as separate numbers, which is exactly the shape in which consumers publish their limits.

Does bucket state survive a rolling deploy or an autoscale event?

Yes, because the state is keyed by endpoint in Redis rather than held per process, so a replica that restarts rejoins the same allowance instead of resetting it. During a rolling deploy the old and new pods overlap and draw from one bucket, so the aggregate rate stays correct while each individual worker waits slightly longer for a token. That is the property a per-process limiter cannot give you, and it is why scaling the dispatcher out does not require re-tuning the rate.