Back to Blog
Python

Python Requests Retries: Exponential Backoff and Connection Errors

Configure retries in Python requests with exponential backoff to handle connection errors, transient HTTP failures, and timeouts.

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

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; retrying too aggressively can make an outage worse. The standard solution is to configure retries with exponential backoff so that waits grow after repeated failures. This article shows how to configure 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.

The status_forcelist argument adds retries for the listed HTTP status codes. Without it, the retry policy primarily applies to transport-level errors such as connection failures.

How backoff_factor Computes the Delay

urllib3 does not sleep before the first retry. It calculates the delay for later retries as backoff_factor * (2 ** (consecutive_failures - 1)), but skips the sleep after the first failure. With backoff_factor=1, the delays before the retries allowed by total=5 are 0, 1, 2, 4, and 8 seconds.

The delay is applied between attempts. 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 a default set of idempotent methods: 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

With default settings, once retries are exhausted, the adapter raises a requests exception instead of silently returning a failed response. The exception class depends on the final failure: connection problems surface as ConnectionError or ConnectTimeout, while retries exhausted by a response status can surface as RetryError. The retry logic does not hide the failure; it only adds attempts and delays before raising.

import logging try: response = session.get('https://api.example.com/data') response.raise_for_status() except requests.exceptions.RequestException as exc: logging.getLogger(__name__).error('Request failed after retries: %s', exc)

Observability and Logging During Retries

The default Retry behavior does not include application-level logging. 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, even a modest total=5 can add 15 seconds of sleep if all attempts fail; higher retry limits push the worst case past two 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, so no delay exceeds two minutes. Lower backoff_max when the service must fail fast.

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

With backoff_factor=2, the delays before the retries would be 0, 2, 4, 8, and 16 seconds; backoff_max=10 caps the last 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 Retries: Exponential Backoff and Connection Errors | RYUSLOG DEV