Skip to content

Commit 6329254

Browse files
yoon-park-rlclaude
andcommitted
feat: add HTTP/2 load testing infrastructure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 50507eb commit 6329254

7 files changed

Lines changed: 490 additions & 0 deletions

File tree

loadtest/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
__pycache__/
2+
*.pyc

loadtest/README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Load tests
2+
3+
Manual transport comparison scripts for the Runloop Python SDK. These hit the
4+
real API and are not part of `pytest`; run on demand.
5+
6+
## End-to-end transport comparison (real API)
7+
8+
All scripts require `RUNLOOP_API_KEY`. Set `RUNLOOP_BASE_URL` to override the
9+
default endpoint (`https://api.runloop.ai`).
10+
11+
Every script sends `devboxes.create` requests against a **deliberately
12+
nonexistent blueprint** (`bp_nonexistent_loadtest_00000`), so every request
13+
fails fast server-side (HTTP `400`) and **no devboxes are created** — isolating
14+
client + server _request handling_ from provisioning.
15+
16+
| Script | Transport under test |
17+
| --- | --- |
18+
| `loadtest.py` | The **SDK** itself (`AsyncRunloop`). `USE_HTTP2=0` → HTTP/1.1; default / `USE_HTTP2=1` → HTTP/2 (shared httpx pool). Always runs against the installed package in this checkout. |
19+
| `h2_test.py` | Raw `httpx` HTTP/2, bypassing the SDK. Configurable connection count. |
20+
| `h2_single_conn.py` | Raw `httpx` HTTP/2 on a single warmed connection (50-request burst). |
21+
| `raw_fetch_test.py` | Raw `httpx` HTTP/1.1 keep-alive baseline. |
22+
| `alpn_check.py` | Confirms the origin negotiates `h2` via TLS ALPN. |
23+
24+
The raw-transport probes compare httpx HTTP/2 multiplexing against HTTP/1.1
25+
directly — the same comparison that motivates the SDK's shared HTTP/2 pool.
26+
They're kept so the comparison stays reproducible.
27+
28+
```sh
29+
# Install the SDK from this checkout (editable):
30+
cd /path/to/api-client-python && uv sync
31+
32+
# SDK: HTTP/2 (default) vs HTTP/1.1, 2000-request burst
33+
source ~/env && REQUEST_COUNT=2000 uv run python loadtest/loadtest.py # HTTP/2
34+
source ~/env && REQUEST_COUNT=2000 USE_HTTP2=0 uv run python loadtest/loadtest.py # HTTP/1.1
35+
36+
# Raw httpx HTTP/2 vs HTTP/1.1 comparison
37+
source ~/env && uv run python loadtest/h2_test.py
38+
source ~/env && uv run python loadtest/raw_fetch_test.py
39+
40+
# Single-connection burst
41+
source ~/env && uv run python loadtest/h2_single_conn.py
42+
43+
# ALPN check
44+
source ~/env && uv run python loadtest/alpn_check.py
45+
```
46+
47+
HTTP/1.1 opens a socket per in-flight request; for large bursts raise the
48+
file-descriptor limit (`ulimit -n 65536`) or keep `REQUEST_COUNT` small.
49+
50+
## Environment variables
51+
52+
| Variable | Default | Description |
53+
| --- | --- | --- |
54+
| `RUNLOOP_API_KEY` | *(required)* | API key |
55+
| `RUNLOOP_BASE_URL` | `https://api.runloop.ai` | Override API endpoint |
56+
| `REQUEST_COUNT` | `100000` (`loadtest.py`) / `10000` (`h2_test.py`) / `500` (`raw_fetch_test.py`) | Total requests |
57+
| `NUM_CONNECTIONS` | `10` (`h2_test.py`) / `20` (`raw_fetch_test.py`) | Parallel connections |
58+
| `USE_HTTP2` | `1` | `0` to force HTTP/1.1 in `loadtest.py` |

loadtest/alpn_check.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Confirm the origin negotiates h2 via TLS ALPN."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import ssl
7+
import socket
8+
9+
BASE_URL = os.environ.get("RUNLOOP_BASE_URL", "https://api.runloop.ai")
10+
url_parts = BASE_URL.split("://", 1)
11+
host = url_parts[1].split("/")[0] if len(url_parts) > 1 else url_parts[0]
12+
port = 443
13+
14+
print(f"Checking ALPN for {host}:{port}")
15+
16+
ctx = ssl.create_default_context()
17+
ctx.set_alpn_protocols(["h2", "http/1.1"])
18+
19+
with socket.create_connection((host, port)) as sock:
20+
with ctx.wrap_socket(sock, server_hostname=host) as tls:
21+
print(f"Negotiated protocol: {tls.selected_alpn_protocol()}")
22+
print(f"TLS version: {tls.version()}")

loadtest/h2_single_conn.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Raw httpx HTTP/2 single-connection burst: 50 requests on one warmed connection."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import os
7+
import time
8+
9+
import httpx
10+
11+
BASE_URL = os.environ.get("RUNLOOP_BASE_URL", "https://api.runloop.ai")
12+
API_KEY = os.environ["RUNLOOP_API_KEY"]
13+
14+
BODY = {
15+
"blueprint_id": "bp_nonexistent_loadtest_00000",
16+
"name": "loadtest-h2s-0",
17+
"environment_variables": {"TEST_VAR_1": "value_one"},
18+
"launch_parameters": {"resource_size_request": "SMALL"},
19+
}
20+
21+
22+
async def send_request(client: httpx.AsyncClient) -> dict[str, object]:
23+
start = time.perf_counter()
24+
response = await client.post(
25+
"/v1/devboxes",
26+
json=BODY,
27+
headers={"authorization": f"Bearer {API_KEY}"},
28+
)
29+
return {"latency_ms": (time.perf_counter() - start) * 1000, "status": response.status_code}
30+
31+
32+
async def main() -> None:
33+
client = httpx.AsyncClient(
34+
base_url=BASE_URL,
35+
http2=True,
36+
limits=httpx.Limits(max_connections=1, max_keepalive_connections=1),
37+
timeout=httpx.Timeout(120.0),
38+
)
39+
40+
# Warmup
41+
w = await send_request(client)
42+
print(f"Warmup: status={w['status']}, latency={w['latency_ms']:.0f}ms")
43+
44+
count = 50
45+
print(f"\nBursting {count} requests on 1 warmed connection...")
46+
wall_start = time.perf_counter()
47+
results = await asyncio.gather(*(send_request(client) for _ in range(count)))
48+
wall_ms = (time.perf_counter() - wall_start) * 1000
49+
50+
await client.aclose()
51+
52+
lats = sorted(r["latency_ms"] for r in results) # type: ignore[arg-type]
53+
print(f"{count} requests in {wall_ms:.0f}ms ({count / (wall_ms / 1000):.1f} req/s)")
54+
print(
55+
f"Latency: min={lats[0]:.0f}ms p50={lats[count // 2]:.0f}ms max={lats[-1]:.0f}ms"
56+
)
57+
58+
59+
if __name__ == "__main__":
60+
asyncio.run(main())

loadtest/h2_test.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Raw httpx HTTP/2 load test: REQUEST_COUNT requests across NUM_CONNECTIONS connections."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import math
7+
import os
8+
import time
9+
10+
import httpx
11+
12+
REQUEST_COUNT = int(os.environ.get("REQUEST_COUNT", "10000"))
13+
NUM_CONNECTIONS = int(os.environ.get("NUM_CONNECTIONS", "10"))
14+
BASE_URL = os.environ.get("RUNLOOP_BASE_URL", "https://api.runloop.ai")
15+
API_KEY = os.environ["RUNLOOP_API_KEY"]
16+
17+
BODY = {
18+
"blueprint_id": "bp_nonexistent_loadtest_00000",
19+
"name": "loadtest-h2-0",
20+
"environment_variables": {"TEST_VAR_1": "value_one", "TEST_VAR_2": "value_two"},
21+
"metadata": {"test_run": "h2", "index": "0"},
22+
"launch_parameters": {"resource_size_request": "SMALL", "keep_alive_time_seconds": 300},
23+
}
24+
25+
26+
def percentile(sorted_vals: list[float], p: float) -> float:
27+
idx = math.ceil(p / 100 * len(sorted_vals)) - 1
28+
return sorted_vals[max(0, idx)]
29+
30+
31+
async def send_request(client: httpx.AsyncClient) -> dict[str, object]:
32+
start = time.perf_counter()
33+
response = await client.post(
34+
"/v1/devboxes",
35+
json=BODY,
36+
headers={"authorization": f"Bearer {API_KEY}"},
37+
)
38+
return {"latency_ms": (time.perf_counter() - start) * 1000, "status": response.status_code}
39+
40+
41+
async def main() -> None:
42+
if NUM_CONNECTIONS < 1:
43+
print(f'NUM_CONNECTIONS must be a positive integer (got "{os.environ.get("NUM_CONNECTIONS")}")')
44+
return
45+
46+
print(f"HTTP/2 test: {REQUEST_COUNT} requests, {NUM_CONNECTIONS} connections to {BASE_URL}")
47+
48+
# One client per logical connection — each capped at 1 connection so requests
49+
# are distributed across NUM_CONNECTIONS distinct HTTP/2 sessions.
50+
clients = [
51+
httpx.AsyncClient(
52+
base_url=BASE_URL,
53+
http2=True,
54+
limits=httpx.Limits(max_connections=1, max_keepalive_connections=1),
55+
timeout=httpx.Timeout(120.0),
56+
)
57+
for _ in range(NUM_CONNECTIONS)
58+
]
59+
60+
print(f"{NUM_CONNECTIONS} connections established\n")
61+
62+
completed = 0
63+
64+
async def progress_printer() -> None:
65+
while True:
66+
await asyncio.sleep(2)
67+
pct = completed / REQUEST_COUNT * 100
68+
print(f" progress: {completed}/{REQUEST_COUNT} ({pct:.1f}%)")
69+
70+
async def wrapped(idx: int) -> dict[str, object]:
71+
nonlocal completed
72+
r = await send_request(clients[idx % NUM_CONNECTIONS])
73+
completed += 1
74+
return r
75+
76+
wall_start = time.perf_counter()
77+
progress_task = asyncio.create_task(progress_printer())
78+
results = await asyncio.gather(*(wrapped(i) for i in range(REQUEST_COUNT)))
79+
progress_task.cancel()
80+
wall_ms = (time.perf_counter() - wall_start) * 1000
81+
82+
for c in clients:
83+
await c.aclose()
84+
85+
latencies = sorted(r["latency_ms"] for r in results) # type: ignore[arg-type]
86+
status_counts: dict[int, int] = {}
87+
for r in results:
88+
s = int(r["status"]) # type: ignore[arg-type]
89+
status_counts[s] = status_counts.get(s, 0) + 1
90+
91+
print(f"\n=== HTTP/2 Results ===")
92+
print(f"Requests: {REQUEST_COUNT}")
93+
print(f"Connections: {NUM_CONNECTIONS}")
94+
print(f"Wall clock: {wall_ms / 1000:.2f}s")
95+
print(f"Throughput: {REQUEST_COUNT / (wall_ms / 1000):.1f} req/s")
96+
if latencies:
97+
print("\nLatency (ms):")
98+
print(f" min: {latencies[0]:.1f}")
99+
print(f" p50: {percentile(latencies, 50):.1f}")
100+
print(f" p90: {percentile(latencies, 90):.1f}")
101+
print(f" p95: {percentile(latencies, 95):.1f}")
102+
print(f" p99: {percentile(latencies, 99):.1f}")
103+
print(f" max: {latencies[-1]:.1f}")
104+
print("\nStatus codes:")
105+
for s, c_count in sorted(status_counts.items()):
106+
print(f" {s}: {c_count}")
107+
108+
109+
if __name__ == "__main__":
110+
asyncio.run(main())

loadtest/loadtest.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""SDK load test: fires devboxes.create bursts via AsyncRunloop.
2+
3+
Imports the installed package from this checkout so the benchmark always
4+
exercises the exact code here — not a separately published build.
5+
6+
USE_HTTP2=0 → HTTP/1.1 (httpx with http2=False)
7+
default → HTTP/2 (shared httpx pool with http2=True)
8+
REQUEST_COUNT defaults to 100 000.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import asyncio
14+
import math
15+
import os
16+
import time
17+
18+
import httpx
19+
20+
from runloop_api_client import AsyncRunloop
21+
22+
REQUEST_COUNT = int(os.environ.get("REQUEST_COUNT", "100000"))
23+
RUNLOOP_BASE_URL = os.environ.get("RUNLOOP_BASE_URL")
24+
# HTTP/2 is the SDK default; set USE_HTTP2=0 to benchmark HTTP/1.1.
25+
USE_HTTP2 = os.environ.get("USE_HTTP2", "1") == "1"
26+
PROGRESS_INTERVAL = 2.0
27+
28+
29+
def build_client() -> AsyncRunloop:
30+
kwargs: dict[str, object] = {"max_retries": 0, "timeout": 120.0}
31+
if RUNLOOP_BASE_URL:
32+
kwargs["base_url"] = RUNLOOP_BASE_URL
33+
if not USE_HTTP2:
34+
# A custom http_client disables the shared HTTP/2 pool.
35+
# Set http2: false explicitly — omitting it would select HTTP/2.
36+
kwargs["http_client"] = httpx.AsyncClient(
37+
http2=False,
38+
limits=httpx.Limits(max_connections=None, max_keepalive_connections=100),
39+
timeout=httpx.Timeout(120.0),
40+
)
41+
return AsyncRunloop(**kwargs) # type: ignore[arg-type]
42+
43+
44+
async def send_request(client: AsyncRunloop, index: int, run_id: str) -> dict[str, object]:
45+
start = time.perf_counter()
46+
status: int | None = None
47+
try:
48+
await client.devboxes.create(
49+
blueprint_id="bp_nonexistent_loadtest_00000",
50+
name=f"loadtest-{run_id}-{index}",
51+
environment_variables={"TEST_VAR_1": "value_one", "TEST_VAR_2": "value_two"},
52+
metadata={"test_run": run_id, "index": str(index)},
53+
launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 300},
54+
)
55+
status = 200
56+
except Exception as exc:
57+
status = getattr(exc, "status_code", None)
58+
return {"index": index, "latency_ms": (time.perf_counter() - start) * 1000, "status": status}
59+
60+
61+
def percentile(sorted_vals: list[float], p: float) -> float:
62+
idx = math.ceil(p / 100 * len(sorted_vals)) - 1
63+
return sorted_vals[max(0, idx)]
64+
65+
66+
def print_metrics(results: list[dict[str, object]], wall_ms: float) -> None:
67+
latencies = sorted(float(r["latency_ms"]) for r in results) # type: ignore[arg-type]
68+
status_counts: dict[str, int] = {}
69+
for r in results:
70+
key = str(r["status"]) if r["status"] is not None else "network_error"
71+
status_counts[key] = status_counts.get(key, 0) + 1
72+
73+
print("\n=== Load Test Results ===")
74+
print(f"Requests: {len(results)}")
75+
print(f"Wall clock: {wall_ms / 1000:.2f}s")
76+
print(f"Throughput: {len(results) / (wall_ms / 1000):.1f} req/s")
77+
if latencies:
78+
print("\nLatency (ms):")
79+
print(f" min: {latencies[0]:.1f}")
80+
print(f" p50: {percentile(latencies, 50):.1f}")
81+
print(f" p90: {percentile(latencies, 90):.1f}")
82+
print(f" p95: {percentile(latencies, 95):.1f}")
83+
print(f" p99: {percentile(latencies, 99):.1f}")
84+
print(f" max: {latencies[-1]:.1f}")
85+
print("\nStatus codes:")
86+
for s, c in sorted(status_counts.items()):
87+
print(f" {s}: {c}")
88+
89+
90+
async def main() -> None:
91+
fd_limit: int | None = None
92+
try:
93+
import resource as _resource
94+
95+
fd_limit = _resource.getrlimit(_resource.RLIMIT_NOFILE)[0]
96+
except Exception:
97+
pass
98+
99+
if not USE_HTTP2 and fd_limit is not None and fd_limit < 10000:
100+
print(f"\nWARNING: File descriptor limit is {fd_limit}. For large HTTP/1.1 bursts, run:")
101+
print(" ulimit -n 65536")
102+
print("Or use HTTP/2 multiplexing: USE_HTTP2=1\n")
103+
104+
client = build_client()
105+
run_id = f"run-{int(time.time())}"
106+
107+
print(f"Starting load test: {REQUEST_COUNT} concurrent requests")
108+
print(f"Run ID: {run_id}")
109+
print(f"HTTP mode: {'HTTP/2 (shared httpx pool)' if USE_HTTP2 else 'HTTP/1.1 (httpx http2=False)'}")
110+
print(f"Base URL: {RUNLOOP_BASE_URL or '(SDK default)'}")
111+
if fd_limit is not None:
112+
print(f"FD limit: {fd_limit}")
113+
print()
114+
115+
completed = 0
116+
117+
async def progress_printer() -> None:
118+
while True:
119+
await asyncio.sleep(PROGRESS_INTERVAL)
120+
pct = completed / REQUEST_COUNT * 100
121+
print(f" progress: {completed}/{REQUEST_COUNT} ({pct:.1f}%)")
122+
123+
async def wrapped(idx: int) -> dict[str, object]:
124+
nonlocal completed
125+
r = await send_request(client, idx, run_id)
126+
completed += 1
127+
return r
128+
129+
wall_start = time.perf_counter()
130+
progress_task = asyncio.create_task(progress_printer())
131+
results = list(await asyncio.gather(*(wrapped(i) for i in range(REQUEST_COUNT))))
132+
progress_task.cancel()
133+
wall_ms = (time.perf_counter() - wall_start) * 1000
134+
135+
await client.close()
136+
print_metrics(results, wall_ms)
137+
138+
139+
if __name__ == "__main__":
140+
asyncio.run(main())

0 commit comments

Comments
 (0)