Retries with HTTPAdapter and urllib3

Requests · Advanced · 12 min

Transient failures (resets, 502, 503, 429) are normal at scale; a retry policy turns them into non-events.

Requests does not retry failed requests by default (it retries nothing beyond what urllib3 does internally for a dead pooled connection). To add a policy, build a urllib3.util.Retry and attach it to a transport adapter mounted on a session:

retry = Retry(total=5, backoff_factor=0.5, status_forcelist=[429, 500, 502, 503, 504])
s.mount("https://", HTTPAdapter(max_retries=retry))

Important Retry options:

  • total, connect, read, status: overall and per-category limits.
  • backoff_factor: exponential backoff. The first retry happens immediately, then urllib3 sleeps backoff_factor * 2 ** (n - 1) seconds, where n counts consecutive failures, so 0.1 gives 0, 0.2, 0.4 and so on, capped by backoff_max. Add backoff_jitter to avoid synchronised retry storms.
  • status_forcelist: response statuses that should be retried.
  • allowed_methods: by default only idempotent methods (GET, HEAD, PUT, DELETE, OPTIONS, TRACE) are retried. POST is excluded because repeating it could create duplicates. Add it only if the API supports idempotency keys.
  • respect_retry_after_header (default True): honours Retry-After on 429 and 503.
  • raise_on_status: when retries are exhausted on a status, raise requests.exceptions.RetryError (default) or return the last response.

Retries multiply worst-case latency: each attempt can wait up to the timeout, plus backoff. Budget total time accordingly, and log retries so silent degradation is visible.

Example

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

retry = Retry(
    total=3,
    backoff_factor=0.1,
    status_forcelist=[429, 500, 502, 503, 504],
    allowed_methods={"GET", "PUT", "DELETE"},
)
with requests.Session() as s:
    s.mount("https://", HTTPAdapter(max_retries=retry))
    s.mount("http://", HTTPAdapter(max_retries=retry))

    t = time.perf_counter()
    try:
        s.get("https://httpbin.org/status/503", timeout=5)
    except requests.exceptions.RetryError as e:
        print("gave up:", type(e).__name__)      # gave up: RetryError
    print(f"{time.perf_counter() - t:.1f}s with backoff")  # about 0.6s

    r = s.post("https://httpbin.org/status/503", timeout=5)
    print(r.status_code)                         # 503  (POST is not retried)

Remember

  • Mount HTTPAdapter(max_retries=Retry(...)) on a session to retry
  • status_forcelist and backoff_factor define what and how fast to retry
  • Only idempotent methods are retried by default; POST needs care

Watch out for

  • Retrying POST requests blindly and creating duplicate orders or payments.

Check yourself

  1. Why does urllib3's Retry exclude POST from allowed_methods by default?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: requests.readthedocs.io/en/latest/user/advanced/#transport-adapters