From 1b9dcad3b0a5e4158006ce5a468b9829fb90c969 Mon Sep 17 00:00:00 2001 From: fiatcode Date: Sun, 22 Feb 2026 13:30:18 +0700 Subject: [PATCH] blog: Building a Load Testing Script with Claude --- public/files/go-loadtest.7z | Bin 0 -> 9077 bytes ...uilding-load-testing-script-with-claude.md | 181 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 public/files/go-loadtest.7z create mode 100644 src/content/blog/building-load-testing-script-with-claude.md diff --git a/public/files/go-loadtest.7z b/public/files/go-loadtest.7z new file mode 100644 index 0000000000000000000000000000000000000000..1bd8e0d31ef1bd803c6ea1dbaeaaad6908056d88 GIT binary patch literal 9077 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 literal 0 HcmV?d00001 diff --git a/src/content/blog/building-load-testing-script-with-claude.md b/src/content/blog/building-load-testing-script-with-claude.md new file mode 100644 index 0000000..584336e --- /dev/null +++ b/src/content/blog/building-load-testing-script-with-claude.md @@ -0,0 +1,181 @@ +--- +title: "Building a Load Testing Script with Claude" +description: "How I built a custom load testing tool with Claude Sonnet 4.6" +date: 2026-02-22T13:23:00+07:00 +draft: false +tags: + - go + - engineering + - tooling + - ai +--- + +## From "I just want something simple" to a full-featured Go tool + +There's a specific kind of frustration that comes with evaluating load testing tools. You find one, it works, but it's either too bloated, requires a config file the size of a novel, or has a learning curve you didn't sign up for. You just want to hammer an endpoint, see some numbers, and move on. + +That was me last week. I needed to load test a REST API I was building at work. I didn't want to install k6, learn Gatling DSL, or spin up Locust just to fire some requests and see how the server holds up. I just wanted something simple — a script I could run from the terminal, point at a URL, and get results. + +So I turned to **Claude Sonnet 4.6** and said: let's build this together. + +--- + +### Why Build Instead of Use? + +Fair question. There are plenty of load testing tools out there. But rolling your own has real advantages: + +- You know exactly what it does +- Zero config files — everything is a CLI flag +- No hidden behavior, no telemetry, no account required +- You own it and can extend it however you want + +The trade-off is time. But when you have an AI pair programmer, that trade-off basically disappears. + +--- + +### The Design Conversation + +Before writing a single line of code, I had Claude walk through the design with me. This is actually something I've started doing consistently — rubber duck debugging, but the duck talks back and knows Go. + +We agreed on: + +- **Language: Go** — zero external dependencies, single binary output, goroutines are a natural fit for concurrency +- **CLI flags** for everything — no config files +- **A shared token bucket rate limiter** across workers, with an `--unlimited` escape hatch +- **Real-time live output** while the test runs +- **HTML report** with charts after each run, saved with a timestamp so it doesn't get overwritten + +The final flag interface looks like this: + +``` +--url Target endpoint URL (required) +--method HTTP method (default: GET) +--payload Inline JSON payload +--payload-file Path to a JSON payload file +--rps Target requests per second (default: 10) +--duration Test duration e.g. 30s, 1m, 2m30s (required) +--workers Concurrent workers (default: 1) +--unlimited Remove rate limiting entirely +--auth Raw Authorization header e.g. "Bearer abc123" +--header Extra headers, repeatable +--live-interval How often to print live stats (default: 1s) +--output HTML report path (default: reports/report_TIMESTAMP.html) +``` + +Clean. No surprises. + +--- + +### How It Works + +The script spins up N worker goroutines, all sharing a single rate limiter. The limiter is a simple ticker-based token bucket — each tick is one allowed request. Workers block on it, so collectively they stay within the target RPS. + +When `--unlimited` is passed, the limiter is skipped entirely and workers fire as fast as the server — and your machine — can handle. + +Each request is tied to a `context.WithTimeout`, which is how we get a clean shutdown when the duration expires. In-flight requests are cancelled immediately via the context, so the script always exits on time. + +A single collector goroutine receives all results from workers through a channel, appending them to a slice and updating live counters atomically. This keeps the workers fast — they never touch shared state directly. + +Here's the core worker loop, simplified: + +```go +func runWorker(ctx context.Context, cfg config, client *http.Client, limiter *rateLimiter, results chan<- Result, wg *sync.WaitGroup) { + defer wg.Done() + + for { + if ctx.Err() != nil { + return + } + + if limiter != nil { + if !limiter.Wait() { + return + } + if ctx.Err() != nil { + return + } + } + + result := doRequest(ctx, cfg, client) + + if ctx.Err() != nil { + return + } + + results <- result + } +} +``` + +The double context check around `limiter.Wait()` is intentional — the context can be cancelled while the worker is blocked waiting for a token, so we check again after it unblocks. + +--- + +### The Debugging Journey + +Nothing works perfectly on the first try. Here are the bugs we caught and fixed along the way. + +**Workers not stopping on time.** The first version used a manual `stop` channel. The problem: workers were blocking on in-flight HTTP requests when the stop signal came in, so the test would run 3-4x longer than the specified duration. Switched to `context.WithTimeout` + `http.NewRequestWithContext`, which cancels the TCP connection immediately on expiry. Fixed. + +**Live output printing line by line on Termux.** I also run tools on my Android phone via Termux. The `\r` carriage return trick to overwrite a single line wasn't working — each update was printing on a new line. We went through a few attempts: ANSI cursor codes, `bufio` flushing. Eventually traced it to Go's stdout buffering. The fix was `os.Stdout.WriteString()` which writes directly to the file descriptor without buffering, the same way shell `printf` does. + +**Results freezing mid-test but the timer kept going.** This was a race condition in the live display — it was reading from a copy of the results slice rather than the live counters. Introduced a dedicated `liveCounters` struct with a mutex that the collector updates directly, and the display reads from that instead. + +--- + +### Running It + +No dependencies, no setup. Just Go: + +```bash +# Run directly +go run loadtest.go --url https://your-api.com/endpoint --duration 30s + +# Or compile to a binary +go build -o loadtest loadtest.go +./loadtest --url https://your-api.com/endpoint --duration 30s + +# Cross-compile for Linux arm64 (e.g. Termux on Android) +GOOS=linux GOARCH=arm64 go build -o loadtest loadtest.go +``` + +Live output looks like this while running: + +``` +🎯 Target : GET https://your-api.com/endpoint +⏱ Duration: 30s +👷 Workers : 10 +⚡ RPS : unlimited + +[00:01] ⚡ 284 reqs | ✅ 284 | ❌ 0 | ⏱ Avg: 35ms +[00:02] ⚡ 571 reqs | ✅ 571 | ❌ 0 | ⏱ Avg: 34ms +[00:03] ⚡ 849 reqs | ✅ 847 | ❌ 2 | ⏱ Avg: 35ms +``` + +And after the test, a timestamped HTML report lands in `reports/` with a latency-over-time chart, status code distribution, and percentile breakdown. Dark themed, easy on the eyes. + +--- + +### A Note on Responsibility + +At one point during our testing session I accidentally ran 500 workers in unlimited mode against a server I didn't own. For about 30 seconds. From a VPS. + +Claude's response when I told him: a calm but very clear explanation that this is functionally a DDoS, that VPS providers will terminate accounts for it, and that intent doesn't matter — impact does. + +So: only run this against servers you own or have explicit written permission to test. The script is powerful enough to cause real harm if mispointed. + +--- + +### What I Learned + +The most useful part of building this with Claude wasn't the code — it was the design conversations before any code was written. Having to articulate what I wanted clearly enough for Claude to understand forced me to think through the architecture properly. By the time we started writing, the hard decisions were already made. + +Claude also caught things I would have missed. The double context check in the worker loop, the collector goroutine pattern to avoid shared mutable state in workers — these are the kinds of things that don't show up in a first draft but matter in production. + +The full script is a single `loadtest.go` file, 812 lines. A good chunk of that is the embedded HTML report template — the actual Go logic is well under half of that. No external dependencies. You can read the whole thing in one sitting and understand exactly what it does. + +That's the goal, isn't it? Code you understand is code you can trust. + +--- + +[**Download the script**](/files/go-loadtest.7z)