DEV Community

Libme
Libme

Posted on

HTTP Request Hangs Forever in Production: Where Timeouts Actually Live in Node, Python, and Go

Most HTTP clients ship with no total deadline, and the ones that do have per-phase timeouts (connect, headers, body) that a slow or half-dead upstream can slip past. If a worker in your service is stuck for minutes on a single outbound call, the fix is not a bigger pool or a retry loop; it is an explicit deadline on every call, set at the call site, with the retry budget living inside that deadline. This post covers where those knobs are in Python requests and httpx, Node fetch and axios, and Go's net/http, and which of them actually cap wall-clock time.

What the symptom looks like

The incident that made me care about this looked like a database problem. p99 latency on one endpoint climbed from under a second to over a minute, error rate stayed near zero, CPU was flat, and the health check kept passing. The endpoint called a third-party enrichment API. That vendor was not down; it had stopped sending response bodies for a fraction of requests and left the TCP connection open.

On the Python side the tell was gunicorn logging this every thirty seconds:

[CRITICAL] WORKER TIMEOUT (pid:4127)
Enter fullscreen mode Exit fullscreen mode

Gunicorn kills a sync worker that has not returned in --timeout seconds, so the request came back as a 502 from the load balancer instead of an error we could catch. On the Node side there was no log line at all. The event loop was healthy, nothing threw, and the only evidence was a growing count of in-flight requests in the metrics and a few promises that simply never settled.

If you have a service where latency spikes with no matching error spike and no resource pressure, an outbound call with no deadline is the first thing to rule out.

Why "I set a timeout" usually is not enough

There are four separate clocks in an HTTP call, and most clients expose them separately:

  1. Connect: time to complete the TCP handshake (and TLS, in some clients).
  2. Read (or headers): time to wait for the response headers after the request is sent.
  3. Body / idle: the maximum gap between two chunks of the response body.
  4. Total: wall-clock time from the start of the call until the last byte is read.

The trap is number three. A "read timeout" in almost every library is an idle timeout between bytes, not a limit on how long the body takes. An upstream that trickles one byte every few seconds will never trip it. The same goes for a socket-level timeout in Node's http.request; the timeout option there is an idle timer on the socket, not a deadline.

Here is what each client gives you out of the box, as of mid-2026. Check the docs for your pinned version, because defaults do change.

Client Default connect Default read / headers Default body idle Built-in total deadline
Python requests none none none (per-byte read) no
Python httpx 5s 5s 5s (per-byte read) no
Node fetch (undici) 10s 300s headers 300s body no; use AbortSignal.timeout
Node axios none none (timeout: 0) not separately timeout option, off by default
Go net/http 30s dial (DefaultTransport) none none Client.Timeout, zero by default

Two things stand out. requests with no arguments will wait forever at every phase, which its own documentation warns about, and Node's built-in fetch will happily wait five minutes for headers. Neither is a bug; both are defaults nobody should ship with.

A read timeout bounds silence, not duration; only a total deadline bounds duration.

How do you set a real deadline in Python?

With requests, pass a tuple so connect and read are bounded separately:

import requests

resp = requests.get(
    "https://api.example.com/enrich",
    timeout=(3.05, 10),  # (connect, read) in seconds
)
resp.raise_for_status()
Enter fullscreen mode Exit fullscreen mode

That still is not a total deadline, because read resets on every byte. For sync code, the cleanest cap I have found is to stream the body and enforce the deadline yourself:

import time
import requests

def get_with_deadline(url, deadline_s=15, chunk=64 * 1024):
    start = time.monotonic()
    with requests.get(url, timeout=(3.05, 5), stream=True) as resp:
        resp.raise_for_status()
        buf = bytearray()
        for part in resp.iter_content(chunk):
            buf.extend(part)
            if time.monotonic() - start > deadline_s:
                raise TimeoutError(f"{url} exceeded {deadline_s}s total")
        return bytes(buf)
Enter fullscreen mode Exit fullscreen mode

With httpx the phases are explicit, and in async code you can add the total cap with the standard library:

import asyncio
import httpx

timeout = httpx.Timeout(connect=3.0, read=5.0, write=5.0, pool=2.0)

async def enrich(client: httpx.AsyncClient, payload: dict) -> dict:
    async with asyncio.timeout(15):  # Python 3.11+
        resp = await client.post("https://api.example.com/enrich", json=payload)
        resp.raise_for_status()
        return resp.json()

async def main():
    async with httpx.AsyncClient(timeout=timeout) as client:
        print(await enrich(client, {"id": 42}))

asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

The pool timeout matters more than it looks: it caps how long a call waits for a free connection, which is exactly what starts failing once a few hung calls have eaten your pool. If you are choosing a Python client for a new service, httpx is the one that makes every phase a named parameter instead of a positional tuple you have to remember the order of. Its honest downside is that HTTP/2 requires an extra install and the sync and async clients are different objects, so shared helper code needs a little care.

How do you set a real deadline in Node?

For built-in fetch, the total deadline is an abort signal, and it covers the body read as long as you consume the body while the signal is live:

async function enrich(payload) {
  const res = await fetch("https://api.example.com/enrich", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(payload),
    signal: AbortSignal.timeout(15_000),
  });
  if (!res.ok) throw new Error(`enrich failed: ${res.status}`);
  return res.json(); // aborts too if the deadline passes mid-body
}
Enter fullscreen mode Exit fullscreen mode

When it fires you get a DOMException named TimeoutError, which is worth checking for explicitly so you can distinguish deadline failures from network errors in your logs. The per-phase knobs live on the undici dispatcher (connectTimeout, headersTimeout, bodyTimeout); they are useful for tightening the five-minute defaults service-wide, but they do not replace the abort signal.

With axios, the timeout option is the deadline until a response arrives. Set it on the instance so nobody forgets it at a call site:

import axios from "axios";

export const enrichClient = axios.create({
  baseURL: "https://api.example.com",
  timeout: 15_000,
});
Enter fullscreen mode Exit fullscreen mode

Its drawback is that a long streaming download can outlive timeout once headers are in, so wrap responseType: "stream" calls in your own timer.

The one rule that survived every library change on my Node services: no fetch or axios call is allowed in review without a deadline visible in the same function.

How does Go get this right by default?

Go is the outlier in the table because http.Client.Timeout is a true wall-clock deadline that includes reading the body:

client := &http.Client{Timeout: 15 * time.Second}

ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
    return err
}
resp, err := client.Do(req)
Enter fullscreen mode Exit fullscreen mode

The catch is that http.DefaultClient has a zero Timeout, meaning none, so http.Get(url) in a handler is the same trap as requests.get(url). Use a context deadline as well; it propagates the remaining budget into downstream calls, which is the piece the other languages make you build by hand.

Where does the retry budget go?

Adding retries to a call with no deadline is how a slow upstream becomes an outage. Three retries on a call that hangs for five minutes is a fifteen-minute hold on a worker. The budget has to be shared: pick a total deadline for the operation, then let each attempt use what remains.

import time
import httpx

def call_with_budget(client: httpx.Client, url: str, budget_s: float = 10.0):
    deadline = time.monotonic() + budget_s
    attempt = 0
    while True:
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            raise TimeoutError(f"budget exhausted after {attempt} attempts")
        try:
            return client.get(url, timeout=httpx.Timeout(min(remaining, 4.0), connect=2.0))
        except (httpx.TimeoutException, httpx.TransportError):
            attempt += 1
            if attempt >= 3:
                raise
            time.sleep(min(0.2 * 2 ** attempt, remaining / 2))
Enter fullscreen mode Exit fullscreen mode

Each attempt gets at most four seconds, the backoff never sleeps past the deadline, and the whole thing is over in ten seconds no matter what the upstream does. Pair this with a dashboard that separates deadline errors from connection errors and you will know within one deploy whether the timeouts you picked are too tight.

A retry that can outlive the operation's deadline is not resilience; it is a multiplier on the outage.

FAQ

Does Python requests have a default timeout?
No. Without a timeout argument, requests will wait indefinitely for the connection and for every byte of the response. Always pass timeout=(connect, read).

What is the default timeout for fetch in Node.js?
There is no total deadline. As of mid-2026 the undici-based fetch defaults to roughly 10 seconds to connect and 300 seconds each for headers and body. Pass signal: AbortSignal.timeout(ms) to cap the whole call.

Is a read timeout the same as a total timeout?
No. A read timeout limits the gap between bytes, so a slow-trickling response never triggers it. A total timeout limits wall-clock time from the first byte sent to the last byte received.

Bottom line

If you write Python, set timeout on every requests call or move to httpx and wrap async calls in asyncio.timeout for a true cap. If you write Node, treat AbortSignal.timeout as mandatory on fetch and set timeout on your axios instance, then tighten undici's per-phase defaults service-wide. If you write Go, never use http.DefaultClient in production code and carry a context deadline through every hop. In all three, put retries inside the deadline, not around it.

Related reading

Top comments (0)