Spaces:
Sleeping
Sleeping
File size: 8,304 Bytes
db284b9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | """Burst test that verifies the slowapi per-user rate limit on /api/answer.
Unlike scripts/stress_test.py (which THROTTLES with a semaphore to measure
graceful degradation), this script does the opposite: it fires every request at
the *same instant* -- a thundering herd -- to prove the limiter admits exactly N
and blocks the rest in a single fixed window.
Expected for --burst 25 --limit 10: 10 x HTTP 200 + 15 x HTTP 429
------------------------------------------------------------------------------
HOW TO RUN
------------------------------------------------------------------------------
1. The server MUST run single-worker (uvicorn's default). slowapi's in-memory
counter is per-process; with --workers N the burst is split across N
independent buckets and far more than `limit` requests pass. So:
uvicorn api:app --port 8000 # (no --workers flag)
2. Mode B -- dev-bypass (DEFAULT, for CI): leave CLERK_ISSUER unset on the
server. Every request maps to the synthetic 'dev-user', so all 25 share one
bucket. No token needed:
python scripts/ratelimit_test.py
3. Mode A -- real Clerk JWT (manual end-to-end check): grab a fresh session
token from the browser (it's short-lived, ~60s) and pass it:
python scripts/ratelimit_test.py --token "eyJhbGci..."
"""
from __future__ import annotations
import argparse
import asyncio
import time
from collections import Counter
import aiohttp
async def _fire(
session: aiohttp.ClientSession,
url: str,
payload: dict,
timeout: float,
start_gun: asyncio.Event,
idx: int,
) -> dict:
"""One request that waits at the barrier, then fires the instant it opens.
The `await start_gun.wait()` is the concurrency barrier: every worker parks
here until the main coroutine fires the starting gun, so all `burst` requests
leave the line on the SAME event-loop tick instead of trickling out as each
task gets scheduled. That true simultaneity is what makes this a real burst
test of the fixed window (a semaphore would serialize them and hide the seam).
"""
await start_gun.wait()
t0 = time.perf_counter()
try:
# NOTE: aiohttp does NOT raise on 4xx/5xx unless raise_for_status=True,
# which we deliberately omit -- a 429 is the EXPECTED result here, not an
# error. We just read the status code off the response.
async with session.post(url, json=payload, timeout=timeout) as resp:
await resp.read() # drain the body so the connection is freed
return {"idx": idx, "status": resp.status,
"latency": time.perf_counter() - t0}
except asyncio.TimeoutError:
# graceful: a slow/blocked request becomes a recorded outcome, not a crash
return {"idx": idx, "status": "timeout",
"latency": time.perf_counter() - t0}
except aiohttp.ClientError as exc:
return {"idx": idx, "status": f"error:{type(exc).__name__}",
"latency": time.perf_counter() - t0}
async def run(url: str, token: str | None, burst: int, limit: int,
timeout: float) -> bool:
"""Fire `burst` simultaneous requests; return True if the limit held exactly."""
# --- identity: one token (or dev-bypass) => one rate-limit bucket ---
headers: dict[str, str] = {}
if token:
headers["Authorization"] = f"Bearer {token}"
mode = "Mode A -- real Clerk JWT (--token)"
else:
# No header -> server runs every request as the synthetic 'dev-user',
# so the whole burst still shares ONE bucket. Perfect for CI.
mode = "Mode B -- dev-bypass (synthetic 'dev-user', no token)"
# Empty context deliberately: /api/answer returns 200 instantly for empty
# context WITHOUT calling the LLM. The rate-limit decorator runs BEFORE the
# endpoint body, so the 429s still fire -- we prove the limiter for zero LLM
# cost. (Set a real context only if you want to exercise the answer path.)
payload = {"query": "rate-limit burst probe", "context": ""}
expected_pass = limit
expected_block = burst - limit
print(f"Bursting {burst} simultaneous requests at {url}")
print(f" {mode}")
print(f" expecting {expected_pass} x 200 and {expected_block} x 429\n")
print(" [!] Server MUST be single-worker (uvicorn default, NO --workers N)")
print(" - slowapi's in-memory bucket is per-process; multiple workers")
print(" mean multiple buckets and more than `limit` requests pass.\n")
start_gun = asyncio.Event()
async with aiohttp.ClientSession(headers=headers) as session:
# Create all tasks first. Each immediately parks on `start_gun.wait()`.
tasks = [
asyncio.create_task(_fire(session, url, payload, timeout, start_gun, i))
for i in range(burst)
]
# Yield control so EVERY task gets scheduled and reaches the barrier
# before we fire. Without this beat, set() could race ahead of tasks
# that haven't hit `await start_gun.wait()` yet.
await asyncio.sleep(0.1)
wall_start = time.perf_counter()
start_gun.set() # FIRE: all `burst` requests go now
results = await asyncio.gather(*tasks)
wall = time.perf_counter() - wall_start
# --- tally outcomes ---
statuses = Counter(r["status"] for r in results)
n_200 = statuses.get(200, 0)
n_429 = statuses.get(429, 0)
lat = sorted(r["latency"] for r in results)
print("=" * 60)
print(f"{'Status / outcome':<28}{'count':>10}")
print("-" * 60)
for status, n in sorted(statuses.items(), key=lambda kv: str(kv[0])):
print(f"{str(status):<28}{n:>10}")
print("-" * 60)
print(f"wall time {wall:>8.3f}s")
print(f"200 (admitted) {n_200:>8} / expected {expected_pass}")
print(f"429 (blocked) {n_429:>8} / expected {expected_block}")
if lat:
print(f"latency min/max {lat[0]:.3f} / {lat[-1]:.3f} s")
print("=" * 60)
# --- verdict ---
passed = (n_200 == expected_pass and n_429 == expected_block)
if passed:
print(f"\n[PASS] Limiter held exactly: {expected_pass}x200, "
f"{expected_block}x429. The 429 path is working.")
return True
print("\n[FAIL] Counts did not match. Likely causes, in order:")
# Diagnostics ranked by how often each is the real culprit.
if n_200 > expected_pass:
print(f" - {n_200} passed (> {expected_pass}). Either the server is "
f"running MULTIPLE WORKERS (each with its own in-memory bucket),")
print(f" or -- far less likely at this speed -- the burst straddled a "
f"60s fixed-window boundary. Re-run; if it persists it's workers.")
if any(str(s).startswith(("error", "timeout")) for s in statuses):
print(" - Hard failures (timeout/conn-error) present -- is the server up "
"at this URL, and is the timeout high enough?")
if n_200 == 0 and n_429 == 0:
print(" - Nothing got through at all -- check the URL and that the API "
"is actually listening.")
if n_429 == 0 and n_200 < burst:
print(" - No 429s seen -- is slowapi actually wired (app.state.limiter + "
"@limiter.limit on /api/answer)?")
return False
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="Verify the per-user rate limit on /api/answer.")
p.add_argument("--url", default="http://127.0.0.1:8000/api/answer")
p.add_argument("--token", default=None,
help="Clerk JWT for Mode A; omit for Mode B dev-bypass.")
p.add_argument("--burst", type=int, default=25,
help="total simultaneous requests (default 25)")
p.add_argument("--limit", type=int, default=10,
help="the server's per-minute cap, i.e. expected 200s (default 10)")
p.add_argument("--timeout", type=float, default=30.0)
args = p.parse_args(argv)
ok = asyncio.run(run(args.url, args.token, args.burst, args.limit, args.timeout))
# exit code 0 on PASS, 1 on FAIL -- so CI (GitHub Actions) fails the job loudly.
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())
|