A third-party API started answering slowly. Not failing, just slow: 90 seconds per request. Within ten minutes every worker in the service was stuck waiting on it, the queue backed up, and the site went down. The API never returned a single error.
The root cause was one missing argument: timeout=.
TL;DR
- Several popular HTTP clients have no timeout by default, or one measured in minutes.
- Set at least two: a connect timeout (short) and a read/total timeout (based on what the call should take).
- A hung dependency without a timeout is worse than a dead one. Dead fails fast. Hung eats your capacity.
- My opinion: no outbound HTTP call should ship without an explicit timeout, and code review should block it.
The defaults are worse than you think
| Client | Default timeout |
|---|---|
Python requests | none, waits forever |
Python urllib.request | none unless passed or socket default set |
Node fetch (undici) | headers ~300 s, body ~300 s |
Go http.Client{} | none |
curl | no total limit, connect ~300 s |
"Waits forever" really means until the OS gives up on the TCP connection, which can take a very long time if the other side keeps the connection open and sends nothing.
Connect timeout vs read timeout
They protect against different failures:
- Connect timeout: the server doesn't answer the TCP handshake. Firewall dropping packets, wrong IP, host down. Should be short, 2 to 5 seconds.
- Read timeout: connected, but data stops flowing. Overloaded app, stuck query, slow upstream. Set from the call's normal latency, with headroom.
- Total deadline: an upper bound for the whole thing, including retries. The one users actually feel.
If you've ever seen ERR_CONNECTION_TIMED_OUT in a browser, that's the connect timeout firing on the user's side. How to fix ERR_CONNECTION_TIMED_OUT covers that view of the same problem.
Python (requests)
import requests resp = requests.get( "https://api.example.com/items", timeout=(3.05, 10), # (connect, read) ) The read timeout is between bytes, not total. A server dribbling one byte every 9 seconds never trips it. For a hard deadline, run the call in a worker with an overall limit, or use httpx with a pool timeout and an outer deadline:
import httpx timeout = httpx.Timeout(10.0, connect=3.0) with httpx.Client(timeout=timeout) as client: r = client.get("https://api.example.com/items") Node (fetch)
const res = await fetch('https://api.example.com/items', { signal: AbortSignal.timeout(8000), // total, including body }); AbortSignal.timeout is the simplest total deadline in modern Node. If you also want a short connect timeout, configure an undici Agent:
import { Agent, setGlobalDispatcher } from 'undici'; setGlobalDispatcher(new Agent({ connect: { timeout: 3000 }, headersTimeout: 8000, bodyTimeout: 8000 })); Go
client := &http.Client{ Timeout: 10 * time.Second, // total Transport: &http.Transport{ DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext, TLSHandshakeTimeout: 3 * time.Second, ResponseHeaderTimeout: 8 * time.Second, }, } http.DefaultClient has no timeout. Using it in a server is a classic way to leak goroutines during someone else's outage. Per-request contexts with context.WithTimeout are even better, because the deadline flows into everything downstream.
curl (scripts, cron, health checks)
curl --connect-timeout 3 --max-time 15 -sS https://api.example.com/items Every curl in a cron job or monitoring script should have both flags. A hung curl in cron quietly stacks up processes until the box runs out of something.
How to pick the numbers
- Measure the dependency's p99 latency for a week.
- Read timeout ≈ 2 to 3 × p99. If p99 is 800 ms, try 2 seconds.
- Connect timeout: 2 to 3 seconds for the public internet, under 1 second inside a data center.
- Total deadline: whatever the caller can tolerate. If your page must render in 3 seconds, a 10-second dependency call is already a bug.
And if you retry, the deadline covers all attempts. Three retries of a 10-second timeout is a 30-second request.
What timeouts look like from outside
When timeouts are missing, the symptom users see is rarely a clean error. It's a page that spins and then shows a 504 from the load balancer, or a 502 when the proxy gives up first. The difference is covered in 503 vs 504: what each error means and who has to fix it. Your own monitoring should use the same discipline, which is why the latency measurement in the alert tuning post starts with p95 before picking a timeout.
From outside, you can tell "slow" from "down" quickly with a tool like upordown.online or a single curl with -w '%{time_starttransfer}'. If time to first byte is huge but the status is 200, you're probably looking at a missing timeout somewhere behind the server, not a dead server.
My take
A timeout is a decision about what happens when a dependency misbehaves. Leaving the default isn't avoiding the decision. It's choosing "my service hangs too". Fail fast, return a degraded response or a cached value, and let the slow dependency be someone else's outage instead of yours.
Over to you
What timeouts do you use for third-party APIs, and do you enforce them in code review or with a shared client wrapper? I'd love to hear if anyone lints for missing timeouts automatically.
