Stale pooled connections: find out what your pool does
Break it on purposeNeeds Python with urllib3
Problem
A connection pool reuses open connections to avoid the cost of setting one up for every request. It also carries risks that are invisible in the code: what happens when every connection is busy (wait, fail, or open more), and what happens when a pooled connection's other end has died without closing it, as a NAT timeout or a crashed load balancer does. The first request on such a connection fails or hangs until its timeout. Every library makes these choices by default, and few teams know which.
Using your language's HTTP client or database pool, (1) set the pool size to 2 and start 10 slow requests (a server that sleeps for 5 s) and see what happens to the 3rd request: does it block forever, time out, or fail at once? (2) Open a connection through a socat or nginx proxy, then kill the proxy and restart it, and send a request on the pooled connection to see the "first write after silent death" failure. (3) Turn on validate-on-borrow or a shorter max idle time and repeat. Write down the three default values of your library: exhaustion policy, max idle time and max lifetime.
The script uses urllib3 and a Python proxy that dies silently (it stops forwarding without closing its sockets). urllib3 has no idle-time or lifetime setting, so the third step uses a retry instead.
The question. With urllib3, what happens to the third of ten slow requests on a pool of two, and to the first request on a connection whose proxy died silently?
Predict
Expect the third request to block or to bypass the pool depending on the library's exhaustion policy, and the first request after a silent death to fail, unless the library retries it on a new connection.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
stale-pooled-connections-find-out-what.py # /// script # requires-python = ">=3.10" # dependencies = ["urllib3"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Stale pooled connections: find out what your pool does. With urllib3's pool: (1) pool size 2 and 10 slow requests (the server sleeps 5 s): what happens to the 3rd? (2) A connection through a TCP proxy that is killed and restarted: the first write on the pooled connection after its silent death. (3) The same with a retry on that first failure. Prints the library's defaults for exhaustion, max idle time and max lifetime. """ import argparse, json, socket, threading, time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import urllib3 p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--slow-s", type=float, default=5.0, help="Seconds the slow endpoint sleeps.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() class H(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def do_GET(self): if self.path == "/slow": time.sleep(args.slow_s) self.send_response(200); self.send_header("Content-Length", "2"); self.end_headers(); self.wfile.write(b"ok") def log_message(self, *a): pass srv = ThreadingHTTPServer(("127.0.0.1", 0), H); threading.Thread(target=srv.serve_forever, daemon=True).start() backend = srv.server_address[1] rows = [] # (1) exhaustion: block=True waits for a free connection; block=False opens extra ones and discards them for block in (False, True): pool = urllib3.HTTPConnectionPool("127.0.0.1", backend, maxsize=2, block=block, timeout=urllib3.Timeout(read=30)) started = {} def call(i): t = time.perf_counter(); pool.request("GET", "/slow"); started[i] = time.perf_counter() - t ts = [threading.Thread(target=call, args=(i,)) for i in range(10)] t0 = time.perf_counter(); [t.start() for t in ts]; [t.join() for t in ts] total = time.perf_counter() - t0 third = sorted(started.values())[2] rows.append({"experiment": f"10 slow requests, pool of 2, block={block}", "result": f"3rd request finished after {third:.1f} s; all done after {total:.1f} s"}) print(rows[-1]["experiment"], "->", rows[-1]["result"]) # (2) and (3): a proxy that dies silently between two requests on a pooled connection class Proxy: """A TCP proxy. blackhole() is a silent death: it stops forwarding and stops listening, but sends no FIN or RST, as a NAT timeout or a crashed middlebox does.""" def __init__(self, port): self.port, self.sock, self.conns, self.dead = port, None, [], False def start(self, listen_port=0): self.sock = socket.socket(); self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.sock.bind(("127.0.0.1", listen_port)); self.sock.listen(8) threading.Thread(target=self.loop, daemon=True).start(); return self.sock.getsockname()[1] def loop(self): while True: try: c, _ = self.sock.accept() except OSError: return u = socket.create_connection(("127.0.0.1", self.port)); self.conns += [c, u] for a, b in ((c, u), (u, c)): threading.Thread(target=self.pipe, args=(a, b), daemon=True).start() def pipe(self, a, b): try: while (d := a.recv(65536)): if self.dead: continue # swallow everything, reply nothing b.sendall(d) except OSError: pass def blackhole(self): self.dead = True; self.sock.close() for retries, label in ((0, "first request after the proxy died silently and restarted, no retry"), (1, "same, with one retry")): proxy = Proxy(backend); port = proxy.start() pool = urllib3.HTTPConnectionPool("127.0.0.1", port, maxsize=1, retries=retries, timeout=urllib3.Timeout(connect=2, read=2)) pool.request("GET", "/") # the connection goes back to the pool proxy.blackhole(); time.sleep(0.2); fresh = Proxy(backend); fresh.start(port) # a new proxy on the same port t = time.perf_counter() try: r = pool.request("GET", "/"); outcome = f"status {r.status} after {time.perf_counter() - t:.1f} s" except Exception as e: outcome = f"failed after {time.perf_counter() - t:.1f} s: {type(e).__name__}" rows.append({"experiment": label, "result": outcome}); print(label, "->", outcome) fresh.blackhole() defaults = urllib3.HTTPConnectionPool("x") d = {"exhaustion (block)": f"block={defaults.block}: extra connections are opened and discarded, no waiting", "max idle time": "none (no idle eviction)", "max lifetime": "none", "retries on a stale connection": f"{urllib3.Retry.DEFAULT if hasattr(urllib3.Retry, 'DEFAULT') else 'Retry(3)'}"} for k, v in d.items(): print(f"default {k}: {v}") rows += [{"experiment": f"default: {k}", "result": v} for k, v in d.items()] if args.json: with open(args.json, "w") as f: json.dump({"tables": {"experiments": rows}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run stale-pooled-connections-find-out-what.py uv run stale-pooled-connections-find-out-what.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --slow-s5 | Seconds the slow endpoint sleeps. |
Our run
urllib3
| Experiment | Result |
|---|---|
| 10 slow requests, pool of 2, block=False | 3rd request finished after 5.0 s; all done after 5.0 s |
| 10 slow requests, pool of 2, block=True | 3rd request finished after 10.0 s; all done after 25.0 s |
| first request after the proxy died silently and restarted, no retry | failed after 2.0 s: MaxRetryError |
| same, with one retry | status 200 after 2.0 s |
| default: exhaustion (block) | block=False: extra connections are opened and discarded, no waiting |
| default: max idle time | none (no idle eviction) |
| default: max lifetime | none |
| default: retries on a stale connection | Retry(total=3, connect=None, read=None, redirect=None, status=None) |
10 slow requests, pool of 2, block=False -> 3rd request finished after 5.0 s; all done after 5.0 s
10 slow requests, pool of 2, block=True -> 3rd request finished after 10.0 s; all done after 25.0 s
first request after the proxy died silently and restarted, no retry -> failed after 2.0 s: MaxRetryError
same, with one retry -> status 200 after 2.0 s
default exhaustion (block): block=False: extra connections are opened and discarded, no waiting
default max idle time: none (no idle eviction)
default max lifetime: none
default retries on a stale connection: Retry(total=3, connect=None, read=None, redirect=None, status=None)What we found
With urllib3's default (block = False), the pool did not limit anything: the third request finished with the others after 5 s, because urllib3 opens extra connections when the pool is empty and discards them afterwards. With block = True, it waited: 3rd request finished after 10.0 s; all done after 25.0 s. The first request on a connection whose proxy had died silently failed after the 2 s read timeout (failed after 2.0 s: MaxRetryError); with one retry it succeeded on a new connection, after the same 2 s wait. urllib3 has no idle-time or lifetime limit at all.
Read it
A pool's defaults are design decisions someone else made. A pool that silently opens extra connections hides exhaustion until the server's connection limit is reached; one that blocks without a timeout turns a slow dependency into a hung service. Set an explicit size, an explicit wait timeout, and either a max idle time shorter than the network's idle timeout or a validation on borrow, and treat the first request on a reused connection as one that may fail.