Back to Blog
Python

Python Requests Retry Exponential Backoff and Connection Errors

python requests retry exponential backoff and connection errors: Configure retries in Python requests with exponential backoff to handle connection errors and transien...

pythonrequestsretryexponential-backofferror-handling
Illustration of a client retrying a network request with exponential backoff, showing arrows of increasing length between two server nodes.

python requests retry exponential backoff and connection errors requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

Connection errors are inevitable when a Python application talks to an external HTTP service. The requests library raises ConnectionError when the client cannot establish a TCP connection, and ConnectTimeout when the handshake takes too long. A single retry with a short delay often fixes the problem; a retry storm makes it worse. The standard solution is to configure retries with exponential backoff so that each attempt waits longer than the previous one. This article explains how to implement python requests retry exponential backoff and connection errors handling using the urllib3 retry layer that requests uses internally.

Why a Plain Loop Is Not Enough

A naive retry loop that sleeps a fixed amount of time between attempts handles the simplest case, but it has two problems. First, it retries every request, including non-idempotent ones like POST, which can duplicate side effects on the server. Second, a fixed delay does not adapt to the failure pattern. If a service is recovering slowly, a constant one-second wait can hammer it before it is ready.

The requests library does not expose retry configuration directly. The retry behavior lives in urllib3, which requests uses as its HTTP transport. You configure retries through an HTTPAdapter attached to a requests.Session.

Configuring an HTTPAdapter with Retry

The urllib3.util.retry.Retry class defines the retry policy. You create a Retry instance, pass it to an HTTPAdapter, and mount the adapter on a session.

import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry retry = Retry( total=5, backoff_factor=1, status_forcelist=[500, 502, 503, 504], ) session = requests.Session() adapter = HTTPAdapter(max_retries=retry) session.mount("https://", adapter) session.mount("http://", adapter) response = session.get("https://api.example.com/data")

The Retry object controls how many attempts are made, which HTTP status codes trigger a retry, and how long the client waits between attempts. The total parameter caps the number of retries, not the total number of requests. With total=5, the client makes at most six requests: the original plus five retries.

How backoff_factor Computes the Delay

urllib3 calculates the sleep time between retries with this formula:

delay = backoff_factor * (2 ** (attempt_number - 1))

The first retry waits backoff_factor seconds, the second waits backoff_factor * 2, the third waits backoff_factor * 4, and so on. With backoff_factor=1, the delays are 1, 2, 4, 8, and 16 seconds for five retries.

The delay is applied after a failed attempt and before the next one. This exponential growth is what gives the pattern its name, and it prevents a recovering service from being overwhelmed by a client that keeps retrying at a constant rate.

Retrying Connection Errors Specifically

Retry handles connection errors by default. The connect parameter controls how many times a connection failure is retried. If you set total but leave connect unset, the total value applies to all retry categories, including connection errors.

retry = Retry( total=5, connect=3, backoff_factor=1, status_forcelist=[500, 502, 503, 504], )

Here connect=3 limits connection-error retries to three, while total=5 caps the overall retry count. This is useful when the remote host is known to be unstable but you do not want to wait through five full backoff cycles for a host that is simply down.

The Retry class also distinguishes between connect errors and read errors. A read error happens after the connection is established but the response cannot be read. You can set read independently if the failure mode differs.

Which HTTP Methods Should Be Retried

Retrying a GET request is generally safe because it does not change server state. Retrying a POST can create duplicate resources or trigger side effects twice. The Retry class restricts retries to idempotent methods by default: GET, HEAD, OPTIONS, PUT, DELETE, and TRACE.

If you need to retry a POST, you must add it to allowed_methods explicitly.

retry = Retry( total=3, backoff_factor=0.5, allowed_methods=frozenset(["GET", "POST"]), status_forcelist=[500, 502, 503], )

Only add POST when the endpoint is designed to be idempotent, for example when the request carries an idempotency key. Otherwise a retry after a timeout can apply the same operation twice.

Handling the Final Failure

When retries are exhausted, requests raises the last exception. A ConnectionError after all retries means the host is unreachable, not that the request is still pending. Your code should catch the exception and decide what to do next.

try: response = session.get("https://api.example.com/data") response.raise_for_status() except requests.exceptions.ConnectionError as exc: logger.error("Request failed after retries: %s", exc)

The retry logic does not change the exception type. It only delays the point at which the exception is raised. The caller still sees the original ConnectionError or ConnectTimeout, which keeps error handling consistent.

Observability and Logging During Retries

The default Retry behavior is silent. A production service should know when retries are happening, because a high retry rate is often the first sign of a downstream outage. The urllib3.connectionpool logger emits a message each time a retry is scheduled.

import logging logging.basicConfig(level=logging.INFO) logging.getLogger("urllib3.connectionpool").setLevel(logging.INFO)

With this configuration, a retry produces a log line that includes the URL and the reason for the retry. In a service that already aggregates logs, this makes it possible to detect when a downstream dependency starts failing without adding custom instrumentation.

Setting a Realistic Upper Bound

Exponential backoff grows quickly. With backoff_factor=1 and total=5, the total sleep time is 31 seconds. With total=8, it grows to several minutes. The total value should reflect the maximum acceptable latency for the request. A background job can afford a longer retry window; a user-facing API endpoint usually cannot.

You can also cap the delay with the backoff_max parameter. The default is 120 seconds, which means the delay stops growing after the seventh retry. Lower backoff_max when the service must fail fast.

retry = Retry( total=4, backoff_factor=2, backoff_max=10, )

With backoff_factor=2, the delays would normally be 2, 4, 8, and 16 seconds, but backoff_max=10 caps the fourth delay at 10 seconds. This keeps the worst-case wait bounded while still giving the service time to recover.

Combining Retries with Timeouts

Retries and timeouts work together. A request that hangs forever without a timeout will never reach the retry logic. Set both connect and read timeouts on each request so that a stuck connection is abandoned and retried.

response = session.get("https://api.example.com/data", timeout=(3, 5))

A connect timeout of three seconds and a read timeout of five seconds means a single attempt fails within eight seconds at most. With five retries and exponential backoff, the total worst-case time is the sum of the timeouts and the backoff delays. The timeout values should be chosen so that the total remains acceptable for the calling code.

python requests retry exponential backoff and connection err | RYUSLOG DEV