Python Request Timeout: The Four Parameters a Working App Actually Sets

Sean

Platform Writer

Jun 10, 2026
5 min read

A Python request timeout is the timeout= argument on requests.get(), requests.post(), or any other call in the requests library. The argument is a number of seconds, and the argument is what the library uses to bound how long the call can hang. Without a timeout, a requests.get() can hang for minutes (or forever, if the server is misbehaving), and the calling application is stuck. The timeout is the lever that turns a hang into a fast failure.

The reason “python request timeout” is its own question and not just “use a timeout” is that the argument has a connect-timeout and a read-timeout split, a tuple form, a default behavior, and a set of gotchas (session reuse, retries, proxies, DNS) that quietly break a request that was supposed to be safe. The four are the part the developer has to know to set the timeout correctly.

Python request timeout: the four parameters a working app actually sets

Table of contents

The short version

The requests library takes a timeout= argument on every method (get, post, put, patch, delete, head, options). The argument is in seconds, and the argument bounds how long the call can take. Without a timeout, the call can hang for the OS’s default TCP timeout (which is on the order of minutes on most systems). With a timeout, the call raises a requests.exceptions.Timeout exception when the timeout expires. The exception is the right answer for a working app — the app catches the exception, retries, or fails fast.

The three forms the timeout argument takes

The timeout= argument accepts three forms. The three are not interchangeable, and the developer should know which one they want before they write the call.

A single number. requests.get(url, timeout=5) — the call has 5 seconds total, including the connect time and the read time. The form is the most common one, and the form is the right answer for a developer who wants a single bound on the call. The form is also the form most likely to be wrong, because the connect and read are bundled together.

A tuple of (connect, read). requests.get(url, timeout=(3.2, 27)) — the connect has 3.2 seconds, the read has 27 seconds. The form is the right answer for a developer who wants to bound the connect separately from the read, and the form is the right answer for an app that is calling a slow endpoint that the developer does not want to give up on too quickly. The form is the form the requests docs recommend.

None. requests.get(url, timeout=None) — no timeout. The form is the right answer for a developer who is intentionally willing to wait forever, and the form is the right answer for a long-running download. The form is the wrong answer for almost every real use case, and the form is the bug that produces the “the app is stuck” symptom.

The three forms are the floor. The developer should always set a timeout (never None), and the developer should always use the tuple form for production code. The single-number form is fine for a quick script, and the single-number form is fine for a developer who is willing to accept the bundled behavior.

The four parameters a working app actually sets

A working app sets more than the timeout= argument. A working app sets four parameters: the timeout, the retries, the backoff, and the circuit breaker. The four are the ones that turn a request from “might hang” into “will fail fast.”

timeout=. The bound on the call. The form is (connect_timeout, read_timeout), and the values are in seconds. A reasonable default for a public API is (3.2, 27) (matching the requests docs example). A reasonable default for a third-party API is (5, 30). A reasonable default for an internal API is (1, 5). The numbers are a function of the workload, not a single global value.

retries=. The number of times the call is retried on a timeout. The requests library does not retry by default — the developer has to add retries explicitly, usually with the urllib3.util.retry.Retry class or with a library like tenacity or backoff. A reasonable default is 2–3 retries, with exponential backoff. The number is a function of the workload — a read-heavy API can afford more retries, a write-heavy API should not retry on a 5xx.

backoff=. The delay between retries. A reasonable default is exponential backoff with jitter (0.5 * 2^attempt + random(0, 1)). The backoff is the lever that turns a flapping endpoint into a successful retry, and the backoff is the lever that prevents the retries from amplifying the load on the upstream.

circuit_breaker=. A pattern that stops retrying after a certain number of consecutive failures. A reasonable default is “open the circuit after 5 consecutive failures, half-open after 30 seconds, close after 1 successful call.” The circuit breaker is the lever that turns a flapping upstream into a fast failure, and the circuit breaker is the lever that protects the calling app from waiting on a downstream that is down.

The four parameters are the floor. The developer should also set the user agent, the request id, the trace context, and the right headers, but the four are the ones the developer has to get right to make the request safe.

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

session = requests.Session()
retries = Retry(
    total=3,
    backoff_factor=0.5,
    status_forcelist=[502, 503, 504],
    allowed_methods=["GET", "POST"],
)
adapter = HTTPAdapter(max_retries=retries, pool_maxsize=10)
session.mount("https://", adapter)
session.mount("http://", adapter)

response = session.get(
    "https://api.example.com/things",
    timeout=(3.2, 27),
    headers={"X-Request-Id": "abc-123"},
)

The seven gotchas that quietly break a request

A short, opinionated list of gotchas that have actually broken real Python requests. None of them are dramatic. They are the boring ones.

The default is no timeout. A developer who calls requests.get(url) without a timeout= is relying on the OS’s default TCP timeout, which is on the order of minutes. The call can hang for minutes before the OS gives up. The fix is to always pass timeout=.

The connect timeout is bundled with the read timeout. A developer who passes timeout=5 is asking for 5 seconds total, not 5 seconds connect + 5 seconds read. The connect and read are bundled. The fix is to use the tuple form for production code, where the connect and read need to be bounded separately.

The session is not reused. A developer who calls requests.get(url) in a loop is creating a new connection on every call, paying the TCP handshake cost on every call, and burning the DNS cache on every call. The fix is to use a requests.Session() and to reuse it across calls. The session also enables connection pooling, which is the lever that makes high-throughput calls fast.

The retry is on the wrong method. A Retry with allowed_methods=["GET", "POST"] will retry POSTs. A retry on a POST is dangerous — the server may have done the work and the retry will do it again. The fix is to restrict retries to idempotent methods (GET, HEAD, OPTIONS, PUT, DELETE) and to use a Retry-After header for rate-limited responses.

The backoff has no jitter. A developer who retries with a fixed backoff (wait 1 second, wait 1 second, wait 1 second) is going to hit the upstream at the same time as every other client that is doing the same thing. The fix is jitter — a random component added to the backoff — so the retries are spread out across the client population.

The DNS resolution is blocking. A requests.get() to a hostname that does not resolve is a call that blocks on DNS. The fix is to use a DNS cache (dnspython’s dns.resolver.Resolver with a cache, or aiodns for async), or to use a connection pool that pre-resolves the hostnames.

The proxy is silently stripping the timeout. A developer who is behind a corporate proxy (like a Zscaler or a Cloudflare WARP) is a developer whose request may be silently held by the proxy. The timeout on the client side does not bound the time the proxy holds the request. The fix is to use a short connect timeout, and to log the time the request takes so the slow path is visible.

The way retries and circuit breakers interact with the timeout

The timeout, the retries, and the circuit breaker are the three levers that turn a “might hang” request into a “will fail fast” request. The three interact, and the developer has to know the interaction to set them correctly.

The timeout is per attempt, not per call. A timeout=(3.2, 27) with 2 retries is up to (3.2 + 27) * 2 = 60.4 seconds of total wall time, plus the backoff between retries. The total is the number the calling app has to be willing to wait, and the total is the number the upstream has to be willing to serve. The fix is to set the total lower than the calling app’s patience, and to set the per-attempt timeout lower than the total.

The retry budget is per call, not per request. A developer who retries on 5xx, on timeout, on connection reset, and on DNS failure is going to retry a lot. The fix is to set a retry budget — the maximum number of retries across all error types — so a flapping upstream does not consume the entire retry budget on the first failure.

The circuit breaker protects the retry. A circuit breaker that is open is a circuit breaker that skips the call entirely. The pattern is the right answer for a downstream that is down, and the pattern is the lever that turns a flapping upstream into a fast failure. The fix is to add a circuit breaker (in pybreaker, in purgatory, or in a custom decorator) on top of the requests call.

The timeout is the floor, not the ceiling. A timeout=(3.2, 27) is the lower bound on the call time (the call fails at 27 seconds if the server is silent), and the upper bound is whatever the server actually takes. A server that responds in 50 ms is a server that returns in 50 ms, regardless of the timeout. A server that hangs is a server that fails at 27 seconds. The fix is to set the timeout higher than the server’s p99, and to alert on calls that approach the timeout.

The five patterns a team should standardize on

A short, opinionated list of patterns a team should standardize on. The patterns are the ones that make a Python HTTP client safe across machines, across CI, and across time.

The session + retry adapter + timeout tuple pattern. The team standardizes on a requests.Session() with a Retry-configured HTTPAdapter, and a tuple timeout on every call. The pattern is the right answer for any team that is making HTTP calls from Python, and the pattern is the right answer for any team that wants to avoid the “the app is stuck” bug.

The tenacity decorator pattern. The team standardizes on the tenacity library for retries, with a custom retry policy (max attempts, backoff, jitter, which exceptions to retry on). The pattern is the right answer for any team that needs more control than the urllib3 Retry provides, and the pattern is the right answer for any team that wants to retry on custom exceptions.

The circuit breaker pattern. The team standardizes on pybreaker (or a custom decorator) to wrap the HTTP call, with a circuit that opens on N consecutive failures, half-opens after T seconds, and closes after a successful call. The pattern is the right answer for any team that is calling a downstream that can be down, and the pattern is the right answer for any team that wants to fail fast on a known-bad downstream.

The async client pattern. The team standardizes on httpx.AsyncClient for async code, with the same session + retry + timeout + circuit breaker pattern. The pattern is the right answer for any team that is writing async code (FastAPI, aiohttp, Starlette, Sanic), and the pattern is the right answer for any team that wants to avoid the “the event loop is stuck” bug.

The OpenTelemetry-instrumented pattern. The team standardizes on opentelemetry-instrumentation-requests to trace every HTTP call, with the trace id logged on every retry, every circuit breaker event, and every timeout. The pattern is the right answer for any team that wants to debug the slow path, and the pattern is the right answer for any team that wants to know which downstream is the bottleneck.

The five patterns are not exhaustive. There are also patterns with httpx, with aiohttp, with urllib3 directly, and with httpx’s HTTP/2 support. The five are the ones the developer should know first.

How this fits the rest of the stack

A Python HTTP client rarely lives in isolation. The client usually calls an external API, an internal microservice, or a database’s HTTP interface. The platform that handles the client should make the rest of the stack feel like part of the same conversation.

The services layer is the part of the platform that runs the long-lived Python service that makes the HTTP calls. The database layer is the part that holds the data the service reads and writes. The static layer is the part that hosts the dashboard the developer uses to see the call’s status. The environment variables are the part that holds the API key the client authenticates with.

A Python HTTP client on a platform where the service, the database, the storage, and the secrets are all in the same place is a client the team is going to be able to debug. A Python HTTP client on a platform where each piece is in a different console is a client the team is going to spend the first hour just opening the right tab.

For a team that wants to see the full cost of the project before it commits, the RunxBuild hosting calculator shows the line items together. The API, the database, the storage, the cache, the worker, the bandwidth — each one is a separate number, and the team’s mental model for the platform is the sum of those numbers.

FAQ

What is the default timeout for Python requests?

There is no default timeout. Without a timeout= argument, a requests.get() will hang for the OS’s default TCP timeout, which is on the order of minutes. The requests library does not set a default, and the developer has to set one explicitly.

How do I set a timeout on a Python requests call?

Pass timeout= as a keyword argument: requests.get(url, timeout=5) for a single number, or requests.get(url, timeout=(3.2, 27)) for a connect/read tuple. The form is the right answer for almost every real use case.

What is the difference between connect timeout and read timeout?

The connect timeout is the time the library waits for the TCP connection to be established. The read timeout is the time the library waits for the server to send data after the connection is established. A tuple (3.2, 27) means “3.2 seconds to connect, 27 seconds to read.” The connect timeout is usually much shorter than the read timeout.

How do I retry a request on timeout in Python?

Use the urllib3.util.retry.Retry class with a requests.Session() and a HTTPAdapter. The Retry config sets the total retries, the backoff factor, the status codes to retry on, and the methods to retry. The pattern is the right answer for a developer who wants to retry on transient failures.

How do I add a circuit breaker to Python requests?

Use pybreaker (or a custom decorator) to wrap the requests call. The circuit breaker opens after N consecutive failures, half-opens after T seconds, and closes after a successful call. The pattern is the right answer for a developer who wants to fail fast on a known-bad downstream.

Why is my Python request hanging?

The most common cause is no timeout. The fix is to pass timeout= to every call. Other causes include a slow DNS lookup (use a DNS cache), a slow TCP handshake (check the network), a slow server (raise the timeout or speed up the server), or a proxy that is holding the request (check the proxy).

Should I use requests or httpx for new code?

For new code, httpx is the modern choice. It has a similar API to requests, supports async, supports HTTP/2, and has better defaults (including a default timeout). For existing code, requests is still the right answer — the migration cost is real, and requests is still well-maintained.