Design the multi-region HA architecture for an LLM application where 429 and 5xx are different diseases with different cures, then prove the design against a mock provider that throws rate limits on schedule. You will measure the user-visible difference between naive retries and a real degradation ladder: frontier → small → cached → static.
The Meridian Retail order-status agent has a launch date in the same week as a Meridian mega-sale, and Rajesh's reliability review has one finding in red: the design treats the LLM endpoint like a stateless web backend. It is not. LLM capacity fails in two distinct ways — quota exhaustion (429: the service is healthy, your allocation is spent) and service failure (5xx: the service is broken) — and a classic load balancer health check cannot tell them apart. Failing over on 429 to a region sharing the same quota pool solves nothing; retrying a 429 in-region burns latency against a wall that will not move for seconds. This lab makes you design the ladder, then run it against a scheduled storm.
~120 minutes hands-on · Advanced tier (maps to module M7). Runs at ~₹0 — the "providers" are local mock servers, the gateway is self-hosted LiteLLM (MIT-licensed), and no real model API is called at any point.
mkdir lab-11 && cd lab-11
python -m venv .venv && source .venv/bin/activate
pip install fastapi uvicorn "litellm[proxy]" openai
All capacity numbers in this lab are synthetic. Where your real design needs quota figures, the taught reflex applies: pull today's numbers from your provider's quota console and pricing pages — never from memory, and never from a model.
Create ha-design.md with four sections, written for the Meridian ARB:
429 with Retry-After, 429 without Retry-After, 5xx, timeout, and columns what it means · correct reaction · wrong-but-common reaction. Draft it yourself, then have your assistant red-team it.✅ Checkpoint: ha-design.md has all four sections; the failure-taxonomy table distinguishes the reaction to 429 from the reaction to 5xx.
One FastAPI app plays two providers: mock-frontier (good answers, throttles on a fixed schedule) and mock-small (always up, faster, plainer). Create mock_provider.py:
# mock_provider.py — OpenAI-compatible mock; frontier 429s on a schedule
import time, itertools
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
counters = {}
def reply(model, text):
return {"id": "mock-1", "object": "chat.completion", "model": model,
"choices": [{"index": 0, "finish_reason": "stop",
"message": {"role": "assistant", "content": text}}],
"usage": {"prompt_tokens": 40, "completion_tokens": 25,
"total_tokens": 65}}
@app.post("/v1/chat/completions")
async def chat(req: Request):
body = await req.json()
model = body.get("model", "mock-frontier")
n = next(counters.setdefault(model, itertools.count(1)))
# Schedule: frontier throttles requests 4,5,6 of every block of 6.
if model == "mock-frontier" and (n - 1) % 6 >= 3:
return JSONResponse(
status_code=429, headers={"Retry-After": "20"},
content={"error": {"type": "rate_limit_error",
"message": "TPM quota exceeded for mock-frontier"}})
time.sleep(0.15 if model == "mock-frontier" else 0.05)
tag = "FRONTIER" if model == "mock-frontier" else "SMALL"
return JSONResponse(reply(model, f"[{tag}] Order 8841 shipped 26 Jul, "
f"ETA 30 Jul. (answer #{n})"))
Run it: uvicorn mock_provider:app --port 9001. Curl it a few times and watch the 4th–6th frontier calls return 429 with Retry-After: 20.
✅ Checkpoint: curl shows the deterministic pattern — three 200s, three 429s, repeating — and the 429 body says quota, not error.
Create config_naive.yaml — the configuration the original design implied: retry the same model on failure, no fallback:
# config_naive.yaml — what "just add retries" looks like
model_list:
- model_name: frontier
litellm_params:
model: openai/mock-frontier
api_base: http://localhost:9001/v1
api_key: mock
- model_name: small
litellm_params:
model: openai/mock-small
api_base: http://localhost:9001/v1
api_key: mock
litellm_settings:
num_retries: 3 # same-target retries — the trap
request_timeout: 30
Start it: litellm --config config_naive.yaml --port 4000.
✅ Checkpoint: gateway is up on :4000 and forwards a request to the mock ([FRONTIER] … comes back).
Create driver.py — 30 requests through the gateway, logging which rung answered and how long the user waited. It also carries the ladder's bottom rungs client-side:
# driver.py — replay 30 requests; log rung + user-visible latency
import csv, sys, time, openai
client = openai.OpenAI(base_url="http://localhost:4000", api_key="sk-local")
Q = "Where is order 8841?"
CACHE = {Q: "[CACHED] Order 8841: shipped 26 Jul (as of 09:10 IST)."}
STATIC = "[STATIC] High demand right now - track any order at meridian.example/track."
rows, counts = [], {}
for i in range(1, 31):
t0, rung = time.time(), "static"
try:
r = client.chat.completions.create(
model="frontier", messages=[{"role": "user", "content": Q}])
text = r.choices[0].message.content
rung = "frontier" if "[FRONTIER]" in text else "small"
except Exception:
rung, text = ("cached", CACHE[Q]) if Q in CACHE else ("static", STATIC)
dt = round(time.time() - t0, 2)
counts[rung] = counts.get(rung, 0) + 1
rows.append([i, rung, dt, text[:48]])
with open(sys.argv[1], "w", newline="") as f:
w = csv.writer(f); w.writerow(["req", "rung", "latency_s", "preview"])
w.writerows(rows)
print(counts, "worst latency:", max(r[2] for r in rows), "s")
Run python driver.py naive.csv. Read the numbers like an SRE: every throttled request either burned ~3 same-target retries before erroring into the client-side cache, or waited out retry backoff. Note the worst user-visible latency and how many requests never got a model answer.
✅ Checkpoint: naive.csv exists; you can state the naive config's rung distribution and worst-case latency in one sentence.
Create config_ladder.yaml — same models, but the gateway now expresses the design: no same-target retries on quota errors; fall to the next rung immediately:
# config_ladder.yaml — fallback-first: 429 means "go down a rung now"
model_list:
- model_name: frontier
litellm_params:
model: openai/mock-frontier
api_base: http://localhost:9001/v1
api_key: mock
- model_name: small
litellm_params:
model: openai/mock-small
api_base: http://localhost:9001/v1
api_key: mock
litellm_settings:
num_retries: 0
request_timeout: 30
fallbacks:
- {"frontier": ["small"]}
Restart the gateway with the new config, restart the mock (to reset counters), and run python driver.py ladder.csv. Compare: every request in the throttle window should now land on [SMALL] in well under a second, and no request should reach the client-side rungs at all.
✅ Checkpoint: ladder.csv shows ~half the requests answered by small, zero exceptions, and worst-case latency an order of magnitude below the naive run.
Stop mock_provider.py mid-run (simulate a true 5xx/outage rather than a quota event) and run python driver.py outage.csv. Now the gateway has nowhere to go, the exception path fires, and the cached rung answers with its staleness label — the user still gets their order status, tagged with "as of 09:10 IST." Change Q to a question not in the cache and confirm the static rung catches it. Then write the closing section of ha-design.md: a three-row results table (naive / ladder / outage) with rung distribution and worst latency, plus the operational note that 429 recovery is scheduled (Retry-After says when capacity returns) while 5xx recovery is unknown (circuit-break and probe).
✅ Checkpoint: three CSVs exist; ha-design.md closes with the measured comparison and one paragraph distinguishing scheduled from unknown recovery.
A 429 is a routing signal, not a health signal — the correct response is to move the request down the ladder or to a separate quota pool, never to hammer the same allocation with retries.
ha-design.md contains the failure taxonomy, synthetic quota model, four-rung ladder with UX states, and gateway placement rationale.Retry-After set.naive.csv vs ladder.csv shows the same workload with materially different user-visible behavior, and you can explain why from the configs alone.outage.csv shows cached and static answers with no unhandled errors.The lab-11/ repo: ha-design.md, mock_provider.py, both gateway configs, driver.py, and the three simulation logs. Self-grading rubric (pass = 8/10):
Nothing in this lab touches a real provider, so it runs unmodified inside any client network — the mocks and gateway are local processes. For a real engagement: the design doc's structure transfers as-is, but populate the quota model from the client's own provider consoles under their credentials, run LiteLLM (or the client's approved gateway/APIM equivalent) inside their tenancy, and keep mock-based simulation as the pre-production test — it is precisely the storm you cannot ethically generate against shared production quota.
fallbacks syntax matches your LiteLLM version (list of single-key dicts) and that num_retries is 0 in the ladder config.uvicorn between measured runs to reset the schedule.[FRONTIER] in the naive run too: your driver retried until it left the throttle window — confirm the mock throttles 3-of-6 and that you ran exactly 30 requests.openai client raises on 429 immediately: that is the naive path working as designed — the exception falls to the client-side cache rung; log it, don't fix it.pkill -f litellm; pkill -f uvicorn and restart.The seeded traps and what they teach:
num_retries: 3 is the primary trap. Same-target retries against a quota wall convert a capacity event into a latency event: the user pays for three doomed round-trips (plus backoff) and still gets no frontier answer. The mock's Retry-After: 20 makes this vivid — the wall's height was announced, and the retries ignored it.[CACHED] … (as of 09:10 IST) is the difference between graceful degradation and quietly lying about order status — a UX state is part of the architecture, not a frontend nicety.Scenario questions in this domain hinge on one discrimination — given a 429 with Retry-After, the wrong answers all involve retrying or health-marking the region down; the right answers reroute to a separate quota pool or descend the ladder.