Skip to content

Commit 4ef3c65

Browse files
yoon-park-rlclaudeReflex
authored
feat: add HTTP/2 load testing infrastructure (#819)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Reflex <reflex@runloop.ai>
1 parent d85117a commit 4ef3c65

8 files changed

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

loadtest/h2_test.py

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

0 commit comments

Comments
 (0)