API Load Testing
Built-in API load testing against any FastAPI endpoint your generated project exposes. Sibling to worker load testing: that one hammers the queue, this one hammers the API.
Built-in
Ships in every generated project. No component to enable. Persists results in Redis when present; degrades cleanly without it.
What You Get
- Route auto-discovery — every
APIRoutein your FastAPI app is loadable, no per-endpoint registration - Concurrent orchestrator —
httpx.AsyncClientdriven by a semaphore, capped at--clients - In-process or out-of-process —
--in-processruns against the app viahttpx.ASGITransportfor CI / quick checks; default hits a real running server - Path parameter substitution —
--path-param key=valueresolves{name}placeholders before each request - Latency percentiles + status-code distribution + error sampling — capped at 100 error samples per run
- Live progress bar in the terminal (when stdout is a TTY)
- Result persistence in Redis — 24h TTL, indexed by recency
- Dashboard tab — Backend modal → Load Tests shows recent runs with per-run drill-down
Quick Usage
# See what's loadable
my-app api-load-test list
# Plain GET, in-process (no server needed)
my-app api-load-test run /health/ --requests 500 --clients 10 --in-process
# GET with a path parameter
my-app api-load-test run /api/v1/tasks/status/{task_id} \
--path-param task_id=abc-123 \
--requests 100 --clients 5 --in-process
# POST with payload
my-app api-load-test run /api/v1/tasks/enqueue \
--method POST \
--payload '{"queue": "load_test", "task": "ping"}' \
--requests 200 --clients 10 --in-process
# Authenticated endpoint (token via header)
TOKEN=$(my-app auth login alice@example.com --print-token)
my-app api-load-test run /api/v1/users/me \
--header "Authorization=Bearer $TOKEN" \
--requests 200 --clients 5
# Recall recent runs
my-app api-load-test recent
my-app api-load-test results <test-id>
list output
Discovered 21 routes
┏━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━┓
┃ METHOD ┃ PATH ┃ AUTH ┃ PARAMS ┃ TAGS ┃
┡━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━┩
│ GET │ /health/ │ no │ │ health │
│ GET │ /api/v1/users/me │ yes │ │ users │
│ GET │ /api/v1/tasks/status/{task_id} │ no │ task_id │ worker │
│ POST │ /api/v1/tasks/enqueue │ no │ │ worker │
└────────┴───────────────────────────────────┴──────┴─────────┴────────────┘
METHODis colored per verb (blue / green / yellow / magenta / red for GET / POST / PUT / PATCH / DELETE)AUTHpopulated by inspecting each route's dependency tree for the project'sget_current_active_usercallable. Auth-less stacks readnoeverywherePARAMSonly appears when at least one route has{...}placeholders
What works, what doesn't
| Endpoint shape | Example | Status |
|---|---|---|
| Plain GET, no params | GET /health/ |
works |
| GET with path param | GET /api/v1/tasks/status/{task_id} |
works with --path-param |
| POST with static payload | POST /api/v1/tasks/enqueue |
works with --payload |
| POST with dynamic payload | callable per request | field exists; resolver pending |
| Auth-gated | GET /api/v1/users/me |
works: signed in automatically (--as-admin, --as-user, --anon) |
| SSE / streaming | GET /events/worker/stream |
runs, but treats stream as one response (timeout risk) |
| WebSocket | (none today) | out of scope |
Overseer
Server > Load Tests starts a run and lists every run, the CLI's included.
- New run: pick one of the app's routes (Overseer's own pages are not
offered) and fill in its request form, the one Server > Routes shows:
typed path and query parameters, a JSON body pre-filled from its model,
who to sign in as (admin, user or anonymous, admin first when
ADMIN_USER_EMAILSnames one), plus the requests and clients. A route that writes asks you to confirm. It runs in the server itself, in-process like--in-process, through the same service and store as the CLI. A run from here is capped at 5,000 requests and 50 clients; the CLI takes more. - Running: a run started here shows its progress (requests done of the total) until it lands in the list.
- Recent runs: method, path, req/s, p95 ms, error %, when. Expand a row
for the latency percentiles, status codes, sampled errors, duration,
clients and test ID. A run whose every response was a redirect says so:
/healthanswers307to/health/, so it measured the redirect, not the health check.
The list streams while the page is open.
In-process vs out-of-process
--in-process (opt-in) |
Out-of-process (default) | |
|---|---|---|
| Transport | httpx.ASGITransport against the app object |
Real HTTP over a socket |
| Server needed | no | yes (my-app run first) |
| Realism | skips uvicorn / keep-alive / kernel hop | exercises the whole stack |
| Use for | CI, regression gates, quick checks | production-shape numbers |
Throughput in-process is typically 2-5x what you'll see out-of-process because there's no socket / no kernel hop. Don't compare across modes.
Exit codes
- 0 — the run completed all its requests, regardless of how the endpoint responded. Endpoint errors are stats, not test failures.
- 2 — misconfiguration (e.g., unsubstituted
{...}placeholders in the path). The error names the missing key.
Gate CI on response codes via --json + jq if you want strict
behaviour:
Programmatic use
from app.services.load_test.api.service import APILoadTestService
from app.services.load_test.api.models import APILoadTestConfiguration
service = APILoadTestService()
result = await service.run(
APILoadTestConfiguration(
method="GET",
path="/api/v1/users/{user_id}",
path_params={"user_id": "u-9"},
requests=200,
clients=10,
in_process=True,
),
app=fastapi_app,
)
print(result.metrics.latency_ms_p95)
Storage layout
When Redis is configured:
api_load_test:results:<test_id>— full result, JSON-serialized, 24h TTLapi_load_test:recent— sorted set of test IDs by start time; backs therecent/ dashboard list views
Shared with the worker load-test service through
app.services.load_test.common.storage.RedisResultStore; only the
key_prefix differs.
Limitations
- No streaming-aware mode for SSE / chunked endpoints. Runs work, but each request is awaited to completion (inflates latency / risks timeouts)
- No run-to-run regression comparison built in. Persist results yourself
via
--jsonif you need historical trends - HTTP only — no WebSocket or gRPC targets
Related
Comparing two configurations rather than measuring one is a different
command: bench engines boots the app
once per ASGI server and drives identical load at each. It takes the same
flags for describing a route, so anything you can load test here you can
benchmark there.