← AI + Backend
Core Backend Fundamentals

Rate Limiting & Throttling

Watch: "Rate Limiting system design | TOKEN BUCKET, Leaky Bucket, Sliding Logs" — Tech Dummies - Narendra Lakshmana Gowda

Rate limiting caps how much work one caller may ask of a system in a period of time, and throttling is the wider act of slowing, queueing or rejecting work to protect the service. Interviewers probe it because it is a small algorithm with large consequences: the choice of algorithm decides whether bursts are tolerated, the choice of key decides who is protected from whom, and the implementation decides whether the limit survives running on ten servers instead of one. For a Backend or AI engineer there is an extra twist. A model endpoint is limited by GPU time and money, and one request can cost a hundred times another, so counting requests alone is not enough; the meaningful unit is generally tokens, and often concurrent in-flight requests as well.

The networking view (where the limiter sits, the four algorithms in the abstract) is on Rate Limiting in the Computer Networks section. This page takes the backend and LLM angle: the boundary-burst flaw of fixed windows, making a distributed limiter atomic, per-tenant fairness, limiting by tokens, and the 429 protocol between server and client. It connects to the LLM API gateway design, multi-tenant serving and circuit breakers.

What Is Rate Limiting?

Rate limiting is a hard cap on how many times someone can use a service in a given stretch of time, like "100 requests per minute." Once a caller hits that number, the service starts refusing further requests until enough time has passed, the same way a ticket counter stops selling once every seat is sold.

What Is Throttling?

Throttling is the broader practice of deliberately slowing, delaying, or scaling back someone's usage instead of serving everything at full speed the instant it is asked for. Rate limiting (an outright cap) is one specific tool a system uses to throttle; other forms include making a request wait in line or reducing quality to keep the whole system stable.

Where This Is Used

A Day-to-Day Example

Picture a popular brunch spot with only one host at the door. The host does not let in more customers than the kitchen and tables can actually handle; once the dining room is full, new arrivals are told to wait outside or come back in twenty minutes. Rate limiting is the fixed rule ("no more than 40 people at once"), and throttling is the host's overall job of pacing entry — sometimes seating people right away, sometimes making them wait a beat — so the kitchen never gets so slammed that every order comes out wrong.

Deep Dive: The Algorithms and What Each One Costs

Rate-limiting algorithms at a glancetoken bucketrefill R/s ↓≤ B tokensburst ≤ Bleaky bucketqueue drains at fixed Rfull queue → rejectwindowsfixed: 1 counter2× burst at edgesliding log: exact, O(N)sliding counter:2 counters, ≈ exact
A token bucket allows a controlled burst then a steady rate; a leaky bucket smooths output through a queue; window counters trade memory for precision. The full comparison is below.
allowed / refillqueuedweakness
TOKEN BUCKET   bucket holds up to B tokens, refilled at R tokens/sec; a request takes a token.
               allows bursts up to B, then sustains R.  State: (tokens, last_refill_time).
LEAKY BUCKET   requests enter a queue that drains at a fixed rate R; a full queue rejects.
               output is smooth (good for protecting a fragile downstream); adds queueing delay.
FIXED WINDOW  count requests per calendar window (e.g. per minute); reject above N.
               State: one counter.  Flaw: up to 2N pass across a window boundary.
SLIDING LOG    keep a timestamp per request; count those in the last W seconds.
               exact, but O(N) memory per key.
SLIDING COUNTER  estimate = prev_window_count * (1 - fraction_elapsed) + current_window_count
               two counters per key, close to the log; an approximation that assumes even spread.

  algorithm         memory/key   burst behaviour              precision
  token bucket      O(1)         controlled burst (B)         good
  leaky bucket      O(queue)     none (smoothed)              good, adds latency
  fixed window      O(1)         2x burst at the boundary     poor at edges
  sliding log       O(N)         none                         exact
  sliding counter   O(1)         small error                  good enough

The token bucket is the usual production choice because it has two intuitive knobs (sustained rate and burst size), constant state, and it matches how real clients behave: idle for a while, then a burst. Two parameters matter more than the algorithm name. The refill rate is the contract you sell ("5 requests per second on average"), and the bucket size is how much unevenness you forgive. A leaky bucket differs in intent: it shapes traffic into a steady stream, which suits protecting something that cannot absorb bursts, while a token bucket permits bursts and is better for user-facing APIs. Both are often described loosely, so in an interview say which behaviour you mean rather than just the name.

Deep Dive: The Fixed-Window Boundary Burst

The fixed-window boundary burstwindow k (limit 100)window k+1 (limit 100)0 s60 s120 s100 @ 59.9 s100 @ 60.1 sfixed windows: each sees exactly 100 → both allowed → 200 in 0.2 s (2× the rate)sliding window at 60.1 s: last 60 s already holds 100 → second batch rejected
With a limit of 100 per minute, 100 requests just before the window resets and 100 just after are both allowed: 200 requests in 0.2 seconds. A sliding window looks at the last 60 seconds and rejects the second batch.
request burstswindow boundary
Show text version
limit = 100 requests per 60-second window

   window k                          window k+1
  |---------------------------------|---------------------------------|
  0s                              59.9s  60.1s                       120s
                                    ^^^^^^^^
            100 requests at 59.9s  +  100 requests at 60.1s
  Each window sees exactly 100  ->  both allowed.
  But 200 requests arrived within 0.2 s: twice the intended rate.

  sliding window: at 60.1 s the last 60 s already hold 100 requests -> the second batch is rejected.

A fixed window is attractive because it is one counter and an expiry, for example INCR key with EXPIRE in Redis, but the counter resets at a boundary that has nothing to do with the client's behaviour. A client that sends its full allowance at the end of one window and again at the start of the next gets up to 2N through in an instant, so the downstream must be able to absorb twice the advertised rate. Sliding windows fix the flaw by measuring the trailing W seconds instead of a calendar window. The exact log costs memory proportional to the limit, which is fine for 100 requests per minute and painful for a million per hour per key; the sliding counter approximates it with two integers at the cost of a small error, which is why it is popular at scale. The Try It Yourself section runs all three on this exact scenario.

Deep Dive: Distributed Limiting and Atomicity

Check-then-set race between two gateway nodesNode ARedisNode BGET countGET count9999both: 99 + 1 ≤ 100 → allowSET 100SET 100101 requests admitted, counter says 100 → use INCR or a Lua script
Two nodes read the same count, both decide there is room, and both write 100, so one request slips through. An atomic INCR (or a Lua script, or routing each key to one node) closes the race. The options are below.
node Anode B
            +--- gateway node A ---+
client ---> LB                       +--> shared counter store (e.g. Redis) --> allow / deny
            +--- gateway node B ---+

Naive check-then-set is a race:
  A: GET count -> 99            B: GET count -> 99
  A: 99 + 1 <= 100 -> allow    B: 99 + 1 <= 100 -> allow
  A: SET 100                    B: SET 100          ->  101 requests admitted, counter says 100

Atomic options:  single INCR (returns the new value) | Lua script (read, compute, write as one unit)
                 | per-key single-writer routing (consistent hashing to one node)

Latency/accuracy trade-off:
  strict global counter : one Redis round trip per request (~sub-ms to few ms in-region)
  local approximation   : each node gets a slice of the budget, syncs periodically -> cheaper, less exact

With one process, a limiter is a dictionary. With many, the state must be shared or the effective limit becomes the per-node limit times the node count, which changes as you autoscale. Redis is the usual shared store because each command, and each Lua script, executes atomically, so an increment-and-compare cannot interleave with another node's. The check itself must be one indivisible step; reading a value, deciding in application code, then writing it back is exactly the race shown above. A token bucket needs two fields (tokens and last-refill time), which is why it is normally implemented as a Lua script rather than as several commands. Time is the second trap: computing "now" on each node makes clock skew part of your limit, so take time from the store or accept the error.

At high scale the limiter becomes a dependency of every request, so its failure mode is a design decision. Fail open (allow traffic when the limiter is down) keeps the service available but removes protection exactly when load may be high; fail closed protects the backend but turns a Redis outage into a total outage. Most public APIs fail open with an in-process fallback limit. To cut the per-request hop, nodes can lease a slice of tokens from the shared store (for example 100 at a time) and spend them locally, accepting a bounded overshoot. The exact scripts depend on your Redis version and deployment (single node versus Cluster, where all keys touched by one script must live in one slot), so treat any snippet as a pattern to adapt.

Deep Dive: Per-Tenant Limits and Limiting LLM Traffic by Tokens

Why LLM APIs limit tokens, not just requestssame 1 request/s, very different load (illustrative)client A: 500 tok/req30,000 tokens/minclient B: 20k tok/req1,200,000 tokens/min (40×)TPM admission:1. reserveinput estimate + max_tokens2. admitif the bucket has enough3. refundunused part, after reply
Two clients at the same request rate can differ 40× in real load, so LLM APIs also limit tokens per minute. Output length isn't known up front, so the limiter reserves the maximum and refunds the unused part afterwards. The key choices are below.
small promptslarge prompts
Key choice decides who is protected from whom:
  per IP        cheap, but shared NATs punish many users; easy to evade
  per API key   the usual public-API unit
  per tenant    fairness between customers; add per-user limits inside a tenant
  per endpoint  expensive routes (generation) get stricter limits than cheap ones
  global        last line of defence for the backend itself

LLM cost is not proportional to request count (ILLUSTRATIVE, same 1 request/s):
  client A: 500 tokens/request     ->  30,000 tokens/min
  client B: 20,000 tokens/request  -> 1,200,000 tokens/min      (40x the load)

  a request limit alone treats A and B as equal.  A tokens-per-minute (TPM) limit does not.

Problem: output length is unknown until generation ends.
  1. estimate input tokens (tokenizer) + reserve max_tokens (or an expected output size)
  2. admit if the bucket has that many tokens
  3. after the response, refund the unused part (actual vs reserved)

Fairness needs the right key and the right unit. Limiting per tenant stops one noisy customer from starving the rest, but a flat per-tenant number ignores plan tiers, so real systems attach a quota per plan and often a smaller per-user limit within it. For LLM APIs, request-per-minute (RPM) and tokens-per-minute (TPM) limits are commonly enforced together, and providers typically expose both, though the names, tiers and numbers differ by provider and change often, so read the current docs. The reservation approach above is the standard way to handle unknown output length: charging only what is known would let a request with a huge max_tokens slip through, and charging the worst case forever would waste capacity, hence reserve then refund.

A third control is concurrency: the number of in-flight requests per key. Because decode runs for seconds, by Little's Law the concurrent load is arrival rate times average duration. With an ILLUSTRATIVE 20 requests per second and an 8-second average generation, about 160 requests are in flight at once, and that number, not the request rate, is what fills GPU batch slots and KV-cache memory (see LLM Inference Fundamentals). A concurrency cap per tenant therefore protects a shared model server better than an RPM cap on its own. Beyond rejecting, the alternatives are queueing with a deadline, priority classes (interactive ahead of batch), and shedding the lowest-priority work first.

Deep Dive: 429, Retry-After and the Client's Half of the Contract

Handling a 429ClientAPIPOST /generate429 Too Many Requests, Retry-After: 12wait 12 s (or full-jitter backoff if no header)POST /generate (same idempotency key)200 OK
A 429 means this caller is over its own limit (503 means the service is overloaded for everyone). The client waits for Retry-After or backs off with jitter, caps its retries, and only retries requests that are safe to repeat. The raw response is below.
rejectedback offaccepted
HTTP/1.1 429 Too Many Requests
Retry-After: 12                 <- seconds to wait (an HTTP date is also allowed)
Content-Type: application/json
{"error": {"type": "rate_limited", "message": "tokens-per-minute limit exceeded"}}

  429 = this caller is over ITS limit          -> back off, slow down
  503 = the service is overloaded (any caller) -> back off, maybe fail over

Client policy:  wait Retry-After if present, else exponential backoff with jitter:
  sleep = random(0, min(cap, base * 2^attempt))        (full jitter)
  and cap total retries + total time; retry only idempotent or idempotency-keyed requests

A limiter is only useful if clients react well, and the usual failure is a retry storm: 1,000 clients are rejected together, all retry after exactly one second, and the synchronised wave is rejected again. Jittered exponential backoff spreads the retries; honouring Retry-After avoids guessing. Some services also return rate-limit headers describing the limit, remaining budget and reset time; the header names are not universal (a standard is still evolving and vendors use their own), so document yours. Retrying also multiplies load, so clients should cap attempts and, for non-idempotent calls, use an idempotency key as described in Idempotency and Exactly-Once Semantics. On the server side, reject early, at the gateway, before parsing large bodies or touching a model, so a rejection costs microseconds instead of GPU time.

Key Pointers

How It Works

  1. The gateway authenticates the request and derives the limit keys: API key, tenant, endpoint, and any per-user key.
  2. It estimates the cost of the request (1 request plus estimated input tokens plus reserved output tokens for an LLM call).
  3. It runs one atomic check-and-consume against the shared store for each relevant limit (requests, tokens, concurrency); the request is admitted only if every check passes.
  4. If admitted, the request goes to the backend; when it completes, the limiter is told the actual token usage so unused reservation is refunded and the concurrency slot is released.
  5. If rejected, the gateway immediately returns 429 with Retry-After computed from how long until enough budget refills, plus limit headers, without touching the model.
  6. The client waits Retry-After or backs off exponentially with jitter, and metrics on rejections per key feed capacity planning and quota decisions.

Try It Yourself

# Python 3 (stdlib only): token bucket with an injected clock (deterministic)
class TokenBucket:
    def __init__(self, rate, capacity, now=0.0):
        self.rate, self.capacity = rate, capacity      # tokens/sec, max burst
        self.tokens, self.last = float(capacity), now
    def allow(self, now, cost=1):
        self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
        self.last = now
        if self.tokens >= cost:
            self.tokens -= cost
            return True, 0.0
        return False, (cost - self.tokens) / self.rate   # seconds until enough tokens: Retry-After

tb = TokenBucket(rate=5, capacity=10)                    # 5 req/s sustained, burst of 10
res = [tb.allow(0.0)[0] for _ in range(15)]              # 15 requests at t=0
print("burst of 15 at t=0 :", "".join("Y" if r else "N" for r in res), "-> allowed", sum(res))
ok, wait = tb.allow(0.0)
print("16th request       : allowed=%s retry_after=%.1fs" % (ok, wait))
res = [tb.allow(1.0)[0] for _ in range(8)]               # one second later: 5 tokens refilled
print("8 requests at t=1s :", "".join("Y" if r else "N" for r in res), "-> allowed", sum(res))
# Real output from python3:
# burst of 15 at t=0 : YYYYYYYYYYNNNNN -> allowed 10
# 16th request       : allowed=False retry_after=0.2s
# 8 requests at t=1s : YYYYYNNN -> allowed 5

# Python 3: fixed window vs sliding log vs sliding counter: the boundary burst
from collections import deque

class FixedWindow:
    def __init__(self, limit, window):
        self.limit, self.window, self.counts = limit, window, {}
    def allow(self, t):
        w = int(t // self.window)
        self.counts[w] = self.counts.get(w, 0) + 1
        return self.counts[w] <= self.limit

class SlidingLog:
    def __init__(self, limit, window):
        self.limit, self.window, self.log = limit, window, deque()
    def allow(self, t):
        while self.log and self.log[0] <= t - self.window:
            self.log.popleft()
        if len(self.log) < self.limit:
            self.log.append(t); return True
        return False

class SlidingCounter:                                    # weighted blend of previous and current window
    def __init__(self, limit, window):
        self.limit, self.window, self.counts = limit, window, {}
    def allow(self, t):
        w = int(t // self.window)
        frac = (t % self.window) / self.window
        est = self.counts.get(w - 1, 0) * (1 - frac) + self.counts.get(w, 0)
        if est + 1 > self.limit: return False
        self.counts[w] = self.counts.get(w, 0) + 1
        return True

LIMIT, WINDOW = 100, 60.0                                # 100 requests per minute
# client sends 100 requests at t=59.9s and 100 more at t=60.1s
times = [59.9] * 100 + [60.1] * 100
for name, cls in (("fixed window", FixedWindow), ("sliding log", SlidingLog), ("sliding counter", SlidingCounter)):
    lim = cls(LIMIT, WINDOW)
    allowed = sum(lim.allow(t) for t in times)
    print("%-15s allowed %3d of 200 within 0.2 s (limit is %d per %d s)" % (name, allowed, LIMIT, WINDOW))
# Real output from python3:
# fixed window    allowed 200 of 200 within 0.2 s (limit is 100 per 60 s)
# sliding log     allowed 100 of 200 within 0.2 s (limit is 100 per 60 s)
# sliding counter allowed 100 of 200 within 0.2 s (limit is 100 per 60 s)

# Python 3: limiting by tokens (LLM cost) instead of request count
class TokenBudget:
    """Tokens-per-minute budget as a token bucket whose 'cost' is the request's token count."""
    def __init__(self, tpm, now=0.0):
        self.rate, self.capacity = tpm / 60.0, float(tpm)
        self.tokens, self.last = float(tpm), now
    def try_consume(self, now, cost):
        self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
        self.last = now
        if cost <= self.tokens:
            self.tokens -= cost; return True
        return False

# One request per second for 60 s. Limit: 60,000 tokens/min. A 60 requests/min limit alone would allow all 60.
def simulate(tokens_per_req):
    budget, req_ok = TokenBudget(60_000), 0
    for sec in range(60):
        if budget.try_consume(float(sec), tokens_per_req):
            req_ok += 1
    return req_ok

print("60 requests in 60 s, budget 60,000 tokens/min:")
for size in (500, 1_000, 4_000, 20_000):
    print("  %6d tokens/request -> %2d allowed by token budget" % (size, simulate(size)))
# Real output from python3:
# 60 requests in 60 s, budget 60,000 tokens/min:
#      500 tokens/request -> 60 allowed by token budget
#     1000 tokens/request -> 60 allowed by token budget
#     4000 tokens/request -> 29 allowed by token budget
#    20000 tokens/request ->  5 allowed by token budget

# Python 3: why the counter update must be atomic (two nodes, limit 100, counter at 99)
# The interleaving is forced so the result is deterministic.
store = {"count": 99}
LIMIT = 100
a_seen = store["count"]                 # node A: GET -> 99
b_seen = store["count"]                 # node B: GET -> 99
a_ok = a_seen + 1 <= LIMIT
b_ok = b_seen + 1 <= LIMIT
if a_ok: store["count"] = a_seen + 1    # node A: SET 100
if b_ok: store["count"] = b_seen + 1    # node B: SET 100 (lost update)
print("non-atomic: A allowed=%s B allowed=%s final count=%d (limit %d)" % (a_ok, b_ok, store["count"], LIMIT))

import threading
store = {"count": 99}
lock = threading.Lock()
def incr_and_check():
    with lock:                           # stands in for Redis running one command or script at a time
        store["count"] += 1
        return store["count"] <= LIMIT
print("atomic    : A allowed=%s B allowed=%s final count=%d" % (incr_and_check(), incr_and_check(), store["count"]))
# Real output from python3 (the atomic version still increments on a rejected call, so 101):
# non-atomic: A allowed=True B allowed=True final count=100 (limit 100)
# atomic    : A allowed=True B allowed=False final count=101

# Redis Lua sketch of an atomic fixed-window check (NOT run here: needs a Redis server, so no output shown)
#   local n = redis.call('INCR', KEYS[1])
#   if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
#   if n > tonumber(ARGV[2]) then return 0 else return 1 end

Interview Q&A

Q: Token bucket versus leaky bucket: what is the practical difference?

A: A token bucket lets a client spend saved-up tokens in a burst up to the bucket size and then sustains the refill rate, so it forgives uneven but average-conforming traffic. A leaky bucket drains a queue at a fixed rate, so the output is smooth but requests can wait or be dropped when the queue is full. Choose token bucket for user-facing APIs where bursts are normal, and leaky bucket (or a queue with a fixed-rate consumer) when the downstream cannot tolerate bursts, accepting added latency.

Q: What is wrong with a fixed-window counter?

A: The window resets at a calendar boundary, so a client can send the full limit just before the reset and the full limit just after it, getting up to twice the limit through in a very short time. In my simulation a limit of 100 per minute admitted 200 requests in 0.2 seconds, while sliding log and sliding counter admitted 100. The advantage is that it is one counter and trivial to implement, so it is fine when the downstream tolerates the 2x boundary spike and wrong when it does not.

Q: How do you implement a rate limiter that works across many servers?

A: Keep the state in a shared store such as Redis and make the check-and-update atomic, using INCR for a counter or a Lua script for a token bucket, because a separate read then write lets two nodes both see 99 and both admit the 100th request. The costs are a network hop per request and a new dependency, so I would decide fail-open versus fail-closed up front and consider local token leases to reduce round trips. Without shared state the effective limit is the per-node limit multiplied by the node count and changes on autoscaling.

Q: Why is limiting an LLM API by request count not enough?

A: Cost and GPU load scale with tokens, and a single request can be tiny or enormous: in a simple example at one request per second, 500-token requests and 20,000-token requests differ by 40 times in load under the same request limit. So I would enforce a tokens-per-minute budget alongside requests-per-minute, plus a cap on concurrent requests since long generations hold GPU batch slots and KV-cache memory. Because output length is unknown up front, reserve the estimated or max_tokens amount at admission and refund the unused part afterwards.

Q: What should a rejected request look like, and what should the client do?

A: Return 429 Too Many Requests with a Retry-After value computed from when enough budget will exist, and a body that says which limit was hit. The client should wait for Retry-After or use exponential backoff with full jitter, cap retries, and only retry idempotent operations or ones carrying an idempotency key. Without jitter, all rejected clients retry in lockstep and the limiter keeps rejecting the same synchronised wave.

Q: Which key do you limit on: IP, API key, user or tenant?

A: It depends on the fairness goal. IP is cheap but unfair behind shared NATs and easy to rotate; API key or tenant is the usual unit for paid APIs so one customer cannot starve others; a per-user limit inside a tenant stops one user from consuming the whole tenant quota. I would layer them, with stricter limits on expensive endpoints such as generation and a global limit protecting the backend, and allow quota tiers per plan rather than one flat number.

Follow-up: Your Redis-backed limiter goes down during a traffic spike. What happens?

A: That is the fail-open versus fail-closed decision. Failing open keeps the API up but removes protection when load is highest, so I would fall back to a conservative in-process limiter per node; failing closed protects the model backend but converts a limiter outage into a full outage. Most public APIs choose fail open with a local fallback, and I would alert on limiter errors and test the failure mode explicitly.

Follow-up: How would you rate limit streaming responses, where tokens are produced over 30 seconds?

A: Admission control happens at the start using the reserved estimate, and a concurrency limit covers the time the stream is open, since the request holds resources for its whole duration. Actual token counts are reconciled when the stream ends or is cancelled, refunding the difference. If tenants can exceed budgets mid-stream, you can truncate at max_tokens, but you should not silently cut a response the tenant has paid to start unless the contract says so.

Follow-up: Can a sliding window counter ever admit more than the limit?

A: Yes, slightly. It assumes requests in the previous window were spread evenly, so if they were actually bunched at the end, the weighted estimate under-counts the trailing window and lets a bit extra through. The error is usually small and bounded, and the trade is two integers per key instead of a log of timestamps. If you need an exact guarantee, use the sliding log or a token bucket with a burst size you can defend.

System Design Tie-in

Rate limiting appears in almost every API design and is central to any LLM gateway: it sits at the edge or in the gateway so that rejected traffic costs microseconds, not GPU seconds. State the policy explicitly: keys (tenant, API key, user, endpoint), units (requests, tokens, concurrency), algorithm (token bucket for bursts), storage (Redis with atomic scripts, or local leases for scale), the failure mode, and the 429 and Retry-After contract. Do the arithmetic with stated assumptions. For example, assume 5,000 tenants on a shared model pool with 200 GPU-batch slots in total; giving each tenant a concurrency cap of 4 means a few noisy tenants cannot hold more than a small share, and a plan-based TPM limit bounds spend per tenant per minute. If the limiter does one Redis operation per request at 20,000 requests per second, that is 20,000 operations per second on the limiter store, which a single Redis instance can generally handle for simple commands but which you would still shard by key and monitor (measure it, do not assume). Tie it to autoscaling (limits keep autoscaling from chasing abusive load and running up cost), to cost optimization (per-tenant budgets), and to priority queues that shed batch work before interactive traffic.

Trade-offs

Rate limiting trades some legitimate traffic and some complexity for protection against overload, abuse and runaway cost. Strict limits reject honest bursts and generate support tickets; generous limits fail to protect; token buckets tolerate bursts but the burst size becomes a second knob to tune; sliding windows are smoother than fixed ones at the cost of more state or an approximation; strict global counters are accurate but add a network hop and a dependency, while local approximations are cheap but can overshoot. Token and concurrency limits are more faithful for LLMs but need token estimation and reconciliation, and estimation errors mean users are sometimes rejected or under-charged. A limiter is the wrong tool when the real problem is missing capacity (use autoscaling and queues), when overload comes from inside the system (use circuit breakers and backpressure), or when abuse needs identity controls rather than throttling. Measure rejections by key and reason, since a limit nobody monitors is either silently hurting customers or silently doing nothing.