Skip to content

Commit ea7cc09

Browse files
jrvb-rlclaudeReflex
authored
feat: add HTTP/2 load testing infrastructure (#798)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Reflex <reflex@runloop.ai>
1 parent 35dd305 commit ea7cc09

11 files changed

Lines changed: 800 additions & 9 deletions

loadtest/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules/
2+
package-lock.json

loadtest/README.md

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,47 @@ duration. Not part of `npm test`; run on demand.
66

77
## Scripts
88

9-
| Script | Purpose |
10-
|---|---|
11-
| `h2-multiplex.ts` | Fire N concurrent requests, report throughput + p50/p95/p99. |
12-
| `h2-pool-growth.ts` | Ramp 1 → 50 → 200 → 50 → 1 concurrent. Observe pool size over time. |
13-
| `h2-leak.ts` | Soak at 50 r/s. Sample heap + FD count. Periodic GOAWAY injection. |
14-
| `h2-chaos.ts` | Server randomly drops sockets / RSTs / GOAWAYs / delays. Asserts ≥50 % GET success. |
15-
| `h2load.sh` | Wraps `nghttp2`'s `h2load` against a long-running test server. |
16-
| `sse-test.ts` | Pre-existing manual SSE round-trip. |
17-
| `push-to-loki.ts` | Runs all four timed tests and pushes results to Loki for Grafana. |
9+
| Script | Purpose |
10+
| ------------------- | ----------------------------------------------------------------------------------- |
11+
| `h2-multiplex.ts` | Fire N concurrent requests, report throughput + p50/p95/p99. |
12+
| `h2-pool-growth.ts` | Ramp 1 → 50 → 200 → 50 → 1 concurrent. Observe pool size over time. |
13+
| `h2-leak.ts` | Soak at 50 r/s. Sample heap + FD count. Periodic GOAWAY injection. |
14+
| `h2-chaos.ts` | Server randomly drops sockets / RSTs / GOAWAYs / delays. Asserts ≥50 % GET success. |
15+
| `h2load.sh` | Wraps `nghttp2`'s `h2load` against a long-running test server. |
16+
| `sse-test.ts` | Pre-existing manual SSE round-trip. |
17+
| `push-to-loki.ts` | Runs all four timed tests and pushes results to Loki for Grafana. |
18+
19+
## End-to-end transport comparison (real API)
20+
21+
Unlike the in-process scripts above, these hit a real Runloop API (`RUNLOOP_API_KEY`,
22+
optional `RUNLOOP_BASE_URL`). They send a burst of `devboxes.create` calls against a
23+
**deliberately nonexistent blueprint** (`bp_nonexistent_loadtest_00000`), so every
24+
request fails fast server-side (HTTP `400`) and **no devboxes are created** — isolating
25+
client + server _request handling_ from provisioning.
26+
27+
| Script | Transport under test |
28+
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
29+
| `loadtest.ts` | The **SDK** itself. `USE_HTTP2=0` → HTTP/1.1 (`node-fetch`); default / `USE_HTTP2=1` → HTTP/2 (native `node:http2`). Imports `../src/sdk.ts`, so it always runs against the SDK code in this checkout. |
30+
| `undici-test.ts`, `undici-single-conn.ts`, `undici-debug.ts` | Raw [undici](https://github.com/nodejs/undici) HTTP/2 pool, bypassing the SDK. |
31+
| `h2-test.ts`, `h2-single-conn.ts` | Raw `node:http2`, bypassing the SDK. |
32+
| `raw-fetch-test.ts` | Raw `node:https` (HTTP/1.1 keep-alive agent). |
33+
| `alpn-check.ts` | Confirms the origin negotiates `h2` via TLS ALPN. |
34+
35+
The raw-transport probes compare undici's HTTP/2 multiplexing against `node:http2`
36+
(and HTTP/1.1) directly — the comparison that motivated building the SDK's own
37+
`node:http2` transport. They're kept so that comparison stays reproducible.
38+
39+
```sh
40+
# SDK: HTTP/2 (default) vs HTTP/1.1, 2000-request burst
41+
source ~/env && REQUEST_COUNT=2000 npx tsx loadtest/loadtest.ts # HTTP/2
42+
source ~/env && REQUEST_COUNT=2000 USE_HTTP2=0 npx tsx loadtest/loadtest.ts # HTTP/1.1
43+
44+
# Raw undici vs node:http2 comparison (undici comes from loadtest/package.json)
45+
cd loadtest && npm install && source ~/env && npx tsx undici-test.ts
46+
```
47+
48+
HTTP/1.1 opens a socket per in-flight request; for large bursts raise the file-descriptor
49+
limit (`ulimit -n 65536`) or keep `REQUEST_COUNT` under it.
1850

1951
## Automated daily runs
2052

loadtest/alpn-check.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import tls from 'node:tls';
2+
3+
const BASE_URL = process.env.RUNLOOP_BASE_URL ?? 'https://api.runloop.pro';
4+
const url = new URL(BASE_URL);
5+
6+
console.log(`Checking ALPN for ${url.hostname}:${url.port || 443}`);
7+
8+
const socket = tls.connect(
9+
{
10+
host: url.hostname,
11+
port: parseInt(url.port || '443', 10),
12+
ALPNProtocols: ['h2', 'http/1.1'],
13+
servername: url.hostname,
14+
},
15+
() => {
16+
console.log(`Negotiated protocol: ${socket.alpnProtocol}`);
17+
console.log(`TLS version: ${socket.getProtocol()}`);
18+
socket.end();
19+
},
20+
);
21+
22+
socket.on('error', (err) => {
23+
console.error('TLS error:', err.message);
24+
});

loadtest/h2-single-conn.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import http2 from 'node:http2';
2+
3+
const BASE_URL = process.env.RUNLOOP_BASE_URL ?? 'https://api.runloop.pro';
4+
const API_KEY = process.env.RUNLOOP_API_KEY!;
5+
6+
const body = JSON.stringify({
7+
blueprint_id: 'bp_nonexistent_loadtest_00000',
8+
name: 'loadtest-h2s-0',
9+
environment_variables: { TEST_VAR_1: 'value_one' },
10+
launch_parameters: { resource_size_request: 'SMALL' },
11+
});
12+
13+
function sendRequest(client: http2.ClientHttp2Session): Promise<{ latencyMs: number; status: number }> {
14+
return new Promise((resolve, reject) => {
15+
const start = performance.now();
16+
const req = client.request({
17+
':method': 'POST',
18+
':path': '/v1/devboxes',
19+
'content-type': 'application/json',
20+
authorization: `Bearer ${API_KEY}`,
21+
});
22+
req.on('response', (headers) => {
23+
const status = headers[':status'] as number;
24+
req.on('data', () => {});
25+
req.on('end', () => resolve({ latencyMs: performance.now() - start, status }));
26+
});
27+
req.on('error', reject);
28+
req.end(body);
29+
});
30+
}
31+
32+
async function main() {
33+
const url = new URL(BASE_URL);
34+
const client = http2.connect(url.origin);
35+
await new Promise<void>((resolve, reject) => {
36+
client.on('connect', resolve);
37+
client.on('error', reject);
38+
});
39+
40+
const maxStreams = client.remoteSettings?.maxConcurrentStreams;
41+
console.log(`Server MAX_CONCURRENT_STREAMS: ${maxStreams}`);
42+
43+
// Warmup
44+
const w = await sendRequest(client);
45+
console.log(`Warmup: status=${w.status}, latency=${w.latencyMs.toFixed(0)}ms`);
46+
47+
// Burst 50 requests on single warmed connection
48+
const count = 50;
49+
console.log(`\nBursting ${count} requests on 1 warmed connection...`);
50+
const wallStart = performance.now();
51+
const results = await Promise.all(Array.from({ length: count }, () => sendRequest(client)));
52+
const wallMs = performance.now() - wallStart;
53+
54+
const lats = results.map((r) => r.latencyMs).sort((a, b) => a - b);
55+
console.log(`${count} requests in ${wallMs.toFixed(0)}ms (${(count / (wallMs / 1000)).toFixed(1)} req/s)`);
56+
console.log(
57+
`Latency: min=${lats[0].toFixed(0)}ms p50=${lats[Math.floor(count / 2)].toFixed(0)}ms max=${lats[lats.length - 1].toFixed(0)}ms`,
58+
);
59+
60+
client.close();
61+
}
62+
63+
main().catch((e) => {
64+
console.error(e);
65+
process.exit(1);
66+
});

loadtest/h2-test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import http2 from 'node:http2';
2+
3+
const REQUEST_COUNT = parseInt(process.env.REQUEST_COUNT ?? '10000', 10);
4+
const NUM_CONNECTIONS = parseInt(process.env.NUM_CONNECTIONS ?? '10', 10);
5+
const BASE_URL = process.env.RUNLOOP_BASE_URL ?? 'https://api.runloop.pro';
6+
const API_KEY = process.env.RUNLOOP_API_KEY!;
7+
8+
const body = JSON.stringify({
9+
blueprint_id: 'bp_nonexistent_loadtest_00000',
10+
name: 'loadtest-h2-0',
11+
environment_variables: { TEST_VAR_1: 'value_one', TEST_VAR_2: 'value_two' },
12+
metadata: { test_run: 'h2', index: '0' },
13+
launch_parameters: { resource_size_request: 'SMALL', keep_alive_time_seconds: 300 },
14+
});
15+
16+
function percentile(sorted: number[], p: number): number {
17+
const idx = Math.ceil((p / 100) * sorted.length) - 1;
18+
return sorted[Math.max(0, idx)];
19+
}
20+
21+
function connectH2(origin: string): Promise<http2.ClientHttp2Session> {
22+
return new Promise((resolve, reject) => {
23+
const client = http2.connect(origin);
24+
client.on('connect', () => resolve(client));
25+
client.on('error', reject);
26+
});
27+
}
28+
29+
function sendRequest(client: http2.ClientHttp2Session): Promise<{ latencyMs: number; status: number }> {
30+
return new Promise((resolve, reject) => {
31+
const start = performance.now();
32+
const req = client.request({
33+
':method': 'POST',
34+
':path': '/v1/devboxes',
35+
'content-type': 'application/json',
36+
authorization: `Bearer ${API_KEY}`,
37+
});
38+
req.on('response', (headers) => {
39+
const status = headers[':status'] as number;
40+
req.on('data', () => {});
41+
req.on('end', () => resolve({ latencyMs: performance.now() - start, status }));
42+
});
43+
req.on('error', reject);
44+
req.end(body);
45+
});
46+
}
47+
48+
async function main() {
49+
if (!Number.isInteger(NUM_CONNECTIONS) || NUM_CONNECTIONS < 1) {
50+
console.error(`NUM_CONNECTIONS must be a positive integer (got "${process.env.NUM_CONNECTIONS}")`);
51+
process.exit(1);
52+
}
53+
54+
console.log(`HTTP/2 test: ${REQUEST_COUNT} requests, ${NUM_CONNECTIONS} connections to ${BASE_URL}`);
55+
56+
const url = new URL(BASE_URL);
57+
const clients = await Promise.all(Array.from({ length: NUM_CONNECTIONS }, () => connectH2(url.origin)));
58+
59+
const maxStreams = clients[0].remoteSettings?.maxConcurrentStreams;
60+
console.log(`Server MAX_CONCURRENT_STREAMS: ${maxStreams ?? 'unknown'}`);
61+
console.log(`${NUM_CONNECTIONS} connections established\n`);
62+
63+
let completed = 0;
64+
const progressTimer = setInterval(() => {
65+
console.log(
66+
` progress: ${completed}/${REQUEST_COUNT} (${((completed / REQUEST_COUNT) * 100).toFixed(1)}%)`,
67+
);
68+
}, 2000);
69+
70+
const wallStart = performance.now();
71+
72+
const promises = Array.from({ length: REQUEST_COUNT }, (_, i) => {
73+
const client = clients[i % NUM_CONNECTIONS];
74+
return sendRequest(client).then((r) => {
75+
completed++;
76+
return r;
77+
});
78+
});
79+
80+
const results = await Promise.all(promises);
81+
const wallMs = performance.now() - wallStart;
82+
clearInterval(progressTimer);
83+
84+
for (const c of clients) c.close();
85+
86+
const latencies = results.map((r) => r.latencyMs).sort((a, b) => a - b);
87+
const statusCounts = new Map<number, number>();
88+
for (const r of results) {
89+
statusCounts.set(r.status, (statusCounts.get(r.status) ?? 0) + 1);
90+
}
91+
92+
console.log(`\n=== HTTP/2 Results ===`);
93+
console.log(`Requests: ${REQUEST_COUNT}`);
94+
console.log(`Connections: ${NUM_CONNECTIONS}`);
95+
console.log(`Wall clock: ${(wallMs / 1000).toFixed(2)}s`);
96+
console.log(`Throughput: ${(REQUEST_COUNT / (wallMs / 1000)).toFixed(1)} req/s`);
97+
if (latencies.length > 0) {
98+
console.log(`\nLatency (ms):`);
99+
console.log(` min: ${latencies[0].toFixed(1)}`);
100+
console.log(` p50: ${percentile(latencies, 50).toFixed(1)}`);
101+
console.log(` p90: ${percentile(latencies, 90).toFixed(1)}`);
102+
console.log(` p95: ${percentile(latencies, 95).toFixed(1)}`);
103+
console.log(` p99: ${percentile(latencies, 99).toFixed(1)}`);
104+
console.log(` max: ${latencies[latencies.length - 1].toFixed(1)}`);
105+
}
106+
console.log(`\nStatus codes:`);
107+
for (const [s, c] of [...statusCounts.entries()].sort()) console.log(` ${s}: ${c}`);
108+
}
109+
110+
main().catch((e) => {
111+
console.error(e);
112+
process.exit(1);
113+
});

0 commit comments

Comments
 (0)