Fixing Python requests That Hang Indefinitely Without Raising a Timeout Error
You kick off a Python script that calls an external API. A minute passes. Then five. The process is still running, your terminal cursor just blinks, and there is no exception anywhere. The requests library, by default, will wait forever for a server that never responds β and it will not say a word about it.
This is one of the most common causes of stuck CI jobs, frozen cron tasks, and runaway worker processes in production. The fix is straightforward once you understand what is actually happening under the hood.
What You'll Learn
- Why
requestssilently hangs even when you think a timeout is set - The difference between a connect timeout and a read timeout, and why you need both
- How to attach default timeouts to a
requests.Sessionso you never forget - When
timeout=is not enough and what to do about it - How to build a retry strategy that handles transient failures without hanging
Why requests Hangs Without Raising Any Error
The requests library is built on top of urllib3, which wraps Python's standard socket module. By default, Python sockets have no timeout at all β they block until the operating system decides something is wrong, which on most systems means several minutes or literally never, depending on TCP keepalive settings.
When you call requests.get(url) without a timeout argument, you are inheriting that socket-level behavior directly. The library does not impose any limit of its own. If the server accepts the TCP connection but then stops sending data mid-response, or if a load balancer holds the connection open without forwarding traffic, your process will sit there indefinitely.
This is not a bug in requests. The documentation explicitly warns that omitting timeout is dangerous in production. It is, however, easy to miss in tutorials and quick scripts, and the consequences only surface at the worst possible moment.
The Two Kinds of Timeouts You Need to Know
Before writing any code, it helps to be clear on what you are actually timing out. There are two distinct phases in an HTTP request where a hang can occur:
- Connect timeout β how long to wait while establishing the TCP connection to the server. This is typically short; if a host is unreachable, you usually find out within a few seconds.
- Read timeout β how long to wait between bytes arriving once the connection is open. This is where most indefinite hangs occur. The server is connected but just stops sending data.
Confusing these two is the source of a lot of misconfigured timeouts. Setting only a connect timeout does nothing to protect you from a server that connects instantly but then stalls mid-response.
Setting a Timeout: The Basics
The simplest fix is to pass a timeout value to any requests call. A single number applies to both phases:
import requests
response = requests.get("https://api.example.com/data", timeout=10)
This tells requests to raise a requests.exceptions.Timeout if either the connection or a read takes longer than 10 seconds. For most straightforward API calls this is a reasonable starting point.
Catch the exception explicitly so your program can recover gracefully:
import requests
from requests.exceptions import Timeout, ConnectionError
try:
response = requests.get("https://api.example.com/data", timeout=10)
response.raise_for_status()
except Timeout:
print("Request timed out β the server did not respond in time.")
except ConnectionError:
print("Network problem β could not reach the server.")
Notice raise_for_status() β it turns 4xx and 5xx responses into exceptions, which is usually what you want alongside timeout handling. This pattern also pairs naturally with structured exception handling elsewhere in your Python code.
Connect Timeout vs Read Timeout: Set Both Separately
Passing a tuple instead of a single number gives you independent control over each phase. The first element is the connect timeout; the second is the read timeout:
import requests
# 5 seconds to connect, 30 seconds to read
response = requests.get(
"https://api.example.com/large-export",
timeout=(5, 30)
)
Use a short connect timeout β anything beyond five seconds usually means the host is genuinely unreachable. Use a longer read timeout only when you expect the server to take time processing your request, like generating a large report or running a slow query. If you are debugging why a database-backed API is slow, the read side is where you should be looking; see the patterns described in fixing PostgreSQL slow queries for the server-side perspective.
For most external API calls, (5, 15) or (5, 30) is a sensible default. For internal microservice calls on a reliable network, (2, 10) is often aggressive enough to surface problems quickly.
Using a requests.Session with Default Timeouts
If you make multiple requests in the same script or service, you do not want to repeat the timeout argument on every call. requests.Session lets you configure shared settings, but it does not expose a built-in timeout attribute. The clean workaround is to subclass it:
import requests
class TimeoutSession(requests.Session):
def __init__(self, timeout=(5, 30)):
super().__init__()
self.default_timeout = timeout
def request(self, method, url, **kwargs):
kwargs.setdefault("timeout", self.default_timeout)
return super().request(method, url, **kwargs)
session = TimeoutSession(timeout=(5, 20))
response = session.get("https://api.example.com/users")
The setdefault call means any individual request can still override the timeout by passing its own value, but if nothing is specified the session default takes effect. This approach is much safer than relying on developers to remember the argument every time.
Handling Timeout Exceptions Correctly
A common mistake is catching Exception too broadly or catching the wrong exception class. Here is the exception hierarchy you need to know:
requests.exceptions.Timeoutβ raised when the connect or read phase exceeds the limit. This is the one you set withtimeout=.requests.exceptions.ConnectTimeoutβ a subclass ofTimeout, specifically for the connect phase.requests.exceptions.ReadTimeoutβ a subclass ofTimeout, specifically for the read phase.requests.exceptions.ConnectionErrorβ DNS failures, refused connections, network-level errors. Not a timeout, but worth catching alongside one.
from requests.exceptions import ConnectTimeout, ReadTimeout, ConnectionError
try:
response = session.get("https://api.example.com/data")
except ConnectTimeout:
# Host unreachable or DNS slow β likely a config or network issue
raise
except ReadTimeout:
# Connected fine but server stalled β may be worth retrying
print("Server connected but stopped responding. Will retry.")
except ConnectionError as exc:
print(f"Network error: {exc}")
Distinguishing ConnectTimeout from ReadTimeout matters for retry logic. A connect timeout often means the host is down; retrying immediately is unlikely to help. A read timeout might mean the server was overloaded briefly and a retry after a short wait could succeed.
When timeout= Still Isn't Enough: The Streaming Gotcha
There is one scenario where timeout= does not protect you at all: streaming responses. When you use stream=True and iterate over the response body, the read timeout applies only to each individual chunk, not to the total download time.
import requests
# timeout=(5, 30) means each chunk must arrive within 30 seconds
# but the total download could take hours
with requests.get("https://files.example.com/huge.csv", stream=True, timeout=(5, 30)) as resp:
for chunk in resp.iter_content(chunk_size=8192):
process(chunk)
If you need a wall-clock limit on the entire operation, you have to implement it yourself. The most portable approach is a thread with a deadline:
import requests
import threading
def download_with_deadline(url, timeout_seconds=60):
result = {"data": None, "error": None}
def fetch():
try:
resp = requests.get(url, timeout=(5, 10), stream=True)
chunks = []
for chunk in resp.iter_content(chunk_size=8192):
chunks.append(chunk)
result["data"] = b"".join(chunks)
except Exception as exc:
result["error"] = exc
thread = threading.Thread(target=fetch, daemon=True)
thread.start()
thread.join(timeout=timeout_seconds)
if thread.is_alive():
raise TimeoutError(f"Download did not complete within {timeout_seconds}s")
if result["error"]:
raise result["error"]
return result["data"]
The daemon=True flag is important β it prevents the thread from blocking process exit if your main thread finishes or raises an error first.
Using Signals for a Hard Wall on Unix Systems
On Linux and macOS, the signal module lets you set a hard time limit on any block of code, including blocking I/O that ignores regular timeouts. This is simpler than threads for single-threaded scripts:
import signal
import requests
class HardTimeout(Exception):
pass
def _handler(signum, frame):
raise HardTimeout("Request exceeded hard time limit")
signal.signal(signal.SIGALRM, _handler)
signal.alarm(15) # 15-second hard limit
try:
response = requests.get("https://api.example.com/data", timeout=(5, 10))
finally:
signal.alarm(0) # Cancel the alarm
The signal.alarm(0) in the finally block is non-negotiable. If you forget it, the alarm fires later and raises an exception in an unrelated part of your code. Note that signal.SIGALRM is not available on Windows, so this technique is Unix-only. For cross-platform code, use the threading approach above.
Building a Retry Strategy That Respects Timeouts
A timeout without a retry is often not enough on its own. Transient network issues and brief server overloads are normal, and a single failed request should not crash your script. The urllib3 Retry class, mounted on a session, gives you structured retry behavior:
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
def build_session(retries=3, backoff_factor=0.5):
session = requests.Session()
retry = Retry(
total=retries,
backoff_factor=backoff_factor,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "POST"],
raise_on_status=False,
)
adapter = HTTPAdapter(max_retries=retry)
session.mount("https://", adapter)
session.mount("http://", adapter)
return session
session = build_session()
try:
response = session.get("https://api.example.com/data", timeout=(5, 20))
response.raise_for_status()
except requests.exceptions.RetryError as exc:
print(f"All retries exhausted: {exc}")
except requests.exceptions.Timeout:
print("Request timed out after retries")
The backoff_factor controls the wait between retries. With a factor of 0.5, the waits are 0s, 1s, 2s for three retries. The status_forcelist determines which HTTP status codes trigger a retry β 429 is rate-limited, 502β504 are gateway or server errors. Note that Retry alone does not handle requests.exceptions.Timeout; you still need your own try/except around the call.
If you are making requests inside a Django application, consider also looking at how Django handles outbound connections β session management patterns there translate well to requests-based API clients. For data pipelines that call external APIs and then process the results, the same structured error-handling discipline applies when you store the response data downstream.
Common Pitfalls
- Setting timeout once at module level and forgetting it. If you set
requests.adapters.DEFAULT_RETRIESor monkey-patch socket defaults, those changes may not survive across threads or subprocesses. The session subclass approach is more explicit and reliable. - Catching
Exceptionand swallowing timeout errors silently. If your except block just logs and continues, a timeout starts looking like a success to downstream code. Always let the caller know something went wrong. - Assuming a short timeout means fast failure. If your server is under load and takes nine seconds to respond, a ten-second timeout still succeeds β it just adds nine seconds of latency to every failed attempt. Size your timeouts to your SLA, not to a round number.
- Not testing timeout behavior locally. Use a tool like
ncator a simple socket server that accepts connections but sends no data to verify your timeout and exception handling work before deploying. - Forgetting that proxies can introduce extra latency. If your requests go through an HTTP proxy, the connect timeout applies to reaching the proxy, not the origin server. A well-connected proxy that forwards to a slow origin will not trigger a connect timeout.
Wrapping Up: Next Steps
Silent hangs in Python HTTP code are almost always caused by a missing or incomplete timeout configuration. Here is what to do right now:
- Audit every
requestscall in your codebase. Search forrequests.get,requests.post, and similar calls that have notimeout=argument. Any one of them can freeze your process indefinitely. - Switch to a session subclass with a default timeout. The
TimeoutSessionpattern above ensures every request is protected, with a clean override path when you need it. - Use a tuple timeout to control connect and read phases independently. Start with
(5, 30)and tighten from there based on what your actual latency data shows. - Add explicit exception handling for
ConnectTimeoutandReadTimeoutso your application can respond differently to each failure mode. - Test your timeout handling against a mock slow server before you trust it. A simple Python socket server that accepts but never writes is all you need to verify that your exceptions fire correctly.
Frequently Asked Questions
Why does my Python requests call hang forever even though I set a timeout?
If you passed a single number as timeout, it applies to both the connect and read phases β but if you are using stream=True, the read timeout only covers each individual chunk, not the total download. Also check that your timeout argument is actually being passed; a Session does not have a built-in default timeout, so calls made through a session without explicit timeout will still hang.
What is the difference between ConnectTimeout and ReadTimeout in Python requests?
ConnectTimeout is raised when the library cannot establish a TCP connection within the allowed time. ReadTimeout is raised when the connection is open but the server stops sending data β this is the more common cause of indefinite hangs in production.
How do I set a default timeout for all requests in a requests.Session?
Subclass requests.Session, override the request() method, and use kwargs.setdefault('timeout', your_default) before calling super().request(). This applies the default to every call made through the session while still allowing individual overrides.
Can I use urllib3 Retry to automatically retry requests that time out?
The urllib3 Retry class handles HTTP-level retries on certain status codes but does not automatically retry on requests.exceptions.Timeout. You need to wrap your request call in a try/except and implement retry logic manually, or combine Retry with your own exception handling loop.
Is there a way to set a hard total time limit on a Python HTTP request regardless of chunk size?
Yes β on Unix systems you can use signal.alarm() with a SIGALRM handler to enforce a wall-clock limit on any blocking code. On Windows or in multithreaded code, run the request in a daemon thread and use thread.join(timeout=seconds) to impose the limit.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!