Table of Contents
- Executive Summary
- TL;DR
- The Reframe: You Are the Client, Not the Server
- Three Things People Call Rate Limiting
- Concurrency Is Not Rate
- You Are Limiting the Wrong Unit
- Every Per-Process Limiter Is a Lie at Scale
- Choosing an Algorithm: Token Bucket, Leaky Bucket, Sliding Window
- A Weighted Redis Token Bucket That Counts Segments
- Wiring the Limiter Into Python and Celery
- Wiring the Limiter Into Node.js and BullMQ
- Wiring the Limiter Into PHP and Laravel
- cURL and Manual Testing
- Batching Changes the Arithmetic
- Backpressure Is a Signal, Not a Limit
- Shed or Buffer: Policy by Message Class
- Priority Lanes and Reserved Capacity
- Reacting to Rejection Without Causing a Storm
- Discovering Your Ceiling Instead of Guessing It
- Inbound Backpressure: DLR Webhooks and smswebhookrate
- Where Rate Limiting Meets Idempotency
- Observability: Four Metrics That Actually Predict an Incident
- How to Add Rate Limiting to an Existing Send Pipeline
- Decision Matrix
- Production Checklist
- Ten Mistakes
- FAQs
Executive Summary
Rate limiting in a messaging pipeline is not the same problem as rate limiting an API you own, and the difference is not cosmetic. When you protect your own endpoint, you are counting arrivals you did not choose, and the worst outcome of getting it wrong is that you reject a request that a client can retry. When you send messages, you are the one generating the load, the resource you are pressing on belongs to someone else, and the worst outcome of getting it wrong is that you burn real money submitting messages that get throttled, or that a five million message promotional campaign starves the OTP that a customer is staring at a login screen waiting for.
That inversion changes almost every design decision. It means the limiter belongs on the egress path inside your own application rather than in front of it. It means the unit you count is not HTTP requests, because one request to the batch send endpoint can carry ten thousand recipients and a Unicode message body can consume three credits per recipient, so a request-per-second limiter can be off by four orders of magnitude. It means the limiter has to be shared across every process in your fleet, which immediately disqualifies the tools most integration tutorials reach for: an asyncio.Semaphore, a p-limit instance, a Guzzle pool concurrency setting, or Celery’s rate_limit option are all per process, and every one of them silently multiplies by your worker count the moment you scale out.

It also means rate limiting on its own is insufficient. A limiter with no backpressure does not reduce load, it relocates it: the excess sits in a queue, the queue grows, and a throughput problem quietly becomes a latency problem. For a promotional campaign that is fine. For an OTP it is a total failure, because a one time password that arrives four minutes late is worse than one that never arrives, since the user has already tapped resend and generated a second charge.
This guide covers the client side of the problem end to end: how to distinguish concurrency from rate from backpressure, how to build a Redis backed token bucket whose cost function is weighted by segment count rather than request count, how to wire it into Celery, BullMQ and Laravel without reintroducing the per process trap, how to decide per message class whether to shed or to buffer, how to use the documented smswebhookrate parameter as an inbound control on delivery receipts, and how to discover your actual ceiling adaptively rather than hardcoding a number nobody published.
Two scoping notes. First, this article is about the HTTP API and the application layer above it. SMPP has its own throughput model built on window size and bind level flow control, and that is covered separately in SMPP Rate Limits and Throughput Tuning; where the two touch, this article links there rather than repeating it. Second, no numeric transactions per second ceiling for the HTTP API is published, so this guide deliberately teaches you to measure yours instead of quoting one.
TL;DR
- A concurrency limit is not a rate limit. Ten in flight requests at 50 ms round trip is roughly 200 sends per second. The same ten at 500 ms is roughly 20. Identical code, an order of magnitude of difference in the load you place on the gateway.
- Count segments and recipients, not requests. One HTTP call can carry thousands of recipients, and a Unicode body can be three credits each. Weight your token cost by
recipients x segments, using the same counter that drives your bill. - Anything per process is not a limiter.
asyncio.Semaphore,p-limit, Celeryrate_limitand Guzzle pool concurrency all scale linearly with your worker count. Only a shared store gives you a global ceiling. BullMQ’s queuelimiteris the notable exception because it is Redis backed. - Token bucket is the right default for messaging because campaigns are legitimately bursty. Implement it as a single Lua script so the read, refill and decrement are atomic.
- Rate limiting without backpressure just moves the problem. The limiter caps throughput; the queue absorbs the excess; latency grows without bound. You need an explicit signal back to the producer.
- Shed or buffer is a per message class decision. OTP and one time links should be shed with a fast error when the pipeline is behind. Promotional traffic should be buffered. Never put both in one queue.
- 429 handling is a safety net, not a strategy. Reactive backoff produces a sawtooth and, across a fleet, synchronised retry storms. Add full jitter, and only retry when the rejection is definite.
- Inbound needs limiting too.
smswebhookrateon webhook creation sets the TPS at which delivery reports are pushed to you, and the documented default is 10. - Shedding interacts with idempotency. A shed message must return its claim to a state your reconciliation understands, which is a different transition from an ambiguous send. See Message Idempotency at Scale.
- Track oldest item age, not queue depth. A deep queue draining fast is healthy. A shallow queue with a four minute old head is an incident.
The Reframe: You Are the Client, Not the Server
Search for rate limiting and essentially everything you find is written from the position of a service operator. The Redis rate limiter documentation frames it as protecting an API from clients. System design material frames it as an interview problem about a gateway in front of your own microservices. Even the Google SRE chapter on handling overload, which is the best writing on the topic, is primarily about what a server does when too much arrives.
That framing is not wrong, it just does not describe your situation when you are sending messages. Your situation has four properties that flip the defaults:
The load is self generated. You are not reacting to unpredictable arrivals. You know, before you send anything, that the campaign is 480,000 recipients. The information needed to pace correctly is available up front, which means pacing can be planned rather than merely defended against.
The scarce resource is not yours. You are not protecting your own CPU. You are staying inside an allowance on infrastructure you do not operate, and beyond that gateway sit operator level constraints you have even less visibility into. You cannot autoscale your way out.
Every unit of load costs money. This is the property with no analogue in generic rate limiting. An over eager retry loop against your own service wastes CPU. An over eager retry loop against a send endpoint submits messages, and credits are deducted at submission, not at delivery. Getting the limiter wrong is not a performance bug, it is a billing bug.
Failure is asymmetric across message classes. A rejected search query is a rejected search query. A delayed promotional SMS is a non event; a delayed OTP is a lost signup and, very often, a duplicate charge because the user tapped resend. Any policy that treats all outbound messages as one homogeneous stream is already wrong.
Hold those four properties in mind, because every recommendation below follows from at least one of them.
Three Things People Call Rate Limiting
Precision here saves a lot of confused debugging later. Three distinct mechanisms get collapsed into one phrase.
| Mechanism | What it constrains | Unit | What happens at the limit | Where it lives |
|---|---|---|---|---|
| Concurrency limiting | How many operations are in flight simultaneously | In flight count | New work waits for a slot | Usually in process, a semaphore or pool |
| Rate limiting | How much work starts per unit of time | Units per second | New work waits for a token, or is rejected | Should be in a shared store |
| Backpressure | Nothing directly. It signals upstream that downstream cannot keep up | A signal, not a count | Producer slows, pauses, or sheds | The boundary between producer and queue |
A useful way to keep them apart: concurrency limiting protects you from running out of sockets and memory. Rate limiting protects the thing you are calling. Backpressure protects the end to end latency of the work already accepted. You need all three, and having one does not give you the others.
The most common production configuration is to have exactly one of them, usually concurrency, and to believe you have all three.
Concurrency Is Not Rate

This is the single most common conflation, and it is worth being precise because the arithmetic is unforgiving.
Little’s Law, applied to an outbound request pipeline, gives throughput as concurrency divided by latency:
throughput (requests/sec) = concurrency / average_latency_seconds
Run a semaphore of 10 against an endpoint responding in 50 ms and you produce roughly 200 requests per second. Run the same semaphore of 10 on a day when the endpoint is responding in 500 ms and you produce roughly 20. Your code did not change. Your configuration did not change. Your offered load moved by a factor of ten.
Now consider which direction that moves in during an incident. When the gateway is under strain, latency rises. Rising latency reduces the throughput a concurrency limiter permits, which is accidentally helpful, and it is the reason a semaphore feels like it works. But the converse is the dangerous case: when latency drops, perhaps because the provider improved routing or because you moved your workers into a closer region, your offered load rises with no change on your side and no warning. Teams discover this as a mysterious rate limit rejection wave following an infrastructure improvement.
The Python integration guide uses an asyncio.Semaphore and the Node.js guide uses p-limit. Both are correct for what they are for: bounding fan out so you do not open ten thousand sockets at once and exhaust file descriptors. Neither is a rate limiter, and neither article claims otherwise. Keep them. Add a rate limiter alongside.
The correct configuration is both, composed:
concurrency limit -> bounds resource usage, protects you
rate limiter -> bounds offered load, protects the gateway
Acquire the rate token first, then the concurrency slot. Doing it the other way round means requests sit holding a connection slot while they wait for a token, which converts your concurrency pool into a queue and defeats its purpose.
You Are Limiting the Wrong Unit
Even a correct, distributed, atomic rate limiter is useless if it counts the wrong thing. Generic rate limiters count requests, because for a typical API a request is a reasonable proxy for work. In messaging it is a terrible proxy.
Three multipliers sit between an HTTP request and the load actually landing on the network.
Recipients per request. The mobile parameter on the send endpoint accepts a comma separated list, and the JSON body form accepts an sms array of {mobile: [...], msg: "..."} objects. One HTTP request can therefore carry thousands of recipients. A limiter of “50 requests per second” permits anything from 50 messages per second to several hundred thousand, depending entirely on how your batching happens to be tuned that week.
Segments per message. A message is billed and transmitted in segments. From the text messages chart, a GSM-7 body fits 160 characters in one segment and 153 per segment once concatenated; a Unicode body fits 70, and 67 concatenated. So a 200 character English message is 2 segments and a 200 character Hindi message is 3. Adding a single curly apostrophe to an otherwise plain English template flips the whole body to Unicode and can take a 1 segment message to 3. That is a 3x change in real load from a copy edit, and it is the same trap described at length in DLT Template Rejected: Every Reason and How to Fix It.
Retries. Every retry is a fresh submission consuming fresh capacity and fresh credits. A limiter that only counts first attempts understates load by exactly your retry rate.
Put together, the cost of a single send call is:
cost = recipients x segments_per_message (+ retries, counted as their own calls)
Weight your token acquisition by that number and the limiter suddenly means something stable. Weight it by 1 per request and it means nothing.
The pleasant part is that you already have a segment counter, because you need one for cost estimation. If you want an authoritative value rather than a local calculation, the Get SMS Length and Cost endpoint returns encoding, length, remaining and credit for a given body:
POST https://unify.smsgateway.center/SMSApi/info/msg
{
"response": {
"api": "info",
"action": "msg",
"status": "success",
"msg": "success",
"code": "200",
"count": 1,
"msgList": { "encoding": "TEXT", "length": 11, "remaining": 149, "credit": 1 }
}
}
Do not call this on the send path. It is a network round trip that would double your request rate to save you an arithmetic operation, which is precisely backwards. Call it at template save time, cache credit against the template ID, and use the cached value as the token weight. Recompute locally at render time only when variable substitution could push the body over a boundary, which is exactly the immutability problem covered in the template article above.
Here is a code point correct local counter. The subtlety worth noting is that ten characters in GSM-7 live in the extension table and occupy two units each, and that a naive split('') or index loop miscounts astral plane characters such as emoji.
GSM7_BASIC = (
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?"
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà"
)
GSM7_EXTENDED = set("^{}\\[~]|€") # each costs 2 units
def segment_cost(body: str) -> tuple[str, int, int]:
"""Return (encoding, units, segments) for a message body."""
chars = list(body) # code point correct, unlike split('')
if all(c in GSM7_BASIC or c in GSM7_EXTENDED for c in chars):
units = sum(2 if c in GSM7_EXTENDED else 1 for c in chars)
single, multi = 160, 153
encoding = "TEXT"
else:
# UTF-16 code units: astral characters count as 2
units = len(body.encode("utf-16-be")) // 2
single, multi = 70, 67
encoding = "UNICODE"
if units <= single:
return encoding, units, 1
return encoding, units, -(-units // multi) # ceiling division
def token_cost(body: str, recipients: list[str]) -> int:
_, _, segments = segment_cost(body)
return segments * len(recipients)
Every Per-Process Limiter Is a Lie at Scale
Here is the table that catches the most teams. Every entry on the left looks like a rate or concurrency control. Every one of them is scoped to a single process, which means its effective value is silently multiplied by however many processes you are running.
| What you configured | Actual scope | Effective value at N workers | Notes |
|---|---|---|---|
asyncio.Semaphore(10) | One event loop | 10 x N concurrent | Concurrency, not rate, and per process |
p-limit(10) | One Node process | 10 x N concurrent | Same, and cluster mode multiplies again by core count |
Guzzle Pool concurrency | One PHP process | 10 x N concurrent | Per pool object, not per application |
Celery rate_limit="10/s" | One worker process | 10 x N per second | Documented as per worker; the most dangerous entry here because it is a rate limit and does read as global |
Semaphore in a JVM | One JVM | 10 x N | Same trap |
BullMQ queue limiter | The Redis backed queue | Genuinely global | The exception. Correct across workers |
| Redis token bucket | The Redis instance | Genuinely global | What you should build |
The Celery row deserves emphasis because it is a genuine footgun rather than a misuse. rate_limit is a real rate limiter with correct semantics, and it is scoped to the worker. Set it to 100/s, run four workers in staging and you observe 400 per second, which is close enough to your intended ceiling that nobody investigates. Autoscale to twenty workers under Black Friday load and you offer 2,000 per second at exactly the moment the downstream is least able to absorb it. The autoscaler, in other words, is wired directly to your rate limit.
This is the same structural failure as p-limit and asyncio.Semaphore, and it is why the fix has to live outside the process.
Choosing an Algorithm: Token Bucket, Leaky Bucket, Sliding Window
| Algorithm | Allows bursts | Memory per key | Smoothness | Right for messaging? |
|---|---|---|---|---|
| Fixed window counter | Yes, badly | 1 counter | Poor: permits 2x the limit across a window boundary | No. The boundary burst is exactly the spike that trips a gateway |
| Sliding window log | No | 1 entry per request | Excellent | No. Memory scales with traffic, and at campaign volumes that is prohibitive |
| Sliding window counter | Slightly | 2 counters | Good | Acceptable, but the weighted cost model fits awkwardly |
| Leaky bucket (queue) | No | Queue depth | Perfectly smooth | Only if you genuinely want to erase burstiness, for example a strict per second operator allowance |
| Token bucket | Yes, bounded | 2 values | Good, with controlled burst | Yes, the default. Handles legitimate campaign bursts, cheap, and naturally supports weighted costs |
Token bucket wins for messaging for a reason specific to the domain: your traffic is legitimately bursty, and the bursts are not abuse. A campaign fires, an inventory alert goes out to a segment, a payday triggers a wave of transaction alerts. A leaky bucket flattens all of that into a strict trickle, which is correct if the downstream truly cannot absorb any burst, and needlessly slow otherwise. A token bucket lets accumulated capacity be spent quickly and then enforces the average, which matches both how allowances are usually granted and how campaigns actually behave.
The weighted cost model is also natural in a token bucket and awkward everywhere else. Taking 47 tokens for a 47 unit send is a one line change. Expressing the same idea in a sliding window log means inserting 47 entries.
One refinement worth knowing: a two level bucket, where each process holds a small local bucket refilled in chunks from a shared distributed bucket, removes a Redis round trip from every send. It is the right optimisation at very high volume and the wrong one before you have measured, because it reintroduces a bounded amount of the per process error you just eliminated. Start with the shared bucket.
A Weighted Redis Token Bucket That Counts Segments
The read, refill and decrement must be one atomic operation. Doing it as GET, compute, SET in application code is a race that shows up as a rate limiter that mostly works and occasionally lets a burst straight through, which is close to the worst possible failure mode because it is hard to reproduce. A Lua script executes atomically on the Redis server, so use one.
-- token_bucket.lua
-- KEYS[1] = bucket key, e.g. "rl:sms:transactional"
-- ARGV[1] = capacity (max burst, in weighted units)
-- ARGV[2] = refill_rate (units per second)
-- ARGV[3] = now (unix seconds, float, supplied by the caller)
-- ARGV[4] = cost (weighted units this call wants)
-- ARGV[5] = ttl (seconds, for key expiry)
-- returns { allowed (1|0), tokens_remaining, retry_after_seconds }
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local ttl = tonumber(ARGV[5])
local state = redis.call('HMGET', KEYS[1], 'tokens', 'updated')
local tokens = tonumber(state[1])
local updated = tonumber(state[2])
if tokens == nil then
tokens = capacity
updated = now
end
-- refill for elapsed time, clamped at capacity
local elapsed = math.max(0, now - updated)
tokens = math.min(capacity, tokens + (elapsed * refill_rate))
local allowed = 0
local retry_after = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
else
retry_after = (cost - tokens) / refill_rate
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'updated', now)
redis.call('EXPIRE', KEYS[1], ttl)
return { allowed, tokens, retry_after }
Four details in that script matter more than they look.
now is supplied by the caller, not read from the server. This is deliberate and it is a tradeoff. Using redis.call('TIME') would make the script non deterministic, which historically caused replication problems; passing the client clock makes it deterministic but exposes you to clock skew across your fleet. In practice, with NTP synchronised hosts, skew is far smaller than a bucket window and this is the right call. If your hosts are not time synchronised, fix that before you build a distributed limiter, because clock skew will also break your retry windows and your reconciliation.
Capacity is separate from refill rate. Capacity is your permitted burst; refill rate is your sustained average. Setting capacity equal to one second of refill gives you a nearly leaky bucket. Setting it to sixty seconds of refill lets a campaign spend a minute of banked capacity immediately. Choose deliberately.
retry_after is returned, not just a boolean. This is what makes backpressure possible later. A limiter that only says no forces the caller to poll. A limiter that says “no, and there will be capacity in 1.8 seconds” lets the caller sleep precisely, or decide that 1.8 seconds exceeds this message’s deadline and shed it instead. Return it always.
Tokens are fractional. Do not round. Rounding down at every call systematically under grants and your effective rate drifts below your configured rate by an amount proportional to your call frequency.
Failure mode: what happens when Redis is unreachable
Decide this explicitly rather than discovering it. Fail open means a Redis outage removes your ceiling entirely and you flood the gateway at exactly the moment your infrastructure is already degraded. Fail closed means a Redis outage stops all messaging, including OTPs. Neither is acceptable as a blanket policy.
The workable answer is class dependent and mirrors the shed or buffer table below: fail closed for bulk and promotional traffic, where a pause costs nothing, and fail open for OTP and transactional traffic but with a conservative per process local limiter as the fallback. The local limiter is per process and therefore wrong, but a known wrong ceiling during a degraded window beats no ceiling. Set the fallback low, alarm loudly on entering fallback, and treat time spent in it as an incident.
Wiring the Limiter Into Python and Celery
A thin wrapper over the script. Register it once with register_script so Redis caches it and subsequent calls use EVALSHA.
import time
import redis
from dataclasses import dataclass
_SCRIPT = open("token_bucket.lua").read()
@dataclass(frozen=True)
class Decision:
allowed: bool
remaining: float
retry_after: float
class WeightedLimiter:
def __init__(self, client: redis.Redis, capacity: float, refill_rate: float):
self._client = client
self._script = client.register_script(_SCRIPT)
self._capacity = capacity
self._refill_rate = refill_rate
def acquire(self, key: str, cost: int = 1) -> Decision:
allowed, remaining, retry_after = self._script(
keys=[key],
args=[self._capacity, self._refill_rate, time.time(), cost, 3600],
)
return Decision(bool(allowed), float(remaining), float(retry_after))
Now the Celery task. Three things are load bearing here and each of them is a mistake people make.
from celery import Celery, Task
from celery.exceptions import Reject
import httpx
app = Celery("messaging", broker="redis://localhost:6379/0")
limiter = WeightedLimiter(redis.Redis(), capacity=600, refill_rate=100)
SEND_URL = "https://unify.smsgateway.center/SMSApi/send"
@app.task(
bind=True,
acks_late=True,
task_reject_on_worker_lost=True,
autoretry_for=(), # deliberately empty: see below
max_retries=5,
)
def send_sms(self: Task, business_key: str, mobiles: list[str], body: str,
senderid: str, dlt_template_id: str):
cost = token_cost(body, mobiles)
decision = limiter.acquire("rl:sms:transactional", cost=cost)
if not decision.allowed:
# Countdown, not sleep. Sleeping holds the worker slot hostage.
raise self.retry(countdown=max(decision.retry_after, 0.1))
with httpx.Client(timeout=httpx.Timeout(10.0, connect=5.0)) as client:
resp = client.post(SEND_URL, data={
"userid": USERID,
"password": PASSWORD,
"sendMethod": "quick",
"mobile": ",".join(mobiles),
"msg": body,
"senderid": senderid,
"msgType": "text",
"dltEntityId": DLT_ENTITY_ID,
"dltTemplateId": dlt_template_id,
"duplicatecheck": "true",
"output": "json",
})
return resp.json()
raise self.retry(countdown=...), never time.sleep(). Sleeping inside the task holds a worker slot for the duration. With a prefetch of one and a hundred throttled tasks, you have converted your worker pool into a very expensive sleep. Requeueing with a countdown releases the slot immediately. The retry_after the limiter returned is exactly the right countdown, which is the payoff for returning it.
autoretry_for=() is deliberate. Automatic retry on any exception is a duplicate message generator on a send path, because a read timeout does not tell you whether the message was submitted. Retry decisions on this path must be explicit and must be gated on an idempotency guard. This is covered in depth in Message Idempotency at Scale and in SMS API Retry Strategy, and the short version is that acks_late without an idempotency guard is itself a duplicate generator.
Do not set Celery’s own rate_limit. Having both means two limiters with different scopes fighting, and the per worker one will be the binding constraint in ways that depend on your autoscaler. Delete it and let the Redis bucket be the single source of truth.
For the async path, compose the limiter with the semaphore in the right order:
import asyncio
sem = asyncio.Semaphore(20) # resource bound, NOT a rate limit
async def send_one(client, batch):
cost = token_cost(batch.body, batch.mobiles)
while True:
decision = await asyncio.to_thread(limiter.acquire, "rl:sms:transactional", cost)
if decision.allowed:
break
if decision.retry_after > batch.deadline_remaining():
raise DeadlineExceeded(batch.business_key) # shed, do not buffer
await asyncio.sleep(decision.retry_after)
async with sem: # token first, slot second
return await client.post(SEND_URL, data=batch.as_form())
The deadline check inside the wait loop is the whole point. Without it, a throttled OTP waits indefinitely and is delivered late, which is the failure this article exists to prevent.
Wiring the Limiter Into Node.js and BullMQ
BullMQ is the one place in this article where the built in control is genuinely global, because the queue limiter is enforced in Redis rather than per process.
import { Queue, Worker, UnrecoverableError } from 'bullmq';
import IORedis from 'ioredis';
import { readFileSync } from 'node:fs';
const connection = new IORedis({ maxRetriesPerRequest: null });
// Queue-level limiter: Redis-backed, therefore correct across N workers.
// Counts JOBS, not segments, so it is a coarse ceiling only.
const smsQueue = new Queue('sms:transactional', { connection });
connection.defineCommand('tokenBucket', {
numberOfKeys: 1,
lua: readFileSync('./token_bucket.lua', 'utf8'),
});
async function acquire(key, cost, { capacity = 600, refillRate = 100 } = {}) {
const [allowed, remaining, retryAfter] = await connection.tokenBucket(
key, capacity, refillRate, Date.now() / 1000, cost, 3600,
);
return { allowed: allowed === 1, remaining: Number(remaining), retryAfter: Number(retryAfter) };
}
const worker = new Worker(
'sms:transactional',
async (job) => {
const { businessKey, mobiles, body, senderid, dltTemplateId, deadlineAt } = job.data;
const cost = tokenCost(body, mobiles);
const decision = await acquire('rl:sms:transactional', cost);
if (!decision.allowed) {
if (Date.now() + decision.retryAfter * 1000 > deadlineAt) {
// Past its deadline. Fail permanently rather than delivering it late.
throw new UnrecoverableError(`deadline exceeded for ${businessKey}`);
}
await job.moveToDelayed(Date.now() + decision.retryAfter * 1000, job.token);
return; // return, do not throw: this is not a failure
}
const res = await fetch('https://unify.smsgateway.center/SMSApi/send', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
userid: USERID, password: PASSWORD, sendMethod: 'quick',
mobile: mobiles.join(','), msg: body, senderid,
msgType: 'text', dltEntityId: DLT_ENTITY_ID,
dltTemplateId, duplicatecheck: 'true', output: 'json',
}),
signal: AbortSignal.timeout(10_000), // fetch in Node has NO default timeout
});
return res.json();
},
{
connection,
concurrency: 20, // resource bound
limiter: { max: 200, duration: 1000 }, // coarse global job-rate ceiling
},
);
Three Node specific notes.
Use both limiters, at different granularities. The BullMQ queue limiter caps jobs per second globally, which is a useful coarse ceiling and costs you nothing. The Lua bucket caps weighted units, which is the ceiling that actually corresponds to load. They are not redundant, they measure different things. Set the job limiter generously and let the weighted bucket bind.
moveToDelayed then return, do not throw. Throwing marks an attempt against the job and eventually exhausts retries, so a job unlucky enough to be throttled repeatedly gets permanently failed for a reason that is not a failure. Moving it to delayed and returning cleanly leaves the attempt count intact.
A blocked event loop stalls the limiter silently. If your worker does heavy synchronous work, for instance parsing a large recipient CSV in one pass, the loop blocks, the BullMQ lock can expire, and the job is redelivered to another worker without any crash to alert you. Stream large files and yield periodically. Same warning as in the Node.js integration tutorial.
Wiring the Limiter Into PHP and Laravel
Laravel ships Redis::throttle() and RateLimiter, both Redis backed and therefore genuinely global. What neither supports directly is a weighted cost, so the pattern is to acquire N times or to drop to the Lua script. For anything above small volumes, use the script.
<?php
namespace App\Messaging;
use Illuminate\Support\Facades\Redis;
final class WeightedLimiter
{
private const SCRIPT = <<<'LUA'
-- (same token_bucket.lua body as above)
LUA;
public function __construct(
private readonly float $capacity = 600,
private readonly float $refillRate = 100,
) {}
/** @return array{allowed: bool, remaining: float, retryAfter: float} */
public function acquire(string $key, int $cost = 1): array
{
[$allowed, $remaining, $retryAfter] = Redis::eval(
self::SCRIPT, 1, $key,
$this->capacity, $this->refillRate, microtime(true), $cost, 3600,
);
return [
'allowed' => (int) $allowed === 1,
'remaining' => (float) $remaining,
'retryAfter' => (float) $retryAfter,
];
}
}
And the job:
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use GuzzleHttp\Client;
use App\Messaging\WeightedLimiter;
class SendSms implements ShouldQueue
{
use Queueable, InteractsWithQueue;
public int $tries = 5;
public function __construct(
public string $businessKey,
public array $mobiles,
public string $body,
public string $senderId,
public string $dltTemplateId,
public int $deadlineAt,
) {}
public function handle(WeightedLimiter $limiter, Client $http): void
{
$cost = token_cost($this->body, $this->mobiles);
$decision = $limiter->acquire('rl:sms:transactional', $cost);
if (! $decision['allowed']) {
if (time() + $decision['retryAfter'] > $this->deadlineAt) {
$this->fail(new DeadlineExceeded($this->businessKey)); // shed
return;
}
// release() returns it to the queue without burning an attempt
// the way an exception would, and honours the limiter's own
// suggested wait rather than a fixed guess.
$this->release((int) ceil($decision['retryAfter']));
return;
}
$http->post('https://unify.smsgateway.center/SMSApi/send', [
'form_params' => [
'userid' => config('sms.userid'),
'password' => config('sms.password'),
'sendMethod' => 'quick',
'mobile' => implode(',', $this->mobiles),
'msg' => $this->body,
'senderid' => $this->senderId,
'msgType' => 'text',
'dltEntityId' => config('sms.dlt_entity_id'),
'dltTemplateId' => $this->dltTemplateId,
'duplicatecheck' => 'true',
'output' => 'json',
],
'http_errors' => false, // keep JSON error bodies readable
'timeout' => 10,
]);
}
}
The Laravel specific trap: ShouldBeUnique is not a rate limiter and it is not idempotency. It prevents two identical jobs being queued at once, which is a narrow deduplication property with the lifetime of the lock, not the lifetime of your data. Do not lean on it for either purpose. The PHP integration tutorial covers the surrounding client construction, including why http_errors should be disabled on this endpoint.
cURL and Manual Testing
There is no documented rate limit header on the send endpoint and no documented idempotency header, so client side pacing is entirely your responsibility. When testing pacing by hand, the parameter that matters is testMessage.
curl -X POST "https://unify.smsgateway.center/SMSApi/send" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "apiKey: YOUR_API_KEY" \
--data-urlencode "sendMethod=quick" \
--data-urlencode "mobile=919999999999,919999999998" \
--data-urlencode "msg=Your order has been shipped." \
--data-urlencode "senderid=YOURID" \
--data-urlencode "msgType=text" \
--data-urlencode "duplicatecheck=true" \
--data-urlencode "testMessage=true" \
--data-urlencode "output=json"
testMessage=true means the message is not delivered, which makes it the correct way to exercise a pacing change without spending credits or messaging real people. Verify your limiter’s behaviour under load with testMessage=true before you point it at production traffic.
Note duplicatecheck in that call. It is documented as “Enable to remove duplicate mobile numbers”, default true, and that is within request recipient deduplication. It removes repeated numbers inside the mobile list of a single call. It is not a cross request retry guard and it will not save you from a resubmitted batch. If you have been treating it as a duplicate suppression backstop, read the idempotency article.
A Postman collection is available at Download SMS Postman Collection, and the interactive tester at SMS Messaging API is useful for confirming parameter names before you wire them into a limiter’s cost function.
Batching Changes the Arithmetic
Because the send endpoint accepts a comma separated recipient list, batching is not merely an efficiency tweak, it is a lever that moves three different rates independently. This is where limiting the wrong unit produces the most spectacular errors.
| Strategy | HTTP requests/sec | Messages/sec | Segments/sec | Load on gateway |
|---|---|---|---|---|
| 1 recipient per request, 200 req/s | 200 | 200 | 200 (single segment) | Baseline |
| 500 recipients per request, 200 req/s | 200 | 100,000 | 100,000 | 500x baseline |
| 500 recipients per request, 1 req/s | 1 | 500 | 500 | 2.5x baseline |
| 500 recipients, 3-segment Unicode, 1 req/s | 1 | 500 | 1,500 | 7.5x baseline |
Row two is the one that ruins an afternoon. A team hits rate limit rejections, correctly concludes that they are making too many requests, batches aggressively to reduce request count, and increases the real load five hundredfold while the metric they were watching goes flat. The request rate graph looks fixed. Everything else is on fire.
Practical guidance:
- Batch to reduce overhead, then pace on weighted units. Batching is genuinely good: fewer TLS handshakes, fewer round trips, less per request auth work. Do it. Just never let request count be the thing you limit.
- Keep batches small enough to be a useful failure unit. A 10,000 recipient batch that returns an ambiguous timeout puts 10,000 messages into an unknown state at once. Batches in the low hundreds keep the blast radius of a single ambiguous outcome manageable. This is a reliability argument, not a performance one.
- Remember there is one
transactionIdper request, not per recipient. The documented success response carries a single top leveltransactionIdwith rejected numbers enumerated ininvalidMobile, so per recipient correlation has to come from the delivery report endpoint viamobileNo,msgIdanduuId. Larger batches make correlation harder, which is another reason not to maximise batch size. - Do not mix message classes in a batch. A batch is scheduled as a unit. Putting an OTP in the same call as promotional traffic gives the OTP the promotional traffic’s latency.
Backpressure Is a Signal, Not a Limit

Suppose you have done everything above correctly: a global, atomic, weighted token bucket, properly composed with a concurrency bound. Your offered load is now capped. Have you solved the problem?
No, and the reason is the most important idea in this article. A rate limiter does not reduce work. It regulates when work happens. If your producers generate 500 units per second and your limiter admits 100, the other 400 do not evaporate, they accumulate. Queue depth grows by 400 per second. After ten minutes the queue holds 240,000 units, and at a drain rate of 100 per second the message arriving now will be sent in forty minutes.
By Little’s Law again:
wait_time = queue_depth / drain_rate
You have not fixed an overload. You have converted a throughput problem into a latency problem, and for time sensitive messages latency past a threshold is indistinguishable from total failure. Worse, it is a silent failure: every message eventually reports success, every delivery receipt eventually arrives, no error rate moves, and the only observable symptom is that customers are complaining that OTPs are not arriving while your dashboard is green.
Backpressure is the missing piece. It is not a limit, it is a signal that propagates upstream telling the producer that the downstream cannot keep up, so that the producer can do something other than keep producing. Three escalating responses:
| Signal level | Trigger | Producer response | Use when |
|---|---|---|---|
| Slow | Queue depth above soft threshold | Producer paces itself, for instance an API endpoint adds latency or reduces its own read rate | Early warning, load is recoverable |
| Pause | Queue depth above hard threshold | Producer stops enqueueing new work entirely; existing work drains | Bulk and campaign traffic only |
| Shed | Oldest item age exceeds message class deadline | Producer rejects new work with a fast, explicit error | Time sensitive traffic, always |
The signal has to reach the actual producer to be useful. If your producer is a campaign scheduler reading from a database, backpressure means the scheduler stops reading. If it is an HTTP endpoint your product calls, backpressure means that endpoint returns a 503 with a Retry-After so the calling service can decide. If it is a Kafka consumer, backpressure is simply not committing offsets, which is the cleanest form of it because the mechanism is built in.
The anti pattern is a producer that enqueues unconditionally into an unbounded queue. An unbounded queue is not a feature, it is a deferred outage: it guarantees that under sustained overload you will fail by timeout rather than by rejection, and timeout failures are slower to detect, harder to attribute and more expensive because you have already paid to store and process the work.
Bound your queues. A bounded queue turns “grow forever, fail later, mysteriously” into “reject now, explicitly, with a signal the producer can act on”. That is strictly better even though it feels worse, and it is the same reasoning the SRE literature on handling overload applies to serving systems.
Shed or Buffer: Policy by Message Class
Shedding is not a failure of design, it is a design decision, and it has to be made per message class because the cost of lateness varies by orders of magnitude across them.
| Message class | Deadline | Cost of being late | Cost of being dropped | Policy |
|---|---|---|---|---|
| OTP / login code | 30 to 60 seconds | Very high. User has already tapped resend, generating a second charge and a second code that may invalidate the first | High, but visible and actionable: the user sees an error and retries deliberately | Shed. Fail fast with an explicit error the UI can render |
| Transaction alert | Minutes | Moderate. Value decays but does not vanish | Moderate | Buffer with a deadline, shed past it |
| Appointment reminder | Hours, but anchored to an event | Low until the event passes, then total | Low | Buffer, shed if past the event time |
| Delivery / shipping update | Hours | Low | Low | Buffer |
| Promotional campaign | Regulatory window only (10:00 to 21:00 for promotional traffic) | None within the window | Low, it can go tomorrow | Buffer, shed only at window close |
| WhatsApp session reply | 24 hour session window | Total once the window closes: the send silently becomes ineligible | Low | Buffer, hard shed at window expiry |
Two rows deserve elaboration.
OTP is the inversion. For every other class, a duplicate is worse than a delay. For OTP it is the reverse: a late OTP reliably produces a duplicate, because the user resends. So the correct response to being rate limited on an OTP is not to queue it, it is to return an error to the calling application immediately so the UI can say “we could not send your code, try again” rather than showing a spinner while a code arrives four minutes later to a user who has already given up. Shedding an OTP is honest. Buffering one is a lie that costs you two credits and a support ticket. Enforce the retryAfter value returned by the OTP generate endpoint server side too, so a user hammering resend does not become your own load problem, which is also your primary defence against SMS pumping.
The WhatsApp session row is a silent one. A queued session message whose 24 hour window closes while it waits does not fail loudly, it becomes a message that requires a template and no longer qualifies as a session message. Your queue delay has changed the message’s eligibility. Attach the window expiry as a hard deadline at enqueue time. The mechanics of the session window are covered in SMS API vs WhatsApp Business API.
Implement the deadline as an absolute timestamp attached at enqueue time, never as a relative TTL evaluated at dequeue time. A relative TTL restarts its clock on every requeue, which means a message that is retried five times can outlive its deadline by five times over while every individual check passes.
Priority Lanes and Reserved Capacity
If OTPs and a five million recipient campaign share a queue, the campaign wins, because it got there first. Priority within a single queue is not enough either: most priority queue implementations are best effort, and a worker already holding a large batch will finish it before looking at anything else.
The structure that works is separate queues with separate limiters and reserved capacity:
total account capacity
|
+---------------------+---------------------+
| | |
OTP lane transactional promotional
reserved 20% reserved 30% best effort 50%
own bucket own bucket own bucket
own workers own workers own workers
shed on deadline buffer w/ deadline buffer to window close
The critical property is reserved, not shared. If the OTP lane’s capacity is drawn from a common pool, the campaign will consume it. Give the OTP lane its own bucket key with its own refill rate, sized so that it is essentially never the binding constraint, and let the promotional lane fight for what remains.
Separate workers matter as much as separate buckets. Shared workers mean an OTP can be stuck behind a promotional job that is currently sleeping on a throttle, even though the OTP lane has tokens available. Dedicated worker pools per lane cost you a little idle capacity and buy you isolation. That is a good trade.
Sizing the reservation is empirical. Take your peak OTP rate over the last ninety days, multiply by a comfortable factor, and reserve that. OTP volume is driven by logins and signups, which are far more predictable than campaign volume, so this number is stable and worth measuring rather than guessing.
Reacting to Rejection Without Causing a Storm
Client side limiting is proactive. Handling rejection is the safety net for when your proactive limit is wrong, which it will periodically be. Both are needed, and the mistake is treating the safety net as the strategy.
Why reactive only handling is insufficient:
It produces a sawtooth. Send until rejected, back off, ramp up, get rejected. You spend a permanent fraction of your traffic on rejected requests and your effective throughput oscillates around the limit rather than sitting under it.
It synchronises your fleet. This is the serious one. Twenty workers get rejected within the same few hundred milliseconds. Every one backs off two seconds. Every one retries at the same moment. The next rejection wave is more synchronised than the first, and the amplitude grows. The AWS Architecture Blog post on exponential backoff and jitter is the definitive treatment: backoff alone does not fix this, and the fix is randomisation.
Use full jitter. Not “base delay plus a small random amount”, which barely decorrelates anything, but a delay drawn uniformly from zero to the full backoff ceiling:
import random
def full_jitter(attempt: int, base: float = 0.5, cap: float = 30.0) -> float:
"""Delay drawn uniformly from [0, min(cap, base * 2**attempt)]."""
ceiling = min(cap, base * (2 ** attempt))
return random.uniform(0, ceiling)
Only retry when the rejection is definite. This is where rate limiting and idempotency intersect and where the reasoning gets subtle. A rate limit rejection returned by your own local limiter is definite: nothing was submitted, nothing was billed, retrying is free and safe. A rejection returned by the gateway with an explicit error code is also definite. A read timeout is not a rejection at all, it is an unknown, and retrying it without an idempotency guard is how you double bill a campaign.
| Outcome | Was it submitted? | Safe to retry? |
|---|---|---|
| Local limiter denied the token | No, definitively | Yes, freely |
| Connect timeout | No, definitively | Yes, freely |
| Explicit rate limit error from the gateway | No, definitively | Yes, after jittered backoff |
| Read timeout after the request was written | Unknown | Only behind an idempotency guard |
| Connection reset mid response | Unknown | Only behind an idempotency guard |
| HTTP 5xx with a body | Unknown, depends on the error | Treat as unknown |
The API response error code list endpoint returns the authoritative code set, and it is worth pulling that list into your classifier at build time rather than hardcoding a handful of codes you happened to observe. The distinction between definite and ambiguous outcomes, and the state machine that handles it, is the subject of Message Idempotency at Scale.
Discovering Your Ceiling Instead of Guessing It
Your permitted throughput is a function of your account configuration, your route, and operator level constraints. It is not a constant you can look up and hardcode, and pretending otherwise produces one of two bad outcomes: a number set too low, which throttles you for no reason and makes campaigns take all night, or a number set too high, which does nothing at all because the gateway becomes the binding constraint and you are back to reactive handling.
The better approach is to make the limiter’s refill rate a variable your system adjusts, using additive increase and multiplicative decrease. This is the same control loop that governs TCP congestion, and it works here for the same reason: it converges on the actual capacity without needing to know it in advance, and it backs off sharply enough to avoid sustained overload.
class AdaptiveRate:
"""AIMD controller for a token bucket refill rate.
Additive increase on sustained success, multiplicative decrease on
any throttle signal. Converges on the real ceiling without knowing it.
"""
def __init__(self, initial: float, floor: float, ceiling: float):
self.rate = initial
self.floor = floor
self.ceiling = ceiling
self._successes_since_change = 0
def on_success(self) -> None:
self._successes_since_change += 1
# Probe upward only after a sustained clean run, so that a single
# lucky request does not undo a decrease.
if self._successes_since_change >= 500:
self.rate = min(self.ceiling, self.rate + 1.0) # additive
self._successes_since_change = 0
def on_throttled(self) -> None:
self.rate = max(self.floor, self.rate * 0.75) # multiplicative
self._successes_since_change = 0
Practical notes on running this:
- Set a floor you are comfortable with. Without one, a burst of unrelated 5xx errors misclassified as throttling can walk your rate down to nearly zero and keep it there.
- Set a ceiling too. Not because you know the real limit, but because an unbounded probe upward will eventually find the limit the expensive way, during your busiest hour.
- Store the rate in Redis, not in process memory. Otherwise each worker discovers the ceiling independently, and the aggregate offered load is N times whatever any single worker thinks it has learned. This is the per process trap wearing a different hat.
- Only feed genuine throttle signals into
on_throttled. A DLT template mismatch, status code 188, is not a capacity signal. Misclassifying content errors as capacity errors makes your rate track your template quality, which is nonsense. - Log every rate change. The rate curve over a week is one of the more useful capacity planning artefacts you will have, and it is the empirical answer to “what is our actual throughput” that no documentation will give you.
Inbound Backpressure: DLR Webhooks and smswebhookrate
Everything so far has been about outbound. Inbound has its own overload problem, and it is the one that surprises people, because a large campaign generates a delivery receipt storm arriving on your infrastructure with no pacing of your choosing.
Here the platform gives you an explicit control. The create webhook endpoint takes an smswebhookrate parameter, documented as “AT what TPS rate DLR should be forwarded to. Default is 10.”
curl -X POST "https://unify.smsgateway.center/SMSApi/webhook/create" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "apiKey: YOUR_API_KEY" \
--data-urlencode "smswebhook=https://your-domain.example/dlr" \
--data-urlencode "smswebhookrate=10" \
--data-urlencode "output=json"
{
"response": {
"api": "webhook",
"action": "create",
"status": "success",
"msg": "Webhook added successfully.",
"code": "200"
}
}
This is a rate limiter you configure on someone else’s sender, which is unusual and useful. The temptation is to lower it when your receiver struggles. Resist that, because lowering the push rate does not reduce the number of delivery receipts, it just means they arrive later, and a receipt backlog behaves exactly like the outbound queue backlog described earlier: your reconciliation runs on stale data, your dashboards lag, and a delivery incident takes longer to detect.
The right move is to make the receiver fast enough that the rate is not the constraint. A DLR receiver should do the absolute minimum:
- Read the raw body. Do not let a JSON body parser touch it, because if the payload is form encoded a JSON parser will silently produce nothing. Read raw and parse deliberately.
- Verify any shared secret using a constant time comparison.
- Append to durable storage or a queue.
- Return 200 immediately.
Everything else, parsing, matching, cost attribution, business logic, happens asynchronously off that queue. A receiver built this way handles far more than 10 TPS on modest hardware, and it has the additional property that a bug in your parsing logic does not cause receipt loss, because the raw payload is already persisted. Persist before you parse. The setup walkthrough is in the DLR webhook knowledge base entry and the broader design in Real-Time DLR Webhooks.
Polling is the backpressure friendly alternative. The delivery report endpoint uses fromdate, todate, pageLimit and a startCursor, which means the consumer controls the pull rate completely. That is textbook backpressure: you cannot be overwhelmed by data you did not ask for. The cost is latency, because your polling interval becomes your floor on how quickly you learn about a delivery. The sensible architecture uses both: webhooks as the primary low latency path, and a periodic cursor based poll as a reconciliation sweep that catches anything the webhook path dropped. Since polling is a read, transport level retries against it are safe in a way they are never safe against the send path.
Where Rate Limiting Meets Idempotency
These two systems touch in three specific places, and each one is a source of subtle bugs.
Shedding must not corrupt the claim state. If you use a claim before send pattern, a message that is shed under backpressure has to move to a state your reconciliation understands. It was claimed, and it was definitively not submitted. That is a different transition from an ambiguous send, which must go to unknown and never back to pending. A shed message can safely go back to pending or to a terminal abandoned state, precisely because the non submission is definite. Getting this wrong in the conservative direction, marking shed messages unknown, floods your reconciliation queue with work that has a known answer. Getting it wrong in the permissive direction, marking ambiguous sends pending, produces duplicates.
Throttled retries must be keyed. A message that has been requeued four times due to throttling has been through your send path four times. If any of those attempts got far enough to submit before the limiter check, you have a duplicate. Order matters: acquire the token before you claim, and claim before you call. Then a throttled message never reaches the claim, and a claimed message never gets throttled.
Batch shedding is all or nothing. A batch is one HTTP request, so you cannot shed half of it. If a 500 recipient batch cannot get 500 units of capacity before its deadline, you shed the whole batch and all 500 claims return to a definite non submitted state together. This argues, again, for smaller batches, since it makes the shedding decision finer grained.
The state machine, the claim table schema and the reconciliation sweep are all in Message Idempotency at Scale. Build that first. A rate limiter bolted onto a pipeline with no idempotency guard makes the duplicate problem worse, not better, because throttling introduces requeues and requeues are precisely how duplicates happen.
Observability: Four Metrics That Actually Predict an Incident
Most messaging dashboards show sent count, delivery rate and error rate. All three are lagging indicators. By the time your delivery rate moves, the incident has already happened and the messages are already late.
These four are leading indicators.
| Metric | What it is | Why it leads | Alert on |
|---|---|---|---|
| Offered rate | Units per second your producers want to send | Rises before anything breaks | Sustained above admitted rate |
| Admitted rate | Units per second the limiter actually permits | The gap to offered rate is your accumulating backlog | Divergence from offered rate |
| Queue depth | Units waiting | Necessary but insufficient on its own | Above a bound, but see below |
| Oldest item age | Wall clock age of the head of the queue | The single best predictor. Directly comparable to your deadlines | Above the deadline for that queue’s message class |
Oldest item age is the metric to put on the wall. Queue depth alone is misleading in both directions: a queue holding 200,000 units draining at 5,000 per second is completely healthy, forty seconds of work, while a queue holding 400 units draining at 1 per second is a live incident with a head that is six minutes old. Depth without drain rate tells you nothing. Age folds both into one number, in the same unit as your deadline, so the alert threshold writes itself: alarm when the oldest OTP is older than your OTP deadline.
Two more worth having:
Token wait time distribution, specifically p50 and p99 of how long a send waited for a token. If p99 is climbing while p50 is flat, you have a burstiness problem rather than a capacity problem, and the fix is a larger bucket capacity rather than a higher refill rate. That distinction is invisible in any aggregate.
Shed count by class and by reason. Shedding is correct behaviour, so it should not page anyone, but it must be counted. A rising OTP shed rate is the clearest possible signal that your reserved capacity is undersized. If this number is always zero, either you have plenty of headroom or, more likely, you are not actually shedding and are silently buffering instead.
Cross reference cost from the delivery report endpoint, which returns cost, msgType, dltTemplateId and submitTime per record, so you can group spend by template and confirm that your token weights match reality. If your weighted units and your actual credits diverge, your segment counter is wrong, and the reconciliation approach in Bulk SMS Pricing in India is the way to find out where.
How to Add Rate Limiting to an Existing Send Pipeline
Ten steps, in dependency order. Do not skip step one.
- Instrument before you limit. Add offered rate, admitted rate, queue depth and oldest item age. Run for a week. You cannot size a limiter for traffic you have not measured, and you may find your real problem is burstiness rather than volume.
- Write the segment and recipient cost function. Use the local counter above. Validate it against the info/msg endpoint over a sample of your real templates, and fix any disagreement before proceeding, because every later number depends on this one.
- Deploy the Lua token bucket in shadow mode. Call
acquire, record the decision, and ignore it. Send anyway. This tells you exactly what fraction of traffic a given rate would have throttled, at zero risk. - Size capacity and refill from the shadow data. Refill rate to your observed sustained peak plus headroom; capacity to somewhere between ten and sixty seconds of refill, depending on how bursty your traffic actually is.
- Split the queues by message class. At minimum OTP, transactional and promotional. Separate queues, separate bucket keys, separate worker pools. Do this before enforcing, or your first enforcement event will delay OTPs.
- Attach absolute deadlines at enqueue time. An absolute timestamp per message, derived from its class. Never a relative TTL.
- Turn on enforcement for the lowest priority lane first. Promotional only. Watch shed count and oldest item age for a full campaign cycle.
- Add backpressure at the producer. Bound the queues. Have producers check depth or age before enqueueing and slow, pause, or shed accordingly. This is the step most often skipped, and skipping it means step seven only relocated the problem.
- Add jittered backoff for gateway rejections, using full jitter, and classify definite versus ambiguous outcomes before any retry. Verify against your idempotency guard.
- Switch the refill rate to the AIMD controller and let it find the real ceiling. Keep the floor and the ceiling. Log every change. Review the rate curve monthly as your capacity planning input.
Verify the whole path with testMessage=true before pointing it at production traffic.
Decision Matrix
| Your situation | Concurrency limit | Rate limiter | Backpressure | Shed policy |
|---|---|---|---|---|
| Single process, low volume, OTP only | Semaphore of 5 to 10 | Local token bucket is acceptable | Not needed | Shed on any throttle |
| Single process, nightly bulk campaign | Semaphore of 20 | Local token bucket | Bounded queue | Buffer to window close |
| Multi worker, mixed traffic | Per process semaphore | Redis token bucket, weighted | Required, per lane | Per class table above |
| Autoscaled workers | Per process semaphore | Redis, mandatory. Never Celery rate_limit | Required | Per class |
| Serverless / lambda per message | Not applicable, no shared process | Redis, mandatory | Producer side only, the queue is your buffer | Per class |
| BullMQ, single queue | concurrency option | Queue limiter plus weighted Lua bucket | moveToDelayed with deadline check | UnrecoverableError past deadline |
| Celery, autoscaled | Prefetch of 1 | Redis bucket, rate_limit unset | self.retry(countdown=...) | Ignore() past deadline |
| High volume, latency sensitive | Per process semaphore | Two level: local bucket refilled from Redis | Required | Reserved OTP lane |
| SMPP bind | Window size | Bind level, see the SMPP guide | Window is the natural backpressure | Not applicable |
For the last row, throughput on an SMPP bind is governed by window size, round trip latency and the account level allowance, and the correct response to ESME_RTHROTTLED is jittered backoff rather than immediate retry. That is a different mechanism from everything above and it is covered properly in SMPP Rate Limits and Throughput Tuning.
Production Checklist
Measurement
- [ ] Offered rate, admitted rate, queue depth and oldest item age are all instrumented
- [ ] Alerting is on oldest item age against a per class deadline, not on queue depth alone
- [ ] Token wait time p50 and p99 are recorded separately
- [ ] Shed count is broken out by message class and by reason
- [ ] Weighted units sent are reconciled monthly against actual credits consumed
Cost function
- [ ] Segment counter is code point correct and handles the GSM-7 extension table
- [ ] Token cost is
recipients x segments, not 1 per request - [ ] Counter has been validated against
SMSApi/info/msgon real templates - [ ] Credit weight is cached per template rather than computed on the send path
Limiter
- [ ] Bucket state lives in a shared store, not in process memory
- [ ] Refill, check and decrement happen in one atomic Lua script
- [ ] Capacity and refill rate are configured separately and deliberately
- [ ]
retry_afteris returned to the caller and actually used - [ ] Tokens are fractional, not rounded
- [ ] Redis unavailability behaviour is decided per class and alarms on entry
- [ ] Celery
rate_limitis unset; the shared bucket is the only rate authority - [ ] Concurrency slot is acquired after the rate token, never before
Backpressure
- [ ] Every queue is bounded
- [ ] Producers observe a depth or age signal before enqueueing
- [ ] Deadlines are absolute timestamps set at enqueue, never relative TTLs
- [ ] Shed decisions are made against the deadline, not against a retry count
- [ ] Message classes have separate queues, separate buckets and separate workers
- [ ] The OTP lane has reserved, not shared, capacity
Retries and correctness
- [ ] Full jitter, not fixed backoff, not backoff plus a small random amount
- [ ] Outcomes are classified as definite or ambiguous before any retry
- [ ] Ambiguous outcomes are retried only behind an idempotency guard
- [ ] Shed messages transition to a definite non submitted state, not to
unknown - [ ] Content errors such as status code 188 do not feed the adaptive rate controller
- [ ] Throttled requeues cannot reach the send call without passing the claim
Inbound
- [ ]
smswebhookrateis set deliberately, not left at the default by accident - [ ] The DLR receiver reads the raw body and does not rely on a JSON body parser
- [ ] The receiver persists before parsing and returns 200 immediately
- [ ] A cursor based reconciliation poll runs as a backstop to the webhook path
Ten Mistakes
- Treating a semaphore as a rate limiter. It bounds concurrency. Your actual rate is concurrency divided by latency, so it moves whenever latency moves, in the direction you least want.
- Counting requests instead of weighted units. One request can be ten thousand recipients at three segments each. A request rate graph can be perfectly flat while real load moves by four orders of magnitude.
- Using Celery’s
rate_limit. It is per worker. Your autoscaler is now wired directly to your rate limit. - Reading, computing and writing bucket state in application code. The race is real and it manifests as a limiter that mostly works, which is the hardest kind of bug to catch.
time.sleep()inside a worker task while throttled. You have converted your worker pool into an expensive sleep. Requeue with a countdown instead.- Unbounded queues. Not a feature. A deferred outage that converts explicit rejection, which is cheap and debuggable, into timeout failure, which is neither.
- One queue for OTP and campaigns. The campaign got there first and the OTP is behind five million messages.
- Buffering OTPs. A four minute old OTP has already caused a resend, a second charge and a support ticket. Shed it and tell the user honestly.
- Fixed backoff on rejection. Your fleet synchronises and each rejection wave is tighter than the last. Use full jitter.
- Relative TTLs as deadlines. The clock restarts on every requeue, so a message retried five times outlives its deadline fivefold while every individual check passes.
FAQs
Is a semaphore a rate limiter? No. A semaphore bounds how many operations run at once. Your throughput is concurrency divided by average latency, so a semaphore of 10 produces roughly 200 requests per second at 50 ms latency and roughly 20 at 500 ms. You need both a concurrency bound and a rate limiter, and you should acquire the rate token first.
What is the difference between rate limiting and backpressure? Rate limiting caps how much work starts per unit of time. Backpressure is a signal telling the producer that the downstream cannot keep up. A rate limiter without backpressure does not reduce load, it accumulates it in a queue and turns a throughput problem into an unbounded latency problem.
Token bucket or leaky bucket for SMS? Token bucket for almost all messaging. Campaign traffic is legitimately bursty, and a token bucket lets banked capacity be spent quickly while still enforcing the average. Leaky bucket only if you need perfectly smooth output, for example against a strict per second allowance.
What TPS can I send at over the HTTP API? No fixed numeric ceiling is published, because permitted throughput depends on your account configuration and route. Rather than guessing, run the AIMD controller described above, which converges on your actual ceiling and gives you a measured answer specific to your account. Contact support for a figure tied to your plan.
Does duplicatecheck protect me from double sending on a retry? No. It is documented as “Enable to remove duplicate mobile numbers”, default true, which is within request recipient deduplication. It removes repeated numbers inside a single call’s mobile list. It does nothing across requests, so a resubmitted batch will send again.
Why did batching cause more rate limit rejections rather than fewer? Because batching reduces your request rate while multiplying your message rate. Five hundred recipients per request at the same request rate is five hundred times the load. If you were limiting on requests, your metric went flat while real load exploded. Limit on weighted units.
Should I lower smswebhookrate if my receiver cannot keep up? Usually not. Lowering it delays delivery receipts rather than reducing them, so your reconciliation runs on stale data. Instead make the receiver trivially fast: read the raw body, verify the secret in constant time, persist, return 200, and do all processing asynchronously.
Where should the limiter live, in my app or in an API gateway? In your application, on the egress path, before the HTTP call. A gateway in front of your own service limits inbound traffic to you, which is a different problem. The load you need to control is the load you generate.
How do I rate limit across serverless functions with no shared process? A shared store is the only option, and this is the case where it is unambiguously mandatory. Every invocation calls the Lua script. Keep the Redis connection outside the handler so it is reused across warm invocations, and accept the added latency per call.
What happens if Redis goes down? Decide per message class in advance. Fail closed for bulk, where a pause is harmless. Fail open for OTP but with a conservative per process local limiter as the fallback, and alarm loudly on entering that state. A known wrong ceiling beats no ceiling.
Is BullMQ’s limiter enough on its own? It is genuinely global, unlike most in process limiters, which already puts it ahead. But it counts jobs, not weighted units, so it cannot see the difference between a one recipient job and a five hundred recipient job. Use it as a coarse ceiling and add a weighted bucket for the real constraint.
Should I retry a message that was rejected for rate limiting? Yes, if the rejection is definite: your own limiter denying a token, a connect timeout, or an explicit rate limit error from the gateway all mean nothing was submitted. No, not blindly, if the outcome was ambiguous such as a read timeout, because you do not know whether the message went out. Ambiguous outcomes require an idempotency guard.
How do I stop a promotional campaign from delaying OTPs? Separate queues, separate bucket keys, separate worker pools, and reserved rather than shared capacity for the OTP lane. Priority within a single queue is insufficient because a worker already holding a large batch finishes it first.
What is the one metric to alert on? Oldest item age per queue, compared against that queue’s message class deadline. Queue depth without drain rate tells you nothing, and delivery rate is a lagging indicator that only moves after the messages are already late.
Sizing a messaging pipeline? Your permitted throughput depends on your route, your account configuration and your traffic mix, which is why no single number applies to everyone. Talk to our team about capacity for your volume profile, or create an account and start measuring your own ceiling with
testMessage=truebefore you send anything real.