The timeout most applications need

A Python requests call has no timeout unless you set one explicitly. The smallest sensible production default is usually a tuple:

response = requests.get(
    "https://api.example.com/items",
    timeout=(3.05, 10.0),
)

The first value is the connect timeout. The second is the read timeout.

That does not create a 13.05-second deadline for the whole request. The connect timeout limits the connection phase. The read timeout limits how long the underlying socket may remain silent while the client waits for response data. A server that keeps sending small chunks can keep the request alive far longer than the configured read timeout.

A timeout also says nothing definitive about a remote side effect. If a POST times out after the request reached the server, the operation may have completed even though the client never received the response.

The examples in this guide target Python 3.13 and Requests 2.34.2. API behavior was verified against the official documentation on July 31, 2026.

How Python Requests timeouts actually work

A single float applies the same value to both phases:

requests.get(url, timeout=10)

The tuple form makes the contract clearer:

requests.get(url, timeout=(3.05, 10.0))
Phase What the timeout limits Important caveat
Connect Establishing a usable connection DNS resolution may outlive the socket timeout
Read Silence between socket reads It is not a deadline for the full response
Full operation Not directly bounded by requests Redirects, retries, streaming, and caller work add time

On a new HTTPS connection, establishing a usable connection includes TCP setup and TLS negotiation. A reused connection from a Session may skip most of that work.

DNS is a separate caveat. Python’s resolver does not necessarily obey the socket timeout used by Requests and urllib3. A slow resolver can therefore make the wall-clock duration exceed the connect value.

The read timeout begins once the client is waiting for response data. It applies between reads, not to the complete download.

DNS -> TCP -> TLS -> send request -> first byte -> next chunk -> next chunk
       connect budget             |<-- read timeout resets between reads -->|

This is why timeout=10 is not a ten-second deadline.

A small production-oriented client

A shared Session reuses connections and gives one place to define transport behavior. Keep timeout values explicit at the call site or behind a small client boundary.

external_api.py
from __future__ import annotations

from typing import Any

import requests
from requests.exceptions import ConnectTimeout, ReadTimeout


CONNECT_TIMEOUT = 3.05
READ_TIMEOUT = 10.0


class ExternalApiUnavailable(RuntimeError):
    pass


class ExternalApiClient:
    def __init__(self) -> None:
        self._session = requests.Session()

    def get_json(self, url: str) -> dict[str, Any]:
        try:
            response = self._session.get(
                url,
                timeout=(CONNECT_TIMEOUT, READ_TIMEOUT),
            )
            response.raise_for_status()
            return response.json()
        except ConnectTimeout as exc:
            raise ExternalApiUnavailable(
                f"Could not connect to {url}"
            ) from exc
        except ReadTimeout as exc:
            raise ExternalApiUnavailable(
                f"Timed out waiting for data from {url}"
            ) from exc

Catching requests.Timeout is useful when both timeout types have the same application response. Keeping them separate is better when retry safety, metrics, or recovery differ.

There is no universal pair of values. A low-latency internal API, a third-party reporting service, and a large file download have different latency profiles and different consequences when they fail.

Streaming can run indefinitely

With stream=True, the same read-timeout semantics apply while chunks are read:

with requests.get(
    download_url,
    stream=True,
    timeout=(3.05, 5.0),
) as response:
    response.raise_for_status()

    for chunk in response.iter_content(chunk_size=64 * 1024):
        if chunk:
            process(chunk)

The five-second value limits silence between reads. If the server sends a small chunk every four seconds, the download can continue indefinitely without a timeout.

Large downloads often need a second policy:

  • a maximum total duration;
  • a maximum accepted size;
  • a minimum transfer rate;
  • cancellation from the surrounding job.

Those limits belong above the Requests socket timeout.

A read timeout can leave the outcome unknown

A connect timeout happens before Requests establishes the connection. The official exception contract describes it as safe to retry.

A read timeout is different. The request may already have reached the server.

For GET, another attempt is normally safe when the endpoint follows HTTP semantics. For a mutating request, retrying can duplicate the effect.

Consider:

client sends POST
server creates payment
response is delayed or lost
client raises ReadTimeout

The client knows that it stopped waiting. It does not know whether the payment exists.

This failure appears in payment integrations and long-running AI operations. In a workflow such as Ghostviber media generation, a provider may accept billable work before the client loses the response. Retrying with a new operation identity can create duplicate cost as well as duplicate output.

The safe pattern is to give the logical operation a stable identity and reuse it across attempts. For REST APIs, that commonly means an idempotency key. See Idempotency in REST APIs: Keys, Concurrency and Safe Retries for the server-side protocol, payload matching, concurrency, and crash recovery.

For the broader model of attempts and uncertain outcomes, read Reliable External Operations Are More Than Retries.

Retries need their own policy

Requests does not retry failed connections by default. A shared Session can mount an HTTPAdapter backed by urllib3’s Retry.

This example retries connection failures and selected server responses for read-only methods. It deliberately does not retry read timeouts.

http_session.py
import requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry


def build_session() -> requests.Session:
    retry = Retry(
        total=2,
        connect=2,
        read=False,
        status=2,
        allowed_methods=frozenset({"GET", "HEAD"}),
        status_forcelist=(502, 503, 504),
        backoff_factor=0.2,
        respect_retry_after_header=True,
    )

    session = requests.Session()
    adapter = HTTPAdapter(max_retries=retry)
    session.mount("http://", adapter)
    session.mount("https://", adapter)
    return session

The distinction between read=False and read=0 is worth testing in your own stack. With current urllib3 behavior, disabling read retries preserves the original ReadTimeout; an exhausted configured retry counter may instead be wrapped by the adapter after retry processing.

Do not add POST to allowed_methods merely because the provider is flaky. First establish idempotency and decide which failure phases are safe to repeat.

For asynchronous operations, RelPrim can express the retry and attempt-timeout policy at the operation boundary:

provider_operation.py
from relprim import resilient


class TemporaryProviderError(Exception):
    pass


@resilient(
    retries=2,
    retry_on=(TemporaryProviderError,),
    timeout=10,
)
async def fetch_recommendations(user_id: str) -> list[str]:
    return await provider.fetch_recommendations(user_id)


result = await fetch_recommendations("user-42")

print(result.value)
print(result.report.to_dict())

Here, retries=2 allows two retry attempts after the initial call, while timeout=10 protects each async attempt. The returned OperationResult keeps the value and execution report together.

RelPrim is not an HTTP client and does not replace Requests, urllib3, or provider-native transport settings. The underlying async client still needs its own connect, read, or pool timeouts. RelPrim becomes useful when retries, attempt timeouts, validation, fallbacks, and execution reporting need to form one explicit operation policy.

See the RelPrim getting started guide for the decorator API and the advanced usage guide for explicit policy composition.

A deadline belongs above one request

A caller can maintain an end-to-end budget and pass the remaining time into each attempt:

deadline_budget.py
from time import monotonic

import requests


class DeadlineExceeded(TimeoutError):
    pass


def get_with_budget(
    session: requests.Session,
    url: str,
    *,
    deadline: float,
) -> requests.Response:
    remaining = deadline - monotonic()

    if remaining <= 0:
        raise DeadlineExceeded(url)

    return session.get(
        url,
        timeout=(
            min(3.05, remaining),
            min(10.0, remaining),
        ),
    )

A retry loop can calculate one deadline before the first attempt and reuse it. This prevents each attempt from receiving a fresh full budget.

It is still not a hard wall-clock cutoff. DNS resolution and a response that continues producing bytes can exceed it. When strict cancellation is a real requirement, enforce it at a stronger execution boundary or choose a client and runtime model with explicit total-deadline support.

The RelPrim example above follows the same separation: an attempt timeout is one mechanism inside a wider operation policy. It still does not create a strict wall-clock deadline for every activity surrounding that operation.

Test the failure phase you care about

A controlled local server can verify read-timeout behavior without depending on a public test endpoint.

test_read_timeout.py
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

import pytest
import requests
from requests.exceptions import ReadTimeout


class SlowResponseHandler(BaseHTTPRequestHandler):
    def do_GET(self) -> None:
        time.sleep(0.2)
        self.send_response(200)
        self.end_headers()

        try:
            self.wfile.write(b"ok")
        except BrokenPipeError:
            pass

    def log_message(self, *_: object) -> None:
        pass


def test_read_timeout() -> None:
    server = ThreadingHTTPServer(
        ("127.0.0.1", 0),
        SlowResponseHandler,
    )
    thread = threading.Thread(
        target=server.serve_forever,
        daemon=True,
    )
    thread.start()

    try:
        url = f"http://127.0.0.1:{server.server_port}/"

        with pytest.raises(ReadTimeout):
            requests.get(url, timeout=(1.0, 0.05))
    finally:
        server.shutdown()
        server.server_close()

This test was executed with Python 3.13.5, Requests 2.32.5, and pytest 9.0.2. The timeout API and exception behavior used here are documented in Requests 2.34.2.

Do not simulate a connect timeout by sleeping inside an HTTP handler. By the time the handler runs, the connection already exists. Test connection failure with network fault injection, a controlled proxy such as Toxiproxy, or an isolated network environment.

What to observe in production

A timeout metric without phase or operation context is hard to act on.

Record:

Signal Why it matters
Timeout phase Connect and read failures imply different risks
Dependency and host Shows whether one provider or route is unhealthy
Operation name Separates reads from side-effecting commands
Attempt number Reveals retry amplification
Elapsed wall time Shows the real user cost
Final outcome Distinguishes recovered, failed, and unknown operations

Do not log credentials, full URLs containing secrets, or sensitive payloads. Prefer a stable operation name and a sanitized dependency identifier.

Before you ship

Check that every Requests call has an explicit timeout.

Use a tuple when connect and read behavior differ. Reuse a Session for connection pooling. Retry only classified failures with bounded attempts and backoff. Treat a read timeout on a mutating operation as potentially ambiguous. Carry one idempotency key across safe retries. Measure the final operation outcome, not only individual exceptions.

Use another execution boundary when you need strict wall-clock cancellation, asynchronous concurrency, or stronger control over long-running streams.

A timeout limits how long the client is willing to wait. It does not tell you what the server did after the client stopped waiting.

Which timeout have you found harder to tune in production: connection setup, time to first byte, or silence during a long response?