When you write code that sends HTTP requests to an outside server, setting a timeout is something most people remember to do. But there are three kinds of timeouts with different jobs, and a single number cannot protect against all of them.
This article walks through connect, read, and total timeouts, with results from actually running Python’s requests library (2.34.0, Python 3.12.8). Some details are library-specific, but the ideas apply to other languages and HTTP clients as well.
Note: A “timeout” here means putting an upper limit on how long you wait for the other side and giving up when it is exceeded. Without one, a silent server can stall your code indefinitely.
Each timeout waits for something different
An HTTP request roughly goes: ① connect → ② send the request → ③ receive the response. Timeouts differ by which stage they are waiting on.
| Kind | What it waits for | Typical cause |
|---|---|---|
| Connect timeout | ① the TCP connection to be established | Server down, packets dropped along the route |
| Read timeout | ③ the next chunk of data to arrive | Server stuck processing, a slow query |
| Total timeout | ①–③ combined | “Slow but not stopped” states the other two miss |
In requests you can pass timeout=(connect, read) as a tuple. There is no argument for a total timeout (more on that below).
Experiment 1: A silent server hits the read timeout
I ran a local server that accepts the connection but sends nothing for 4 seconds after receiving the request, and called it with timeout=(3, 1).
import requests
requests.get("http://127.0.0.1:PORT/", timeout=(3, 1))
The result was a ReadTimeout after about 1.0 second. The connection succeeded instantly, so the 3-second connect budget was never used; the 1-second read budget fired. Passing a single number, timeout=1, also raised ReadTimeout after about 1.0 second, because a scalar applies to both connect and read.
Experiment 2: An unreachable server hits the connect timeout
Next I called an address that never answers (10.255.255.1, a non-responding private address) with timeout=(1, 5). The result was a ConnectTimeout after about 1.0 second; the 5-second read limit was never involved.
By contrast, a server whose port was simply closed (the refusal comes back immediately) produced a ConnectionError in about 0.0 seconds, regardless of the timeout. “Unreachable” and “refused” are different failures, with different waits and different exceptions.
- Packets silently dropped → no reply, so you wait until the connect timeout
- The other side explicitly refuses → fails immediately; timeouts don’t matter
When writing error handling, decide deliberately whether ConnectTimeout and ConnectionError get the same treatment.
Experiment 3: The read timeout is not an overall limit
This is the most commonly missed point. I made a server send a 12-byte body, one byte every 0.5 seconds, and called it with timeout=(3, 1).
The result: no timeout, a 200 OK, and about 5.5 seconds elapsed. The read limit is 1 second, yet the call took more than five.
The reason is that the read timeout measures the wait for the next data to arrive. As long as data keeps arriving every 0.5 seconds, the 1-second limit is never reached. So:
- Read timeout = a limit on how long the line stays silent
- It is not a limit on how long the whole request takes
Against a peer that trickles out a response, the read timeout alone may never end the call.
When you need a total timeout
For “this request must finish within N seconds no matter what,” the timeout argument of requests is not enough. Common approaches:
- Count elapsed time yourself. Read the body in chunks (
stream=True) and abort once the time since the start exceeds your limit. - Put a deadline on the caller. Wrap the whole operation in a thread, process, or other mechanism that enforces a deadline.
- Choose a client with built-in total timeouts. Some clients support it directly (it depends on the language and library).
In every case, the “total” deadline is something you own explicitly in your design.
Choosing the numbers
There is no single right answer, but there are useful axes:
- Keep connect timeouts short. Establishing a connection normally takes very little time; a few seconds is often enough. Longer values rarely raise the success rate and only delay failure detection.
- Match read timeouts to the peer’s processing time. Don’t give a heavy backup-style endpoint and a simple status endpoint the same value.
- Work backwards from how long a person can wait for the total limit. An on-screen result and an overnight batch call for different values.
- Estimate as a multiple of normal. Measure typical durations first, then use a few times that as a guide, so healthy requests aren’t cut but real stalls are caught.
Timeouts and retries
If you retry after a timeout, the practical worst-case wait is per-attempt limit × number of attempts. A 10-second read timeout with 3 attempts can mean up to 30 seconds of waiting. How to space the retries is covered in Exponential Backoff vs. Fixed-Interval Retries.
Also, for requests that write something, a timeout can fire after the other side has already finished the work. “Timed out” does not always mean “failed,” so retrying assumes the operation is safe to run twice.
For timeouts when running external commands, see Calling External Commands from Python.
Summary
- Timeouts come in three kinds by what they wait for: connect, read, total.
- The
requeststimeoutcovers connect and read only — it is not an overall limit (it won’t stop a peer that sends data slowly). - Treat “unreachable” (connect timeout) and “refused” (connection error) as different failures.
- If you need a total deadline, build it into your own design.
- With retries, limit × attempts is the worst-case wait.
“I set a timeout” and “I can explain after how many seconds, in which situation, it will cut” are different things. It may be worth checking which of the three kinds each timeout= in your code actually guards.