Skip to content
>GLB_
Go back

Retries in Python: from a basic retry loop to exponential backoff for API rate limits

· Updated

Every script that talks to an API eventually gets a 429 Too Many Requests. The naive fix is time.sleep(1) in a loop. It works until you run ten workers, they all sleep exactly one second, wake up together, and hit the limit again in lockstep.

What actually works is three rules, in order:

  1. If the server tells you when to come back (Retry-After), obey it.
  2. Otherwise back off exponentially, with jitter.
  3. Only retry what is safe to retry.

This guide starts from a plain retry loop, shows where it breaks, and builds up to backoff that is safe to run in parallel.

Starting point: a basic retry loop

Without error handling, one bad operation crashes the program. Division by zero is the simplest example:

# This will raise a ZeroDivisionError and crash the program
num1 = 10
num2 = 0
result = num1 / num2  # Raises ZeroDivisionError: division by zero

A try/except inside a bounded loop turns that into a retry: attempt the operation, wait, try again, and give up after a fixed number of attempts. (Division by zero will never succeed on a retry; it only stands in for an operation that can fail.)

import time

def division_with_retry(num1, num2, retries=3, sleep_time=1):
    """
    Tries to divide num1 by num2 with retries in case of ZeroDivisionError.

    Args:
    num1 (int/float): The numerator.
    num2 (int/float): The denominator.
    retries (int): The number of retry attempts.
    sleep_time (int/float): Time to wait between retries in seconds.

    Returns:
    str: The result of the division or an error message.
    """
    for attempt in range(retries + 1):  # +1 to include the first attempt
        try:
            result = num1 / num2
            return f"Success: {result}"
        except ZeroDivisionError as ex:
            if attempt < retries:
                time.sleep(sleep_time)  # Delay between retries
                print(f"Retrying... (Attempt {attempt + 1}/{retries})")
            else:
                return f"Error: {str(ex)}"

print(division_with_retry(10, 0))  # Output: Error: division by zero
print(division_with_retry(10, 2))  # Output: Success: 5.0

The same logic is sometimes written as a first attempt followed by a while loop that counts down the remaining retries:

import time

def division_with_retry(num1, num2, retries=3, sleep_time=1):
    """
    Tries to divide num1 by num2 with a specified number of retries in case of ZeroDivisionError.
    """
    calculated = False
    result = ""
    try:
        result = num1 / num2
    except ZeroDivisionError as ex:
        remaining_retries = retries
        while remaining_retries > 0 and not calculated:
            time.sleep(sleep_time)  # Delay between retries
            try:
                result = num1 / num2
                calculated = True
            except ZeroDivisionError:
                remaining_retries -= 1
                result = f"Error: {str(ex)}"
    return result

print(division_with_retry(10, 0))  # Output: Error: division by zero

The for version is easier to read: one loop, one exit per outcome.

To retry on more than one exception type, pass a tuple to except:

def safe_division_with_retry(num1, num2, retries=3, sleep_time=1):
    for attempt in range(retries + 1):
        try:
            result = num1 / num2
            return f"Success: {result}"
        except (ZeroDivisionError, TypeError) as ex:
            if attempt < retries:
                time.sleep(sleep_time)
                print(f"Retrying... (Attempt {attempt + 1}/{retries})")
            else:
                return f"Error: {str(ex)}"

print(safe_division_with_retry(10, "two"))  # Output: Error: unsupported operand type(s) for /: 'int' and 'str'
print(safe_division_with_retry(10, 0))      # Output: Error: division by zero

The pattern applies to anything that fails intermittently: API requests to a busy server, database connections to an overloaded host, files that are temporarily locked. For HTTP APIs, the fixed one-second sleep is where it falls short, which is what the rest of this guide fixes.

What to retry

SignalRetry?Why
429 Too Many RequestsYesRate limit. Defined in RFC 6585.
503 Service UnavailableYesOften comes with Retry-After.
500, 502, 504Yes, boundedTransient upstream/gateway failures.
Connection error / timeoutYes, if idempotentThe request may or may not have been processed.
400, 401, 403, 404, 422NoRetrying won’t change the answer. Fix the request.

Retry-After can be either a number of seconds or an HTTP date. Handle both. (RFC 9110 §10.2.3, RFC 6585 §4)

Why jitter matters

Pure exponential backoff (base * 2**attempt) spreads retries out over time, but every client that failed at the same moment retries at the same moment. Adding randomness breaks the synchronization. AWS’s analysis compared strategies and “full jitter”, sleep = random(0, min(cap, base * 2**attempt)), gave the best balance of total work and completion time. (AWS Architecture Blog: Exponential Backoff and Jitter)

Hand-rolled version (no dependencies beyond requests)

# >GLB_ // retry with Retry-After + full jitter
import random
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime

import requests

RETRYABLE_STATUS = {429, 500, 502, 503, 504}
IDEMPOTENT_METHODS = {"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}


def parse_retry_after(value: str | None) -> float | None:
    """Retry-After is either delta-seconds or an HTTP-date (RFC 9110 §10.2.3)."""
    if not value:
        return None
    try:
        return max(0.0, float(value))
    except ValueError:
        pass
    try:
        when = parsedate_to_datetime(value)
    except (TypeError, ValueError):
        return None
    return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())


def full_jitter(attempt: int, base: float, cap: float) -> float:
    return random.uniform(0, min(cap, base * 2**attempt))


def request_with_backoff(
    session: requests.Session,
    method: str,
    url: str,
    *,
    max_retries: int = 6,
    base: float = 0.5,
    cap: float = 30.0,
    retry_non_idempotent: bool = False,
    **kwargs,
) -> requests.Response:
    kwargs.setdefault("timeout", 10)
    can_retry = retry_non_idempotent or method.upper() in IDEMPOTENT_METHODS

    for attempt in range(max_retries + 1):
        try:
            resp = session.request(method, url, **kwargs)
        except (requests.ConnectionError, requests.Timeout):
            if not can_retry or attempt == max_retries:
                raise
            delay = full_jitter(attempt, base, cap)
        else:
            if resp.status_code not in RETRYABLE_STATUS or attempt == max_retries:
                return resp
            # 429 means "not processed", so it's safe even for POST
            if not can_retry and resp.status_code != 429:
                return resp
            retry_after = parse_retry_after(resp.headers.get("Retry-After"))
            delay = retry_after if retry_after is not None else full_jitter(attempt, base, cap)

        time.sleep(delay)

    raise RuntimeError("unreachable")

Usage:

with requests.Session() as s:
    r = request_with_backoff(s, "GET", "https://api.example.com/v1/items", params={"page": 1})
    r.raise_for_status()

Design decisions worth calling out:

Option 2: urllib3 Retry mounted on a Session

If you just need it to work, requests sits on urllib3, which already implements backoff and honors Retry-After:

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

retry = Retry(
    total=6,
    backoff_factor=0.5,          # sleep = backoff_factor * 2**(n-1)
    backoff_max=30,
    backoff_jitter=0.5,          # urllib3 >= 2.0
    status_forcelist=[429, 500, 502, 503, 504],
    allowed_methods=frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}),
    respect_retry_after_header=True,
    raise_on_status=False,       # return the last response instead of raising
)

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

Caveats:

Option 3: tenacity

When the retried thing isn’t a single HTTP call (a paginated fetch, an SDK method), tenacity decorates any function:

import requests
from tenacity import (
    retry,
    retry_if_exception_type,
    stop_after_attempt,
    wait_random_exponential,
)


class RetryableHTTPError(Exception):
    pass


@retry(
    retry=retry_if_exception_type((RetryableHTTPError, requests.ConnectionError, requests.Timeout)),
    wait=wait_random_exponential(multiplier=0.5, max=30),  # full jitter
    stop=stop_after_attempt(7),
    reraise=True,
)
def fetch_page(session: requests.Session, page: int) -> dict:
    r = session.get("https://api.example.com/v1/items", params={"page": page}, timeout=10)
    if r.status_code in {429, 500, 502, 503, 504}:
        raise RetryableHTTPError(r.status_code)
    r.raise_for_status()
    return r.json()

wait_random_exponential implements full jitter. It doesn’t read Retry-After on its own; for that, write a custom wait callable that reads the header from the exception. (tenacity docs)

Backoff is reactive. Don’t build on it alone.

Backoff handles the 429 after it happens. If your job expects to run near the limit, read the rate-limit headers the API sends (X-RateLimit-Remaining, RateLimit-*, names vary by provider) and pace requests before you get rejected. That’s the topic of batch API requests in Python: throughput vs rate limit trade-off.

Gotchas

Summary

References


Share this post:

Related Posts


Previous Post
Selecting, dropping and inserting rows in a pandas DataFrame
Next Post
Selenium vs Beautiful Soup: when to use each, and how to combine them