Back to Blog
Python

Python httpx Timeout, Retries, and Exception Handling

python httpx timeout retries and exception handling: Learn how to configure timeouts, implement retries, and handle exceptions in Python httpx to make HTTP requests re...

httpxtimeoutsretriesexception handlingHTTP client
Illustration of a Python HTTP request with a timer, retry arrows, and an exception warning icon, representing httpx timeout and retry handling.

When a Python service depends on an external HTTP API, network failures and slow responses are inevitable. python httpx timeout retries and exception handling is the set of techniques you need to keep requests reliable without hiding underlying problems. This article covers the practical details: how to set timeouts, how to implement retries, and how to catch the right exceptions in httpx.

Configuring Timeouts in httpx

httpx provides a Timeout class that lets you control four separate phases of a request: connect, read, write, and pool. Each phase has its own timeout value, and you can set them individually or as a single default.

import httpx # Global default for all requests timeout = httpx.Timeout(10.0) # 10 seconds for all phases client = httpx.Client(timeout=timeout) # Per-phase configuration timeout = httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=3.0) client = httpx.Client(timeout=timeout)

The connect timeout is the maximum time to establish a TCP connection. The read timeout is the maximum time to wait for a chunk of data from the server after the request is sent. The write timeout is the maximum time to send the request body. The pool timeout is how long to wait for a connection from the connection pool.

You can also override the timeout on a per-request basis:

response = client.get("https://api.example.com", timeout=15.0)

If you pass a float, it applies to all phases. If you pass a tuple, it must be four values corresponding to connect, read, write, and pool.

A common mistake is to set only a single timeout value and assume it covers the entire request. In reality, the total time can be much longer because each phase is independent. For example, a 10-second read timeout does not limit the total time if the connection takes 9 seconds and the read takes another 10 seconds. If you need a strict total deadline, you need to design for that separately.

Understanding httpx Retry Behavior

httpx itself does not automatically retry requests after a timeout or connection error. The only built-in retry is at the transport level for connection attempts. The httpx.Transport class accepts a retries parameter that controls how many times it tries to establish a connection before raising an error.

import httpx transport = httpx.Transport(retries=3) client = httpx.Client(transport=transport)

This retry only applies to connection establishment, not to the entire request. If the connection is established but the read times out, you get a ReadTimeout exception and no automatic retry occurs. For application-level retries, you need to implement your own logic.

A typical approach is to wrap the request in a loop that catches transient exceptions and retries with a backoff delay. The tenacity library is a common choice, but you can also write a simple loop yourself.

import httpx import time def request_with_retry(client, method, url, **kwargs): max_retries = 3 for attempt in range(max_retries): try: return client.request(method, url, **kwargs) except (httpx.ConnectError, httpx.TimeoutException) as exc: if attempt == max_retries - 1: raise time.sleep(2 ** attempt) # exponential backoff

This loop catches connection errors and timeouts, which are usually transient. It does not retry on HTTP status codes like 500 or 503 because those are responses, not exceptions. To retry on specific status codes, you need to inspect the response.

Handling httpx Exceptions Correctly

httpx raises several exception types that you should catch explicitly. The base class for network-related errors is httpx.RequestError. From it derive ConnectError, ReadTimeout, WriteTimeout, PoolTimeout, and others.

ExceptionRaised when
httpx.ConnectErrorA TCP connection cannot be established
httpx.ReadTimeoutThe server does not send data within the read timeout
httpx.WriteTimeoutThe client cannot send the request body within the write timeout
httpx.PoolTimeoutNo connection is available from the pool within the pool timeout
httpx.TimeoutExceptionAny timeout occurs (base class for timeouts)
httpx.RequestErrorAny transport-level failure (base class)

Catching httpx.RequestError covers all network-level failures. But you might want to differentiate timeouts from connection errors to decide whether a retry is appropriate. For example, a ConnectError might indicate the server is down, while a ReadTimeout might mean the server is overloaded but still alive.

import httpx try: response = client.get("https://api.example.com") except httpx.TimeoutException as exc: print(f"Request timed out: {exc}") except httpx.ConnectError as exc: print(f"Connection failed: {exc}") except httpx.RequestError as exc: print(f"Request failed: {exc}")

Note that httpx.TimeoutException is a subclass of httpx.RequestError, so the order of except clauses matters. Catch more specific exceptions first.

Another important exception is httpx.HTTPStatusError, which is raised when you call response.raise_for_status() and the response status is 4xx or 5xx. This is not a network error; it is a response error. You should handle it separately from transport errors.

Combining Timeouts, Retries, and Exception Handling

The real value comes from combining these three elements. A robust request function should set timeouts, retry on transient network failures, and handle exceptions in a way that preserves the original error context.

import httpx import time def safe_request(client, method, url, *, retries=3, backoff=1.0, **kwargs): """Send a request with timeouts, retries, and exception handling.""" for attempt in range(retries): try: response = client.request(method, url, **kwargs) response.raise_for_status() # raises HTTPStatusError for 4xx/5xx return response except (httpx.ConnectError, httpx.TimeoutException) as exc: # Transient network error, retry with backoff if attempt == retries - 1: raise time.sleep(backoff * (2 ** attempt)) except httpx.HTTPStatusError as exc: # Server responded with an error status; don't retry unless you have a reason raise

This function sets a default timeout via the client configuration, retries on connection errors and timeouts, and lets HTTPStatusError propagate immediately. You can customize the timeout per call by passing timeout in kwargs.

One important detail: when you retry after a timeout, the original request may have reached the server and been processed. For non-idempotent methods like POST, retrying can cause duplicate side effects. You should only retry idempotent requests (GET, PUT, DELETE) or ensure your API supports idempotency keys.

Production Considerations for Retries

In production, retries need to be carefully tuned to avoid overwhelming the server or masking systemic issues. A few practical rules:

  • Use exponential backoff with a cap. A simple 2 ** attempt grows quickly; you might want min(backoff * (2 ** attempt), max_backoff).
  • Add jitter to avoid thundering herd problems when many clients retry simultaneously.
  • Limit the number of retries. Three to five retries is typical; more than that can cause long delays.
  • Do not retry on all exceptions. Only retry on transient errors like ConnectError and TimeoutException. Do not retry on HTTPStatusError unless you specifically handle status codes like 429 (rate limit) or 503 (service unavailable) with a Retry-After header.
import random def retry_delay(attempt, base=1.0, max_delay=10.0): delay = min(base * (2 ** attempt), max_delay) return delay + random.uniform(0, delay * 0.1) # add 10% jitter

Also consider whether your retry logic should be centralized. If you have many endpoints, wrapping each call individually leads to duplication. A decorator or a wrapper function like the one above keeps the logic in one place.

Common Pitfalls with httpx Timeouts and Retries

One common mistake is setting a timeout that is too short for legitimate slow responses. For example, a read timeout of 2 seconds might be fine for a simple API but will fail for a report generation endpoint that takes 10 seconds. Always align timeouts with the expected response time of the service.

Another pitfall is retrying without checking the response status. A 500 Internal Server Error is a response, not an exception. If you only catch exceptions, you will miss server-side failures. Use raise_for_status() or explicitly check response.status_code to decide whether to retry.

A third issue is catching Exception broadly. This hides programming errors like TypeError or ValueError and makes debugging harder. Always catch specific httpx exceptions.

Finally, remember that httpx's retries parameter on Transport only handles connection retries. If you set it to 3, you still need your own retry loop for timeouts. Do not confuse the two.

By applying these patterns, you can make your httpx-based code resilient to network failures while keeping the error handling explicit and maintainable.

python httpx timeout retries and exception handling: Practic | RYUSLOG DEV