It's one thing for a from-scratch server to pass a conformance suite on a good day. It's another for it to survive a client that opens sixteen thousand connections and then just… stops talking. Writing your own HTTP server is a great way to learn protocols; it's also a great way to discover, in production, that you never handled the slow-client case. So before I trust BlackBull — my pure-Python protocol framework — with anything, I wanted two boring numbers: does it stay responsive under a Slowloris attack, and does it leak over a long soak? Here's how I measured both, and what came back.
(Part of my ongoing BlackBull series. Earlier posts cover the HTTP/2 and multi-protocol internals; this one is purely about robustness.)
Test 1: Slowloris — the attack of doing nothing, slowly
Slowloris is the laziest denial of service there is. You don't flood the server; you open a connection, send part of a request — a request line and one header — and then dribble a byte every few seconds, forever. Each half-open request ties up a slot. Open enough of them and a naive server runs out of room to accept anyone real. There's no bandwidth spike to alarm on; the server is simply, quietly, full.
The defence is a deadline. BlackBull gives every request a header-completion budget (BB_HEADER_TIMEOUT, 10 s by default): if the request headers aren't finished in time, the connection is reaped with a 408 before it ever reaches a handler or occupies a request task. That's the design. The question is whether it works — and, more usefully, what the curve looks like as the attacker scales up.
So I wrote a characterisation that holds N Slowloris connections open — each having sent a partial request head and nothing more — and then, while they're held, fires a burst of ordinary, legitimate requests and measures how long each one takes to be accepted and served. Sweep N from 0 to 16,384. A healthy server keeps serving real traffic with flat latency; a vulnerable one climbs, then starts timing out. On a clean c7i.2xlarge, 200 legitimate probes per point:
| Slowloris connections held | legitimate p99 | failures |
|---|---|---|
| 0 | 4.2 ms | 0 |
| 1,024 | 2.9 ms | 0 |
| 4,096 | 3.1 ms | 0 |
| 8,192 | 3.1 ms | 0 |
| 16,384 | 3.0 ms | 0 |
Fresh-connection latency is flat at ~3 ms all the way to 16,384 held connections, with zero failures across a thousand probes. The curve has no knee. The deadline reaper frees each slow slot before it can crowd the accept path, so the slow connections never occupy a request task — they cost a socket and nothing more.
The nice part is that the server tells you it's happening. With logging at INFO, each reaped connection prints exactly why:
408 Request Timeout (slowloris defence) — peer=('10.0.0.7', 41288)
sent 23 bytes in 10.0s without completing headers
That's the mechanism the number is measuring, made visible.
Test 2: the soak — leaks don't show up in five seconds
A throughput benchmark runs for 30 seconds and tells you nothing about a slow resource leak. File descriptors that never close, actor inboxes that grow unbounded, a buffer that's retained one request too long — these only show up over hours, and only if you're watching the right counters. So the second test is a soak: a fixed, moderate load held for a long time, with the process sampled the whole way through.
Two hours, 256 concurrent connections, mixed traffic (plaintext, JSON, a 1 KiB body), sampling RSS, file descriptors, connection count, and tracemalloc totals every 60 seconds. The pass/fail question is simple: after the initial warm-up, does anything trend?
| metric | start | end | 2nd-half drift |
|---|---|---|---|
| VmRSS | 48 MB | 78 MB | +0.1 % |
| tracemalloc.current | 9.1 MB | 6.1 MB | −33 % |
| established connections | 256 | 0 | — |
| open file descriptors | 265 | 8 | — |
Reading it: RSS climbs once during warm-up (workers fork, buffers and steady-state structures allocate) and then plateaus — the drift across the second hour is +0.1 %, i.e. flat. Python-tracked allocations actually trend down over the run, the opposite of a leak. And when the load stops, connections drain to zero and file descriptors fall back to eight — everything the run acquired, it gave back. Verdict: leak-free plateau. 120 samples with no trend is a much stronger statement than a clean 30-second window.
Why it holds: the actor model does the bookkeeping
Neither result is an accident of tuning; both fall out of how connections are structured. Every connection is owned by a ConnectionActor with its own lifecycle, and the deadline subsystem is a single scanner that reaps connections whose header/body/idle budgets have expired. There's no per-connection timer to leak and no shared lock to contend on — a slow or idle connection is just an entry the scanner will collect. When a connection ends, its actor and everything it owns are torn down together, which is why the file-descriptor count returns exactly to baseline.
The honest caveats, because a single run is a single run:
-
Not a security audit. This is a resilience characterisation, not adversarial red-teaming. Real hostile testing covers many more shapes — HTTP/2
RST_STREAMfloods, malformed-frame fuzzing, header-table attacks. This measures one well-known attack and one stability question. - One soak, moderate load. Two hours at 256 connections is enough to catch an obvious leak; it isn't days at saturation. A genuinely slow leak, or one that only appears under a specific protocol mix, could still be hiding. It's a floor, not a proof.
- Single-host numbers. The absolute latencies include loopback and co-tenancy; treat the shape (flat, no trend) as the result, not the millisecond values.
Try it
The Slowloris characterisation and the soak harness are both in the repo, and both are self-contained — the Slowloris tool even boots its own server, so it's a one-liner against a clean box:
pip install blackbull
git clone https://github.com/TOKUJI/BlackBull && cd BlackBull
# Slowloris: sweep held-connection counts, measure legitimate-request latency
python bench/hostile_repro/characterize_slowloris.py \
--sweep 0,1024,4096,8192,16384 --probes 200
Point it at your own server if you like — the interesting question isn't whether BlackBull holds up, it's whether yours does. If your framework doesn't enforce a header-completion deadline, the curve above is the one to go looking for.
Source, the harnesses, and the full numbers are at github.com/TOKUJI/BlackBull. The rest of the series covers how the pure-Python HTTP/2 and multi-protocol layers underneath actually work.
BlackBull v0.57.0, 2026-07-18.
Top comments (0)