Skip to content

Fix: Python requests.get() Hanging — Timeout Not Working

FixDevs · (Updated: )

Part of:  Python Errors

Quick Answer

How to fix Python requests hanging forever — why requests.get() ignores timeout, how to set connect and read timeouts correctly, use session-level timeouts, and handle timeout exceptions properly.

The Error

A requests.get() call hangs indefinitely and never returns:

import requests

# This hangs forever if the server is slow or unresponsive
response = requests.get('https://api.example.com/data')

Or you set a timeout but it still hangs:

response = requests.get('https://api.example.com/data', timeout=5)
# Still hangs for minutes...

Or the timeout raises an exception you did not catch, crashing your program:

requests.exceptions.ReadTimeout: HTTPSConnectionPool(host='api.example.com', port=443):
Read timed out. (read timeout=5)

Or in a web server context, a single hanging request blocks a worker thread and eventually causes the entire server to stop responding.

Why This Happens

By default, requests has no timeout — it will wait forever for a response. This is almost never what you want in production code.

When you do set a timeout, there are two distinct phases where a timeout can occur:

  1. Connect timeout — how long to wait to establish the TCP connection to the server. If the server is unreachable or behind a firewall that drops packets (rather than refusing the connection), the connect phase hangs.

  2. Read timeout — how long to wait for the server to send the first byte of the response body after the connection is established. A server that accepts the connection but is slow to respond causes a read timeout.

timeout=5 sets both to 5 seconds. But if you set timeout=(30, 5), it means 30 seconds for connect, 5 seconds for read — and the connect phase can still hang if the server silently drops packets.

Other causes:

  • Large response body — the read timeout applies to the time between bytes, not the total transfer time. A slow server that trickles data can never trigger the read timeout.
  • Retry logic retrying on timeout — if your code retries indefinitely on ReadTimeout, the overall call still hangs.
  • DNS resolution hanging — DNS resolution happens before the connect phase and is not covered by requests’s timeout.
  • TLS handshake stalls — the TLS handshake happens after TCP connect but is also bounded by the connect timeout in modern urllib3 versions. Old urllib3 versions did not enforce a timeout during the handshake itself.
  • Proxy in between — a corporate HTTP proxy that authenticates lazily can swallow the connect timeout and replace it with its own (much longer) timeout.
  • HTTP keep-alive on a half-open connectionrequests.Session reuses connections. If the remote silently dropped the connection but the local socket is still open, the next request hangs until the kernel TCP keepalive kicks in (default: 2 hours on Linux).

Fix 1: Always Set a Timeout

The most important rule: never call requests without a timeout in production code.

import requests

# Bad — no timeout
response = requests.get('https://api.example.com/data')

# Good — 10 second timeout for both connect and read
response = requests.get('https://api.example.com/data', timeout=10)

# Best — separate connect and read timeouts
# connect: 5s to establish TCP connection
# read: 30s to wait for response bytes
response = requests.get('https://api.example.com/data', timeout=(5, 30))

What timeout=(connect, read) means:

# (5, 30) means:
# - Wait up to 5 seconds to establish the TCP connection
# - Wait up to 30 seconds between bytes received from the server
# - Does NOT mean the total request must complete in 35 seconds

response = requests.get(url, timeout=(5, 30))

For large file downloads, the read timeout applies per-chunk — a large download can take longer than the read timeout as long as data arrives continuously.

Fix 2: Handle Timeout Exceptions

Setting a timeout without catching the exception causes your program to crash:

import requests
from requests.exceptions import Timeout, ConnectionError, RequestException

def fetch_data(url: str) -> dict | None:
    try:
        response = requests.get(url, timeout=(5, 30))
        response.raise_for_status()  # Raises HTTPError for 4xx/5xx
        return response.json()

    except Timeout:
        print(f"Request timed out: {url}")
        return None

    except ConnectionError as e:
        print(f"Connection failed: {e}")
        return None

    except requests.HTTPError as e:
        print(f"HTTP error {e.response.status_code}: {url}")
        return None

    except RequestException as e:
        # Catch-all for any other requests error
        print(f"Request failed: {e}")
        return None

Distinguish connect timeout from read timeout:

from requests.exceptions import ConnectTimeout, ReadTimeout

try:
    response = requests.get(url, timeout=(5, 30))
except ConnectTimeout:
    # Server is unreachable — do not retry immediately
    print("Could not connect to server")
except ReadTimeout:
    # Connected but server is slow — might retry with longer timeout
    print("Server connected but timed out sending response")

Fix 3: Use a Session with a Default Timeout

If you make many requests, configure the timeout once on a Session object:

import requests
from requests.adapters import HTTPAdapter

class TimeoutHTTPAdapter(HTTPAdapter):
    def __init__(self, *args, timeout=(5, 30), **kwargs):
        self.timeout = timeout
        super().__init__(*args, **kwargs)

    def send(self, request, **kwargs):
        kwargs.setdefault('timeout', self.timeout)
        return super().send(request, **kwargs)

# Create a session with a default timeout on all requests
session = requests.Session()
adapter = TimeoutHTTPAdapter(timeout=(5, 30))
session.mount('http://', adapter)
session.mount('https://', adapter)

# All requests through this session respect the timeout
response = session.get('https://api.example.com/data')
response = session.post('https://api.example.com/items', json={'name': 'test'})

Simpler approach — monkey-patch the default timeout:

import requests

# Override the default send method to always include a timeout
original_send = requests.Session.send

def patched_send(self, *args, **kwargs):
    kwargs.setdefault('timeout', (5, 30))
    return original_send(self, *args, **kwargs)

requests.Session.send = patched_send

# Now all requests have the default timeout
requests.get('https://api.example.com/data')  # Uses (5, 30) timeout

Pro Tip: Use the TimeoutHTTPAdapter pattern in library code where you cannot guarantee callers will pass a timeout. In application code, always pass timeout explicitly to make the behavior clear.

Fix 4: Set a Total Request Timeout with a Thread or Signal

requests’s timeout parameter does not guarantee the total time. For a hard time limit on the entire operation (including retries, redirects, and large response reading):

Using a thread with concurrent.futures:

from concurrent.futures import ThreadPoolExecutor, TimeoutError
import requests

def fetch_with_hard_timeout(url: str, total_timeout: float = 10.0) -> dict:
    with ThreadPoolExecutor(max_workers=1) as executor:
        future = executor.submit(
            requests.get, url, timeout=(5, 30)
        )
        try:
            response = future.result(timeout=total_timeout)
            return response.json()
        except TimeoutError:
            future.cancel()
            raise TimeoutError(f"Total request time exceeded {total_timeout}s")

Using signal (Unix only — not available on Windows):

import signal
import requests

class HardTimeout(Exception):
    pass

def timeout_handler(signum, frame):
    raise HardTimeout("Request exceeded hard timeout")

def fetch_with_signal_timeout(url: str, hard_limit: int = 15) -> dict:
    signal.signal(signal.SIGALRM, timeout_handler)
    signal.alarm(hard_limit)  # SIGALRM fires after hard_limit seconds

    try:
        response = requests.get(url, timeout=(5, 30))
        response.raise_for_status()
        return response.json()
    except HardTimeout:
        raise
    finally:
        signal.alarm(0)  # Cancel the alarm

Fix 5: Use Retry Logic with Backoff

For transient timeouts, retry with exponential backoff instead of crashing:

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

def create_session_with_retry(
    retries: int = 3,
    backoff_factor: float = 0.5,
    timeout: tuple = (5, 30),
) -> requests.Session:
    session = requests.Session()

    retry = Retry(
        total=retries,
        read=retries,
        connect=retries,
        backoff_factor=backoff_factor,
        status_forcelist=[429, 500, 502, 503, 504],  # Retry on these HTTP codes
        allowed_methods=['GET', 'POST'],
    )

    adapter = HTTPAdapter(max_retries=retry)
    session.mount('http://', adapter)
    session.mount('https://', adapter)

    # Patch for default timeout
    original_send = session.send
    def send_with_timeout(*args, **kwargs):
        kwargs.setdefault('timeout', timeout)
        return original_send(*args, **kwargs)
    session.send = send_with_timeout

    return session

# Usage
session = create_session_with_retry()
response = session.get('https://api.example.com/data')

Warning: Retry from urllib3 retries on connection failures, but not on ReadTimeout by default. Add raise_on_status=False and handle ReadTimeout manually if you need to retry on timeouts:

retry = Retry(total=3, read=3, connect=3, backoff_factor=0.5)
# This retries connect failures (3 times) and read failures (3 times separately)

Fix 6: Add a Circuit Breaker

Retrying with backoff helps with transient failures, but if the upstream service is genuinely down, retries amplify the load and tie up worker threads. A circuit breaker stops calls once the failure rate crosses a threshold:

import time
from dataclasses import dataclass, field
from typing import Callable
import requests
from requests.exceptions import RequestException


@dataclass
class CircuitBreaker:
    failure_threshold: int = 5
    recovery_timeout: float = 30.0
    failures: int = 0
    opened_at: float = 0
    state: str = "closed"  # closed | open | half-open

    def call(self, fn: Callable, *args, **kwargs):
        # If open, refuse calls until recovery_timeout elapses
        if self.state == "open":
            if time.time() - self.opened_at > self.recovery_timeout:
                self.state = "half-open"
            else:
                raise RuntimeError("Circuit breaker is open")

        try:
            result = fn(*args, **kwargs)
            # Success closes a half-open breaker
            if self.state == "half-open":
                self.state = "closed"
                self.failures = 0
            return result
        except RequestException:
            self.failures += 1
            if self.failures >= self.failure_threshold:
                self.state = "open"
                self.opened_at = time.time()
            raise


breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=30.0)


def fetch(url: str):
    return breaker.call(
        requests.get, url, timeout=(3, 10)
    )

pybreaker is the production-ready library version with metrics and listeners.

Pro Tip: Pair a circuit breaker with a cache fallback. When the breaker is open, return the last-known-good value from cache instead of failing the request. The user sees slightly stale data instead of an error, and the upstream service gets time to recover.

Fix 7: Fix DNS Hanging (Timeout Doesn’t Help)

DNS resolution in requests uses Python’s standard socket.getaddrinfo() which is not covered by the requests timeout parameter. A DNS resolution that hangs will block indefinitely regardless of your timeout setting:

import socket
import requests

# Check if DNS resolution is the problem
try:
    ip = socket.getaddrinfo('api.example.com', 443, timeout=5)
    print(f"DNS resolved to: {ip}")
except socket.gaierror as e:
    print(f"DNS lookup failed: {e}")

Workaround — use dnspython with a timeout:

import dns.resolver
import requests

def resolve_with_timeout(hostname: str, timeout: float = 5.0) -> str:
    resolver = dns.resolver.Resolver()
    resolver.lifetime = timeout
    answers = resolver.resolve(hostname, 'A')
    return str(answers[0])

# Or use httpx which handles DNS timeouts better
import httpx

async def fetch():
    async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
        response = await client.get('https://api.example.com/data')
        return response.json()

Consider switching to httpx for async or better timeout control:

import httpx

# Synchronous with full timeout control
with httpx.Client(timeout=httpx.Timeout(5.0, connect=3.0, read=30.0)) as client:
    response = client.get('https://api.example.com/data')
    return response.json()

# Async
async with httpx.AsyncClient(timeout=5.0) as client:
    response = await client.get('https://api.example.com/data')

In Production: Incident Lens

A hanging HTTP client is the textbook way for a fast service to be taken down by a slow dependency. The owning team often does not realize the failure mode until a user-facing service is degraded.

Surface. Latency dashboards show a p99 (or p99.9) spike on a single endpoint while p50 stays flat — classic “long tail” pattern. Worker pool saturation alerts fire because every worker is blocked waiting for a response. Upstream services show increased error rates (often 502/504 from load balancers as health checks fail). On Kubernetes, kubectl describe pod shows livenessProbe failures and the pod gets restarted, which masks the root cause as “pod was flaky.”

Blast radius. Depends on whether the failing call is on the critical path. A blocking call inside a request handler ties up one worker per active call — a Gunicorn or uWSGI worker pool of 20 fills in seconds against a slow upstream, returning 503 to every other request. If the call is async or off-path (analytics ping, audit log), the user-facing impact is minimal but the worker pool still leaks. The worst case is a cascading failure: service A times out waiting for B, A’s clients time out waiting for A, and the timeout pressure propagates outward.

Alerting. Page on per-dependency error rate (5xx + timeouts) > 1% sustained for 5 minutes. Track upstream call duration as a histogram per dependency, and alert on p99 > SLO. Worker pool utilization above 80% sustained is a leading indicator — workers are stuck. For each external dependency, define a budget (e.g., “Stripe must respond within 2s, p99”) and alert when reality diverges.

Recovery. First, contain the blast: trip the circuit breaker manually if you have one, otherwise add a rate limit at the gateway to throttle calls to the failing dependency. Cached fallback (Redis, in-memory) keeps the user experience degraded but functional. Restart workers to clear stuck connections — keep-alive can pin a worker to a dead socket for hours otherwise. Long-term, push the dependency owner for a faster recovery path or work around them.

Preventive. Always split connect and read timeouts (timeout=(3, 10)), and pick values aggressive enough that a stuck dependency cannot consume more than ~5% of your SLO budget. Retry with jitter, not pure exponential backoff — uncoordinated retries from many clients create thundering-herd failures. Implement bulkhead isolation: a dedicated thread pool or semaphore per external dependency, so a slow Stripe call cannot starve workers needed for Twilio. Add synthetic monitoring (Prometheus blackbox exporter, Datadog Synthetics) that hits each dependency from the same network the production service uses. Run a quarterly chaos drill: inject 5-second delays into each dependency and observe what breaks.

Still Not Working?

Confirm the hang is in requests and not your code. Add debug logging:

import logging
import requests

logging.basicConfig(level=logging.DEBUG)
# This shows every step: DNS resolution, TCP connect, TLS handshake, headers, body

response = requests.get('https://api.example.com/data', timeout=10)

Test with curl to isolate the issue:

# If curl also hangs, the problem is server-side or network-level
curl -v --max-time 10 https://api.example.com/data

# Check DNS resolution time
time curl -o /dev/null -s -w "%{time_namelookup}\n" https://api.example.com/data

Check for a proxy that is blocking or hanging the connection:

# Disable proxies explicitly
response = requests.get(url, timeout=10, proxies={'http': None, 'https': None})

# Or check what proxies are configured
import urllib.request
print(urllib.request.getproxies())

Check for connection pool starvation. requests.Session defaults to 10 connections per host. Under high concurrency, requests wait for a free connection — the wait does not count against timeout until a connection becomes available. Configure with HTTPAdapter(pool_connections=50, pool_maxsize=50) and monitor urllib3.poolmanager.PoolManager logs at DEBUG level.

Check for TCP-level socket leaks. A long-running process that creates many requests.Session objects without closing them leaks sockets. Run lsof -p <pid> | grep TCP | wc -l and watch the count over time. Always use with session: or call session.close() explicitly. The socket-leak limit is ulimit -n (default 1024) — exceeding it raises OSError: [Errno 24] Too many open files.

Check for an IPv6 vs IPv4 race (Happy Eyeballs). Some networks have broken IPv6 routing. requests resolves AAAA records first; if those go to a black hole, the connect timeout fires per address. Force IPv4 by patching socket.getaddrinfo or use httpx which has cleaner Happy Eyeballs handling.

For related Python networking issues, see Fix: Python ConnectionError Max Retries Exceeded, Fix: Python SSL Certificate Verify Failed, Fix: Python asyncio Not Running, and Fix: Postgres Max Connections Exceeded.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles