-
Notifications
You must be signed in to change notification settings - Fork 3
feat: add HTTP/2 load testing infrastructure #798
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| node_modules/ | ||
| package-lock.json |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import tls from "node:tls"; | ||
|
|
||
| const BASE_URL = process.env.RUNLOOP_BASE_URL ?? "https://api.runloop.pro"; | ||
| const url = new URL(BASE_URL); | ||
|
|
||
| console.log(`Checking ALPN for ${url.hostname}:${url.port || 443}`); | ||
|
|
||
| const socket = tls.connect( | ||
| { | ||
| host: url.hostname, | ||
| port: parseInt(url.port || "443", 10), | ||
| ALPNProtocols: ["h2", "http/1.1"], | ||
| servername: url.hostname, | ||
| }, | ||
| () => { | ||
| console.log(`Negotiated protocol: ${socket.alpnProtocol}`); | ||
| console.log(`TLS version: ${socket.getProtocol()}`); | ||
| socket.end(); | ||
| }, | ||
| ); | ||
|
|
||
| socket.on("error", (err) => { | ||
| console.error("TLS error:", err.message); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import http2 from "node:http2"; | ||
|
|
||
| const BASE_URL = process.env.RUNLOOP_BASE_URL ?? "https://api.runloop.pro"; | ||
| const API_KEY = process.env.RUNLOOP_API_KEY!; | ||
|
|
||
| const body = JSON.stringify({ | ||
| blueprint_id: "bp_nonexistent_loadtest_00000", | ||
| name: "loadtest-h2s-0", | ||
| environment_variables: { TEST_VAR_1: "value_one" }, | ||
| launch_parameters: { resource_size_request: "SMALL" }, | ||
| }); | ||
|
|
||
| function sendRequest(client: http2.ClientHttp2Session): Promise<{ latencyMs: number; status: number }> { | ||
| return new Promise((resolve, reject) => { | ||
| const start = performance.now(); | ||
| const req = client.request({ | ||
| ":method": "POST", | ||
| ":path": "/v1/devboxes", | ||
| "content-type": "application/json", | ||
| authorization: `Bearer ${API_KEY}`, | ||
| }); | ||
| req.on("response", (headers) => { | ||
| const status = headers[":status"] as number; | ||
| req.on("data", () => {}); | ||
| req.on("end", () => resolve({ latencyMs: performance.now() - start, status })); | ||
| }); | ||
| req.on("error", reject); | ||
| req.end(body); | ||
| }); | ||
| } | ||
|
|
||
| async function main() { | ||
| const url = new URL(BASE_URL); | ||
| const client = http2.connect(url.origin); | ||
| await new Promise<void>((resolve, reject) => { | ||
| client.on("connect", resolve); | ||
| client.on("error", reject); | ||
| }); | ||
|
|
||
| const maxStreams = client.remoteSettings?.maxConcurrentStreams; | ||
| console.log(`Server MAX_CONCURRENT_STREAMS: ${maxStreams}`); | ||
|
|
||
| // Warmup | ||
| const w = await sendRequest(client); | ||
| console.log(`Warmup: status=${w.status}, latency=${w.latencyMs.toFixed(0)}ms`); | ||
|
|
||
| // Burst 50 requests on single warmed connection | ||
| const count = 50; | ||
| console.log(`\nBursting ${count} requests on 1 warmed connection...`); | ||
| const wallStart = performance.now(); | ||
| const results = await Promise.all(Array.from({ length: count }, () => sendRequest(client))); | ||
| const wallMs = performance.now() - wallStart; | ||
|
|
||
| const lats = results.map((r) => r.latencyMs).sort((a, b) => a - b); | ||
| console.log(`${count} requests in ${wallMs.toFixed(0)}ms (${(count / (wallMs / 1000)).toFixed(1)} req/s)`); | ||
| console.log(`Latency: min=${lats[0].toFixed(0)}ms p50=${lats[Math.floor(count / 2)].toFixed(0)}ms max=${lats[lats.length - 1].toFixed(0)}ms`); | ||
|
|
||
| client.close(); | ||
| } | ||
|
|
||
| main().catch((e) => { console.error(e); process.exit(1); }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| import http2 from "node:http2"; | ||
|
|
||
| const REQUEST_COUNT = parseInt(process.env.REQUEST_COUNT ?? "10000", 10); | ||
| const NUM_CONNECTIONS = parseInt(process.env.NUM_CONNECTIONS ?? "10", 10); | ||
| const BASE_URL = process.env.RUNLOOP_BASE_URL ?? "https://api.runloop.pro"; | ||
| const API_KEY = process.env.RUNLOOP_API_KEY!; | ||
|
|
||
| const body = JSON.stringify({ | ||
| blueprint_id: "bp_nonexistent_loadtest_00000", | ||
| name: "loadtest-h2-0", | ||
| environment_variables: { TEST_VAR_1: "value_one", TEST_VAR_2: "value_two" }, | ||
| metadata: { test_run: "h2", index: "0" }, | ||
| launch_parameters: { resource_size_request: "SMALL", keep_alive_time_seconds: 300 }, | ||
| }); | ||
|
|
||
| function percentile(sorted: number[], p: number): number { | ||
| const idx = Math.ceil((p / 100) * sorted.length) - 1; | ||
| return sorted[Math.max(0, idx)]; | ||
| } | ||
|
|
||
| function connectH2(origin: string): Promise<http2.ClientHttp2Session> { | ||
| return new Promise((resolve, reject) => { | ||
| const client = http2.connect(origin); | ||
| client.on("connect", () => resolve(client)); | ||
| client.on("error", reject); | ||
| }); | ||
| } | ||
|
|
||
| function sendRequest( | ||
| client: http2.ClientHttp2Session, | ||
| ): Promise<{ latencyMs: number; status: number }> { | ||
| return new Promise((resolve, reject) => { | ||
| const start = performance.now(); | ||
| const req = client.request({ | ||
| ":method": "POST", | ||
| ":path": "/v1/devboxes", | ||
| "content-type": "application/json", | ||
| authorization: `Bearer ${API_KEY}`, | ||
| }); | ||
| req.on("response", (headers) => { | ||
| const status = headers[":status"] as number; | ||
| req.on("data", () => {}); | ||
| req.on("end", () => resolve({ latencyMs: performance.now() - start, status })); | ||
| }); | ||
| req.on("error", reject); | ||
| req.end(body); | ||
| }); | ||
| } | ||
|
|
||
| async function main() { | ||
| console.log( | ||
| `HTTP/2 test: ${REQUEST_COUNT} requests, ${NUM_CONNECTIONS} connections to ${BASE_URL}`, | ||
| ); | ||
|
|
||
| const url = new URL(BASE_URL); | ||
| const clients = await Promise.all( | ||
| Array.from({ length: NUM_CONNECTIONS }, () => connectH2(url.origin)), | ||
| ); | ||
|
|
||
| const maxStreams = clients[0].remoteSettings?.maxConcurrentStreams; | ||
| console.log(`Server MAX_CONCURRENT_STREAMS: ${maxStreams ?? "unknown"}`); | ||
| console.log(`${NUM_CONNECTIONS} connections established\n`); | ||
|
|
||
| let completed = 0; | ||
| const progressTimer = setInterval(() => { | ||
| console.log( | ||
| ` progress: ${completed}/${REQUEST_COUNT} (${((completed / REQUEST_COUNT) * 100).toFixed(1)}%)`, | ||
| ); | ||
| }, 2000); | ||
|
|
||
| const wallStart = performance.now(); | ||
|
|
||
| const promises = Array.from({ length: REQUEST_COUNT }, (_, i) => { | ||
| const client = clients[i % NUM_CONNECTIONS]; | ||
| return sendRequest(client).then((r) => { | ||
| completed++; | ||
| return r; | ||
| }); | ||
| }); | ||
|
|
||
| const results = await Promise.all(promises); | ||
| const wallMs = performance.now() - wallStart; | ||
| clearInterval(progressTimer); | ||
|
|
||
| for (const c of clients) c.close(); | ||
|
|
||
| const latencies = results.map((r) => r.latencyMs).sort((a, b) => a - b); | ||
| const statusCounts = new Map<number, number>(); | ||
| for (const r of results) { | ||
| statusCounts.set(r.status, (statusCounts.get(r.status) ?? 0) + 1); | ||
| } | ||
|
|
||
| console.log(`\n=== HTTP/2 Results ===`); | ||
| console.log(`Requests: ${REQUEST_COUNT}`); | ||
| console.log(`Connections: ${NUM_CONNECTIONS}`); | ||
| console.log(`Wall clock: ${(wallMs / 1000).toFixed(2)}s`); | ||
| console.log(`Throughput: ${(REQUEST_COUNT / (wallMs / 1000)).toFixed(1)} req/s`); | ||
| console.log(`\nLatency (ms):`); | ||
| console.log(` min: ${latencies[0].toFixed(1)}`); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: If request count is zero, no latency values exist and this call throws because Severity Level: Major
|
||
| console.log(` p50: ${percentile(latencies, 50).toFixed(1)}`); | ||
| console.log(` p90: ${percentile(latencies, 90).toFixed(1)}`); | ||
| console.log(` p95: ${percentile(latencies, 95).toFixed(1)}`); | ||
| console.log(` p99: ${percentile(latencies, 99).toFixed(1)}`); | ||
| console.log(` max: ${latencies[latencies.length - 1].toFixed(1)}`); | ||
| console.log(`\nStatus codes:`); | ||
| for (const [s, c] of [...statusCounts.entries()].sort()) console.log(` ${s}: ${c}`); | ||
| } | ||
|
|
||
| main().catch((e) => { | ||
| console.error(e); | ||
| process.exit(1); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| import { Runloop } from "@runloop/api-client"; | ||
|
|
||
| const REQUEST_COUNT = parseInt(process.env.REQUEST_COUNT ?? "100000", 10); | ||
| const RUNLOOP_BASE_URL = process.env.RUNLOOP_BASE_URL; | ||
| const USE_HTTP2 = process.env.USE_HTTP2 === "1"; | ||
| const PROGRESS_INTERVAL_MS = 2000; | ||
|
|
||
| function buildClient(): Runloop { | ||
| const opts: ConstructorParameters<typeof Runloop>[0] = { | ||
| maxRetries: 0, | ||
| timeout: 120_000, | ||
| }; | ||
| if (RUNLOOP_BASE_URL) { | ||
| opts.baseURL = RUNLOOP_BASE_URL; | ||
| } | ||
| if (USE_HTTP2) { | ||
| (opts as any).http2 = true; | ||
| } | ||
| return new Runloop(opts); | ||
| } | ||
|
|
||
| interface RequestResult { | ||
| index: number; | ||
| latencyMs: number; | ||
| status: number | null; | ||
| error: string | null; | ||
| } | ||
|
|
||
| async function sendRequest( | ||
| client: Runloop, | ||
| index: number, | ||
| runId: string, | ||
| ): Promise<RequestResult> { | ||
| const start = performance.now(); | ||
| try { | ||
| await client.devboxes.create({ | ||
| blueprint_id: "bp_nonexistent_loadtest_00000", | ||
| name: `loadtest-${runId}-${index}`, | ||
| environment_variables: { | ||
| TEST_VAR_1: "value_one", | ||
| TEST_VAR_2: "value_two", | ||
| }, | ||
| metadata: { | ||
| test_run: runId, | ||
| index: String(index), | ||
| }, | ||
| launch_parameters: { | ||
| resource_size_request: "SMALL", | ||
| keep_alive_time_seconds: 300, | ||
| }, | ||
| }); | ||
| return { | ||
| index, | ||
| latencyMs: performance.now() - start, | ||
| status: 200, | ||
| error: null, | ||
| }; | ||
| } catch (err: any) { | ||
| return { | ||
| index, | ||
| latencyMs: performance.now() - start, | ||
| status: err?.status ?? null, | ||
| error: err?.message ?? String(err), | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| function percentile(sorted: number[], p: number): number { | ||
| const idx = Math.ceil((p / 100) * sorted.length) - 1; | ||
| return sorted[Math.max(0, idx)]; | ||
| } | ||
|
|
||
| function printMetrics(results: RequestResult[], wallClockMs: number): void { | ||
| const latencies = results.map((r) => r.latencyMs).sort((a, b) => a - b); | ||
|
|
||
| const statusCounts = new Map<string, number>(); | ||
| for (const r of results) { | ||
| const key = r.status != null ? String(r.status) : "network_error"; | ||
| statusCounts.set(key, (statusCounts.get(key) ?? 0) + 1); | ||
| } | ||
|
|
||
| console.log("\n=== Load Test Results ==="); | ||
| console.log(`Requests: ${results.length}`); | ||
| console.log(`Wall clock: ${(wallClockMs / 1000).toFixed(2)}s`); | ||
| console.log( | ||
| `Throughput: ${(results.length / (wallClockMs / 1000)).toFixed(1)} req/s`, | ||
| ); | ||
| console.log(""); | ||
| console.log("Latency (ms):"); | ||
| console.log(` min: ${latencies[0].toFixed(1)}`); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: When Severity Level: Major
|
||
| console.log(` p50: ${percentile(latencies, 50).toFixed(1)}`); | ||
| console.log(` p90: ${percentile(latencies, 90).toFixed(1)}`); | ||
| console.log(` p95: ${percentile(latencies, 95).toFixed(1)}`); | ||
| console.log(` p99: ${percentile(latencies, 99).toFixed(1)}`); | ||
| console.log(` max: ${latencies[latencies.length - 1].toFixed(1)}`); | ||
| console.log(""); | ||
| console.log("Status codes:"); | ||
| for (const [status, count] of [...statusCounts.entries()].sort()) { | ||
| console.log(` ${status}: ${count}`); | ||
| } | ||
| } | ||
|
|
||
| async function main(): Promise<void> { | ||
| const fdLimit = await checkFileDescriptorLimit(); | ||
| if (!USE_HTTP2 && fdLimit < 10000) { | ||
| console.warn( | ||
| `\nWARNING: File descriptor limit is ${fdLimit}. For 100k HTTP/1.1 requests, run:\n` + | ||
| ` ulimit -n 65536\n` + | ||
| `Or use HTTP/2 multiplexing: USE_HTTP2=1\n`, | ||
| ); | ||
| } | ||
|
|
||
| const client = buildClient(); | ||
| const runId = `run-${Date.now()}`; | ||
|
|
||
| console.log(`Starting load test: ${REQUEST_COUNT} concurrent requests`); | ||
| console.log(`Run ID: ${runId}`); | ||
| console.log(`HTTP mode: ${USE_HTTP2 ? "HTTP/2 (undici)" : "HTTP/1.1 (node-fetch)"}`); | ||
| console.log(`Base URL: ${RUNLOOP_BASE_URL ?? "(SDK default)"}`); | ||
| console.log(`File descriptor limit: ${fdLimit}`); | ||
| console.log(""); | ||
|
|
||
| let completed = 0; | ||
| const progressTimer = setInterval(() => { | ||
| console.log( | ||
| ` progress: ${completed}/${REQUEST_COUNT} (${((completed / REQUEST_COUNT) * 100).toFixed(1)}%)`, | ||
| ); | ||
| }, PROGRESS_INTERVAL_MS); | ||
|
|
||
| const wallStart = performance.now(); | ||
|
|
||
| const promises = Array.from({ length: REQUEST_COUNT }, (_, i) => | ||
| sendRequest(client, i, runId).then((result) => { | ||
| completed++; | ||
| return result; | ||
| }), | ||
| ); | ||
|
|
||
| const results = await Promise.all(promises); | ||
|
|
||
| const wallClockMs = performance.now() - wallStart; | ||
| clearInterval(progressTimer); | ||
|
|
||
| printMetrics(results, wallClockMs); | ||
| } | ||
|
|
||
| async function checkFileDescriptorLimit(): Promise<number> { | ||
| try { | ||
| const { execSync } = await import("child_process"); | ||
| return parseInt(execSync("ulimit -n", { encoding: "utf-8" }).trim(), 10); | ||
| } catch { | ||
| return -1; | ||
| } | ||
| } | ||
|
|
||
| main().catch((err) => { | ||
| console.error("Fatal error:", err); | ||
| process.exit(1); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| { | ||
| "name": "runloop-loadtest", | ||
| "private": true, | ||
| "type": "module", | ||
| "scripts": { | ||
| "test": "tsx loadtest.ts" | ||
| }, | ||
| "dependencies": { | ||
| "@runloop/api-client": "file:..", | ||
| "tsx": "^4.0.0", | ||
| "undici": "^7.26.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/node": "^25.9.3" | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion:
NUM_CONNECTIONSis fully user-configurable, but zero is not validated; with0, no clients are created and this access dereferencesundefined, crashing before any test runs. Validate that connection count is at least 1 before building clients and readingclients[0]. [incorrect condition logic]Severity Level: Major⚠️
Steps of Reproduction ✅
Fix in Cursor | Fix in VSCode Claude
(Use Cmd/Ctrl + Click for best experience)
Prompt for AI Agent 🤖