LLM Failover & Rate Limiting
AI gateway routes requests to tens of LLM models. A model backend starts responding slowly - not failing, just 10x latency. What happens to your system, and what mechanisms should prevent cascade?
Failure
- requests pile up eventually overwhelm the backend CPU / MEM resources goes down
- if queue - backpressure starts building up
- users experience outage
- after down controller (flux) tries to bring up the backend
- backend is bombarded with incomming requests
- If no queue outage happen
Prevent
- introduce a retry + exponantial backoff + jitter + circut breakers
- retry budget in gateway api
- if possible shard the down server
- shed non-essential load if system is overwhelmed
Examples
Retry - bombard with retries
async def with_retry(request, tries=3):
i = 0
while i < tries:
try:
return await request()
except Exception as e:
i += 1
Backoff Retry - wait between retries
async def with_retry(request, tries=3):
...
await asyncio.sleep(3) # <-- wait 3 seconds and retry
i += 1
Exponential Backoff Retry - the more you fail the more you wait
async def with_retry(request, tries=3):
...
await asyncio.sleep(2**i) # <-- wait 1, 2, 4, 8, ... seconds and retry
i += 1
Exponential Backoff Retry + Jitter - if 100 users are retrying at the same time - add randomness to prevent spikes when everyone retries in same intervals
async def with_retry(request, tries=3):
...
await asyncio.sleep(2**i + random.randint(1, 5)) # <-- wait 3, 4, 10, 12, ... seconds and retry
i += 1
Exponential Backoff Retry + jitter + CIRCUT BREAKER - you don't just exhaust your tries and fail - you add a circut breaker
self.circuit_state = CIRCUT_STATE.CLOSED
self.circuit_wait_s = 10
self.circuit_opened_at = None
self.failure_count = 0
self.failure_threshold = 5
self.lock = asyncio.Lock()
async def with_retry(self, request, tries=3):
i = 0
async with self.lock: # <--- protect state against concurrency
# 2. if circuit open AND circuit still in cooldown - raise
if self.circuit_state == CIRCUT_STATE.OPEN:
if (time.perf_counter() - self.circuit_opened_at) < self.circuit_wait_s:
raise Exception("circuit")
else:
# 3. cooldown passed - allow call
self.circuit_state = CIRCUT_STATE.HALF_OPEN
elif self.circuit_state == CIRCUT_STATE.HALF_OPEN:
raise Exception("circuit is HALF_OPEN - waiting...")
while i < tries:
try:
response = await request()
async with self.lock: # <--- protect state against concurrency
# 5. SUCCESS - reset circuit
if self.circuit_state == CIRCUT_STATE.HALF_OPEN:
self.circuit_state = CIRCUT_STATE.CLOSED
self.failure_count = 0
return response
except Exception as e:
i += 1
if i == tries:
async with self.lock: # <--- protect state against concurrency
# 1. THIS request failed - check for others
if self.circuit_state == CIRCUT_STATE.CLOSED:
self.failure_count += 1
# 1. OTHER requests failed too - trip
if self.failure_count >= self.failure_threshold:
self.trip_circuit()
# 4. circuit waited and retried - still fails
elif self.circuit_state == CIRCUT_STATE.HALF_OPEN:
self.trip_circuit()
raise e
await asyncio.sleep(2**i + random.randint(1, 5))
def trip_circuit(self):
self.circuit_state = CIRCUT_STATE.OPEN # OPEN circuit
self.circuit_opened_at = time.perf_counter() # remember WHEN
self.failure_count = 0 # <--- EXPLAINING THIS IS THE DIFFERENCE BETWEEN L4 AND L5 ENGINEER
How would you design rate limiting for a multi-tenant LLM gateway - what do you limit on (requests? tokens?), where does state live, and what breaks at scale?
Problem
- rules: limit per hour
- actors: only authorized users with their api keys
- location: in-service(fasted, easiest, doesn't know global limits) / global service(configurable, extra hop, PoF) / gateway (best, not easy)
Solution
- lives in gateway in redis sidecar
- token bucket vs sliding window - bucket we update every interval and substract on user request, on sliding window we have k last seconds where we invalidate older than that uses to refresh limits.
- we limit on tokens because 1 request can be 100k tokens vs 10 requests of 1k tokens.
- how to limit on tokens - we estimate based on history and input size and reserve that amount in limit, after llm responds and we get exact output tokens size - we refund reserved-real. e.g. anthropic has max_tokens
Concerns
- in our case we only have auth users so at large scale our bottleneck is user quota lookup in redis
- if we also need to lookup the user info by ip - that service becomes overwhelmed
Examples
Token Bucket
class RateLimiter:
def request(self, user_id, tokens_used):
key = f"{user_id}:bucket"
while True:
try:
# WATCH FOR CHANGES
r.watch(key)
current_tokens_raw = r.hget(key, "tokens")
current_tokens = int(current_tokens_raw) if current_tokens_raw is not None else 0
if current_tokens < tokens_used:
r.unwatch()
return False # Rate limited!
# TRANSACTION TO UPDATE
pipe = r.pipeline(transaction=True)
pipe.hset(key, "tokens", current_tokens - tokens_used)
pipe.hset(key, "last_refill", int(time.time()))
pipe.expire(key, 60 * 60)
# Throws WatchError if another client modified this key
pipe.execute()
return True # Tokens successfully deducted!
except redis.WatchError:
# KEY CHANGED MID-FLIGHT - RETRY
continue
def put_bucket(self, key, amount, pipe):
pipe.multi()
pipe.hset(key, "tokens", amount)
pipe.hset(key, "refilled_at", int(time.time()))
pipe.expire(key, 60 * 60)
pipe.execute()
def main(self):
REFILL_AMOUNT_S = 1
while True:
# 1. GET ALL KEYS
keys = list(r.scan_iter(match='*:bucket'))
if not keys:
time.sleep(1)
continue
# 2. GET ALL BUCKETS
pipe = r.pipeline(transaction=False)
for key in keys:
pipe.hgetall(key)
all_buckets = pipe.execute()
# 3. UPDATE IN SINGLE NETWORK ROUNDTRIP
write_pipe = r.pipeline(transaction=False)
for key, data in zip(keys, all_buckets):
if not data:
continue
self.put_bucket(key, REFILL_AMOUNT_S, write_pipe)
result = write_pipe.execute()
time.sleep(1)
Reserve then Reconcile - LLM budget limiting
import time
import uuid
from typing import Dict, Any, Tuple
import redis
class MockLLM:
def generate(self, prompt: str, max_tokens: int) -> Dict[str, int]:
time.sleep(0.1)
prompt_tokens = len(prompt.split()) # OR TIKTOKEN
actual_completion_tokens = int(max_tokens * 0.6) # 60% of MAX
return {
"prompt_tokens": prompt_tokens,
"completion_tokens": actual_completion_tokens,
"total_tokens": prompt_tokens + actual_completion_tokens
}
class TokenLimiter:
def __init__(self, redis_client: redis.Redis, tpm_limit: int = 10000):
self.redis = redis_client
self.tpm_limit = tpm_limit
self.window = 60 # 1-minute window for TPM
# Lua script ensures atomic check, reservation, and TTL setting
self._reserve_script = self.redis.register_script("""
local key = KEYS[1]
local reserve_amount = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local window = tonumber(ARGV[3])
local current = tonumber(redis.call('GET', key) or "0")
if current + reserve_amount > limit then
return 0 -- Reject: Exceeds limit
else
redis.call('INCRBY', key, reserve_amount)
if current == 0 then
redis.call('EXPIRE', key, window)
end
return 1 -- Success: Reserved
end
""")
def _get_minute_key(self, user_id: str) -> str:
current_minute = int(time.time() / 60) # Groups buckets by the current minute timestamp
return f"ratelimit:{user_id}:{current_minute}"
def reserve(self, user_id: str, estimated_tokens: int) -> bool:
key = self._get_minute_key(user_id)
success = self._reserve_script(keys=[key], args=[estimated_tokens, self.tpm_limit, self.window])
return bool(success)
def reconcile(self, user_id: str, estimated_tokens: int, actual_tokens: int):
key = self._get_minute_key(user_id)
difference = estimated_tokens - actual_tokens
if difference > 0: self.redis.decrby(key, difference) # REFUND
elif difference < 0: self.redis.incrby(key, abs(difference)) # MODEL WENT OVER ESTIMATE
if __name__ == "__main__":
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
limiter = TokenLimiter(redis_client=r, tpm_limit=1000)
llm = MockLLM()
user_id = "user_12345"
prompt = "Write a comprehensive essay about distributed rate limiting patterns."
max_tokens = 500
# ESTIMATE
estimated_prompt_tokens = len(prompt.split())
worst_case_tokens = estimated_prompt_tokens + max_tokens
print(f"Attempting to reserve {worst_case_tokens} tokens...")
# CHECK
if limiter.reserve(user_id, worst_case_tokens):
print("✅ Reservation successful. Calling LLM...")
# CALL
response = llm.generate(prompt, max_tokens)
actual_used = response["total_tokens"]
print(f"LLM finished. Actual tokens used: {actual_used}")
# REFUND
limiter.reconcile(user_id, worst_case_tokens, actual_used)
refunded = worst_case_tokens - actual_used
print(f"🔄 Reconciled. Refunded {refunded} tokens to user bucket.")
else:
print("❌ Request dropped: 429 Too Many Requests. Budget exceeded.")