|
| 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