DEV Community

Taylor Wang
Taylor Wang

Posted on

I Blamed the Thread Pool for 48 Hours. timeout=None Was the Quiet Hang.

Have you ever watched a worker sit in a thread pool and refuse to finish a batch? I did, and I spent two days blaming ThreadPoolExecutor for a hang that lived one layer lower. The workers looked healthy in process listings, and the queue length looked honest in every log line I added. What if the pool was fine, and one HTTP call had been willing to wait forever?

This is a field notebook rather than a victory lap, and I am writing down the ugly middle on purpose. I recorded what I tried, what broke, and what I would actually repeat on the next hang. You can steal the reproduction harness even if you throw away every paragraph of this narrative.

Hour 0–8: I blamed the pool because it was the loud object

The first hours went the way they always go when you are tired and want a visible villain. I logged the work queue size, printed future.running(), and shrank max_workers as if a smaller pool would confess. Nothing confessed, and the process sat at low CPU, which should have been the real clue. Hangs that never raise an exception stay invisible while telemetry on the pool looks perfectly busy.

Do you see how easy it is to instrument the object that has methods you already know? I kept adding pool telemetry because the pool is visible, named, and sitting in my stack traces. The missing timeout was not in those traces, so I treated the network as a slow teammate.

The worker I thought I understood

Here is the shape of the worker I thought I understood, shown as an example rather than production code.

# example worker — starting shape, not a production dump
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests

def fetch_one(url: str) -> tuple[str, int]:
    response = requests.get(url)  # no timeout: waits until the socket gives up
    return url, response.status_code

def fetch_all(urls: list[str]) -> dict[str, int]:
    results = {}
    with ThreadPoolExecutor(max_workers=8) as executor:
        futures = {executor.submit(fetch_one, url): url for url in urls}
        for future in as_completed(futures):  # also unbounded
            url, status = future.result()
            results[url] = status
    return results
Enter fullscreen mode Exit fullscreen mode

Look at fetch_one again and ask yourself which argument is missing from that requests.get call. I did not see it for a long time, because the happy path on my laptop always returned quickly. Without timeout, Requests waits until the socket layer gives up, and the default there is None. Forever is a very patient coworker, and as_completed will sit with that coworker until the process dies.

Current Requests docs still say you should almost always set timeout; missing it is wait-forever, not wait-a-reasonable-while. Python's socket.getdefaulttimeout() returning None is the same confession at process scope. I had both facts on the screen and still decorated the pool.

Hour 8–16: local Wi-Fi lied to me

My laptop sat on a fast network that almost never dropped a SYN or stalled a handshake. Every manual curl returned in milliseconds, so I decided the remote service had to be healthy enough. Was that a measurement I could defend, or just a vibe wearing a terminal font?

Comfort commands that did not comfort me

I ran the usual comfort commands and still failed to connect them into a single story.

curl -sS -D - -o /dev/null --max-time 2 https://example.invalid/health || true
python -c "import socket; print('default timeout', socket.getdefaulttimeout())"
ps -o pid,stat,wchan,etime,cmd -p "$PID"
Enter fullscreen mode Exit fullscreen mode

socket.getdefaulttimeout() printed None, and I nodded as if a printed None were a green light. I still did not pass timeout into Requests, which is the only place that printout mattered. The worker waited in a network channel, and I misread that wait as somebody else's slowness. Slow and eternal are not the same failure, even when both look quiet in a process listing.

Local reproduction failed because my path to the peer was short, warm, and embarrassingly well behaved. I needed a second environment that was not my kitchen table and not my usual DNS cache. Have you noticed how often "I cannot reproduce it locally" is just "my network is kinder than production"?

Hour 16–24: a free model drafted the harness, then I moved it

I did not need a generated architecture or a rewritten pool; I needed a server that accepts and never speaks. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access to draft a reproduction harness I could actually run. Then I ran that same harness on the free server option so my laptop's cheerful network could not hide the stall.

The prompt that was boring on purpose

The prompt I pasted was boring on purpose, because clever prompts kept producing a server that closed too fast.

Write a Python script that listens on 127.0.0.1, accepts one TCP connection, and never sends a response body. Then write a client that uses requests.get without a timeout, and a second client that sets timeout=(0.2, 0.2). Print which client returns. Do not close the accepted socket.

The model gave me something close to this, and I edited it until the hang was real.

# repro_hang.py — labeled example; run locally or on any spare host
import socket
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed

import requests

HOST, PORT = "127.0.0.1", 8765
held_sockets: list[socket.socket] = []


def silent_server() -> None:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
        server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        server.bind((HOST, PORT))
        server.listen(8)
        while True:
            conn, _addr = server.accept()
            held_sockets.append(conn)  # accept, hold, never read or write


def client(timeout):
    url = f"http://{HOST}:{PORT}/"
    started = time.monotonic()
    try:
        requests.get(url, timeout=timeout)
        status = "returned"
    except requests.Timeout:
        status = "timeout"
    except Exception as exc:
        status = type(exc).__name__
    return timeout, status, round(time.monotonic() - started, 3)


if __name__ == "__main__":
    threading.Thread(target=silent_server, daemon=True).start()
    time.sleep(0.2)
    with ThreadPoolExecutor(max_workers=2) as pool:
        jobs = [pool.submit(client, t) for t in (None, (0.2, 0.2))]
        for future in as_completed(jobs, timeout=5):
            print(future.result())
Enter fullscreen mode Exit fullscreen mode

Run it like this and watch which future actually prints before as_completed hits its own wall clock.

python -m pip install requests
python repro_hang.py
Enter fullscreen mode Exit fullscreen mode

What broke on the first generated draft, before I treated the model as a typist instead of a witness? The server accepted the client and closed immediately, so Requests raised ConnectionError instead of hanging around. A hang needs an open socket that never speaks, never resets, and never sends a clever HTTP status. I had to say that out loud to the model, and then I had to keep that sentence in the review.

On the free server option, the client with timeout=None did not come back before I cancelled the process. On my laptop I could have sworn the whole path was fine, because the peer answered before I blinked. Two environments beat one anecdote, especially when the anecdote is only your home network being kind.

The artifact: a timeout decision table you can paste into a review

I now refuse to merge an HTTP client that cannot fill these rows without leaving a blank cell. If a cell is blank, a hang is still possible, and the thread pool will look like the villain again.

Call site Connect timeout Read timeout Total budget Who retries What as_completed must use
User-facing request short, explicit short, explicit deadline from the caller limited, with jitter timeout= on as_completed too
Background fan-out short, explicit bounded by the SLA smaller than the worker lease at most N, never unbounded wall clock, not future count
Health check very short very short fail loud no do not wait on siblings
One-shot debug curl whatever you typed whatever you typed your patience you not applicable

Companion rules I now keep next to the table, because tables without rules become wallpaper in pull requests.

  1. Requests does not apply a default timeout, so a missing timeout argument means the call can wait forever. See the Requests timeout docs.
  2. socket.getdefaulttimeout() returning None is a process-wide confession, not permission to skip timeout in application code. See socket.setdefaulttimeout.
  3. as_completed without its own timeout will wait for every future, including the one stuck in a socket read. See concurrent.futures.
  4. future.cancel() will not rescue a thread that is already blocked in recv, so you must bound the socket.
  5. httpx ships with a default timeout in current docs, but switching libraries is not a substitute for a budget.

Petty tests that survive refactors

# test_fetch_timeout.py
import requests
from pytest import raises

def test_fetch_one_rejects_missing_timeout(monkeypatch):
    def fake_get(*args, **kwargs):
        assert kwargs.get("timeout") is not None, "timeout must be set"
        raise requests.Timeout("boom")

    monkeypatch.setattr(requests, "get", fake_get)
    with raises(requests.Timeout):
        requests.get("http://example.invalid", timeout=(0.2, 0.2))


def test_as_completed_has_its_own_budget():
    # documentation test: the call you merge must pass timeout= into as_completed
    import inspect
    from concurrent.futures import as_completed
    assert "timeout" in inspect.signature(as_completed).parameters
Enter fullscreen mode Exit fullscreen mode

That test is petty, and petty tests survive refactors when someone deletes an argument to make a line shorter. I also keep a slower check that boots the silent server, because monkeypatch cannot see a coworker switch clients. Would you trust a mock that never opens a socket, after this hang?

Commands I now run before I accuse the pool of eating work it never actually started.

python -c "import socket; print('default timeout', socket.getdefaulttimeout())"
rg -n "requests\.(get|post|put|delete|head|request)\(" -g "*.py"
rg -n "timeout\s*=" -g "*.py"
python -m pytest test_fetch_timeout.py -q
Enter fullscreen mode Exit fullscreen mode

If rg finds requests.get without a nearby timeout, I do not argue with a thread dump yet. Grep is cheaper than folklore, and folklore is how I lost the first eight hours of this notebook.

Hour 24–48: what broke, and what I would repeat

Several things broke, and only one of them was the missing timeout on the original client.

  • The generated server closed too fast, which turned a hang into a noisy connection error I misread as progress.
  • as_completed without a timeout made the test suite look wedged even after the client itself started failing loudly.
  • future.cancel() on a running network thread did nothing useful, which I relearned with too much leftover confidence.
  • Calling future.result() without a timeout reintroduced the same wait one layer above the HTTP client.
  • Running only on my laptop hid the hang, because the peer answered before I could even blink.

Checklist I would repeat

What I would repeat looks like a short checklist, not a new personality and not a bigger thread pool.

  1. Write the silent-server harness before you add another metric to the pool that is already innocent.
  2. Run that harness in a second environment, not only on the network path you already trust after lunch.
  3. Put timeout on the HTTP call and on as_completed, then make both values smaller than the caller's deadline.
  4. Add one petty unit test that forbids timeout is None at the exact call site you think is obvious.
  5. Read a network wait as blocked, not as proof that another team is slow on purpose today.

Would I still use a coding assistant for this kind of hang, after it first generated a server that closed? Yes, as a typist for the harness, never as a witness for a process sitting quietly in recv. The model did not feel the hang, and it could not see wchan on a machine I had not given it.

Limitations, and who should not copy this

This notebook is for application developers who own the client, not for people hunting a kernel scheduler bug. It is not an incident runbook, and it is not a load-test report with percentiles I never collected. I am not claiming a speedup, a quota, or a hardware profile, because I did not measure those things.

Do not treat a free model as an oracle for production timeouts, because it will invent round numbers that look neat. You still owe your caller a budget that matches the SLA you already published in a document somebody can find. Do not use the free server option as a production dependency, a compliance boundary, or a substitute for real staging. I used it as a second place to run a script that accepts a connection and then stays quiet on purpose.

If you are air-gapped, already have staging, or cannot send code to a hosted assistant, skip that hosted part. Keep the harness, the table, and the grep, because those do not depend on anybody's free tier. This approach also fails when the hang is not in HTTP at all, which happens more than we admit.

A lock, a DNS lookup stuck in libc, or a child process without a pipe reader can look similar in ps. Those failures will not be fixed by passing timeout=(0.2, 0.2) into a client that is not the blocked one. If the wait channel does not point at network wait, stop copying this notebook and start a different one.

What I keep on the sticky note

The pool was innocent, as_completed was obedient, and requests.get without timeout was the coworker who never went home. Two environments and one rude little server made that obvious after I stopped decorating the thread pool with logs. The review table is how I stop myself from forgetting, along with a grep I can run before the next review.

If you need a second environment for the silent-server harness, I reran mine on MonkeyCode's free server option after the model drafted the first sketch. The part I would keep even if that option disappeared is still the table, the petty test, and the missing argument.

Top comments (0)