Defending Async Servers Against Slow Clients¶
An asyncio server handles idle connections cheaply — a suspended task and a socket each — which is why a slow-client ("slowloris") attack does not exhaust its CPU or memory the way it exhausts a thread-per-connection server. What it exhausts instead is file descriptors: every connection holds one, and a process has a hard limit. Measured on Python 3.14 against a minimal ASGI app, with clients that opened a connection, sent a partial request header, and then sent one more header line every five seconds without ever finishing: with its defaults, uvicorn 0.54 kept all 1,000 such connections open for 30 seconds and closed none, though it still served ordinary requests in a median of 2.0 ms. With the process's open-file limit at 1,024 — a common container default — 1,100 slow clients used up every descriptor and 0 of 20 ordinary requests succeeded. Adding --limit-concurrency 500 changed nothing, because the slow connections never complete a request to be counted. Hypercorn 0.18 with its defaults closed every slow connection after its 5-second keep-alive timeout and served 15 of 15 requests under the same attack. This guide closes slow connections before they close the server.
Prerequisites¶
- Python 3.11+,
pip install uvicorn hypercorn httpx. - ASGI servers, from choosing between uvicorn, Hypercorn and Granian.
- The topic overview, Securing Async Services.
1. Reproduce a slow-client attack against your server¶
A slow client needs only a socket and patience; reproduce it with asyncio:
async def slow_client(host: str, port: int) -> None:
reader, writer = await asyncio.open_connection(host, port)
writer.write(b"GET / HTTP/1.1\r\nHost: x\r\n") # headers never finished
await writer.drain()
try:
while True:
await asyncio.sleep(5)
writer.write(b"X-a: b\r\n") # keep the connection busy
await writer.drain()
except ConnectionError:
pass # the server closed it
async def attack(host: str, port: int, n: int) -> None:
await asyncio.gather(*(slow_client(host, port) for _ in range(n)), return_exceptions=True)
While the attack runs, issue an ordinary request every second and record whether it succeeds, and sample the server process's open file descriptors (psutil.Process(pid).num_fds()). Measured against uvicorn with a generous descriptor limit: all 1,000 slow connections stayed open for the full 30 seconds, the server held 1,014 descriptors, and ordinary requests still succeeded — asyncio was not overwhelmed by idle connections. The danger is the limit, not the load.
Verify: run the attack in staging at a connection count just above the server's ulimit -n, and observe what happens to ordinary requests.
2. Know which timeout covers which phase¶
Servers have several timeouts, and only some apply to a connection that has not finished sending its request:
# uvicorn
uvicorn.run(app, timeout_keep_alive=5) # idle time *between* requests on a kept-alive connection
uvicorn.run(app, limit_concurrency=500) # concurrent requests/connections before 503s
# Neither closed connections that were still sending their first request headers.
# Hypercorn
config = hypercorn.config.Config()
config.keep_alive_timeout = 5.0 # also closed connections stuck before a complete request
Measured: uvicorn closed none of the slow connections over 30 seconds with its default timeout_keep_alive of 5 seconds, and --limit-concurrency 500 did not reduce the number held — neither setting governs the phase before a complete request has arrived. Hypercorn's keep_alive_timeout (5 seconds by default) did close them: every slow connection was gone after a few seconds, and the descriptor count returned to 7. Behaviour like this changes between server versions, so treat the measurement as a test to repeat on yours rather than a permanent property of either server.
Verify: for your server and version, a connection that sends half a request and stalls is closed within a known time.
3. Put a proxy with header timeouts in front¶
The most reliable defence is a component built for hostile clients: a reverse proxy or load balancer that buffers requests and enforces header and body timeouts before anything reaches Python:
server {
listen 443 ssl;
client_header_timeout 10s; # time allowed to send the request headers
client_body_timeout 10s; # time between body reads
client_max_body_size 10m;
keepalive_timeout 30s;
location / {
proxy_pass http://127.0.0.1:8000; # uvicorn sees only complete requests
proxy_request_buffering on;
}
}
With request buffering, the proxy absorbs slow uploads and slow headers, and the application server receives complete requests over a fast local connection — the slow-client problem disappears from the Python process entirely. Managed load balancers provide the same protection with their own idle and header timeouts. This is the same division of labour as terminating TLS at the edge, discussed in TLS & DNS.
Verify: the slow-client test against the proxy's address leaves the application server's descriptor count unchanged.
4. Raise the descriptor limit and watch it¶
Whatever else is in place, the descriptor limit should be far above expected concurrent connections, and the current count should be visible:
import resource
def raise_nofile(target: int = 65_536) -> int:
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
new = min(target, hard)
if soft < new:
resource.setrlimit(resource.RLIMIT_NOFILE, (new, hard))
return new
async def report_fds(interval: float = 10.0) -> None:
proc = psutil.Process()
limit = resource.getrlimit(resource.RLIMIT_NOFILE)[0]
while True:
OPEN_FDS.set(proc.num_fds())
FD_LIMIT.set(limit)
await asyncio.sleep(interval)
Measured: at a soft limit of 1,024, eleven hundred slow connections were enough to deny service completely. On the test host the hard limit was 524,288, so the process could have raised its own soft limit at startup; in containers the limit is set by the runtime (--ulimit nofile=... in Docker, the container runtime's defaults in Kubernetes). A raised limit turns an easy attack into an expensive one, but it is a cushion, not a fix — the connections still need timeouts. The descriptor gauge also catches ordinary leaks, as in detecting leaked sockets and file descriptors.
Verify: the service logs its descriptor limit at startup, and an alert fires when open descriptors exceed 70% of it.
5. Bound what complete requests can hold, too¶
Slow clients also come with complete headers and a slow body, or with a request that is fast but asks for a slow streaming response. Bound those in the application where the server does not:
async def app(scope, receive, send):
if scope["type"] != "http":
return
try:
async with asyncio.timeout(15): # whole request, including body
body = await read_body(receive, limit=10 * 2**20) # size limit, too
except TimeoutError:
await send({"type": "http.response.start", "status": 408, "headers": []})
await send({"type": "http.response.body", "body": b""})
return
await handle(scope, body, send)
A request-level timeout covers slow bodies once the server has handed the request to the application, and the size limit stops large uploads from occupying memory, as in limiting request body size in ASGI apps. For responses, the server's write path applies backpressure to slow readers, and a deadline on the handler — as in enforcing request timeouts in ASGI servers — keeps a slow reader from holding a streaming response open indefinitely.
Verify: a client that sends headers and then trickles its body is answered with 408 within the request timeout.
Verification¶
An async server withstands slow clients when:
- Something closes connections that never complete their headers — a proxy, or a server measured to do so.
- The descriptor limit is high and monitored, with alerts well before it is reached.
- Request bodies have time and size limits, and handlers have deadlines.
- A slow-client test runs in staging against the real deployment path.
Diagnostic Hook: track open file descriptors against established TCP connections from the load balancer. Descriptors rising while request rate stays flat means connections are being held without completing requests — the signature of slow clients or a leak — and the server logs will show nothing, because no request was ever completed.
Pitfalls & edge cases¶
- Trusting keep-alive timeouts. Measured: uvicorn's did not close connections still sending headers.
- Trusting concurrency limits. Measured:
--limit-concurrency 500did not help. - Default descriptor limits. Measured: 1,024 descriptors fell to 1,100 slow clients.
- Testing only the app server. The proxy in front is part of the defence; test the full path.
Frequently Asked Questions¶
Is uvicorn vulnerable to slowloris attacks?
In testing with uvicorn 0.54 defaults, connections that never finished their headers stayed open indefinitely; with a 1,024 file-descriptor limit, 1,100 such clients made every ordinary request fail. Put a proxy with header timeouts in front and raise the descriptor limit.
Does --limit-concurrency protect uvicorn from slow clients?
It did not in testing: the slow connections never complete a request, and ordinary requests still failed at the descriptor limit.
Does Hypercorn close slow connections?
In testing, Hypercorn 0.18's 5-second keep-alive timeout closed connections stuck before a complete request, and it kept serving ordinary requests under the same attack.
Why doesn't asyncio run out of memory with thousands of idle connections?
Each idle connection is a suspended task and a socket — cheap. The limit that runs out is file descriptors, one per connection.
Related¶
- Securing Async Services — up to the topic overview.
- Verifying webhook signatures in async handlers — authenticating requests once they arrive.
- Resilience, Cancellation & Error Handling — the section overview.