Skip to content

Retry Strategies

Pacing prevents most 429s. Retrying handles the rest โ€” and the 503s and 504s that have nothing to do with rate limits.

RetryHandler wraps any callable, not just this library's requests. It inspects the returned object for a status code and decides whether to go again.

from smartratelimit import RateLimiter
from smartratelimit.retry import RetryConfig, RetryHandler, RetryStrategy

limiter = RateLimiter()
handler = RetryHandler(RetryConfig(max_retries=3, strategy=RetryStrategy.EXPONENTIAL))

response = handler.retry_sync(
    lambda: limiter.request("GET", "https://api.example.com/data")
)

Configuration

RetryConfig(
    max_retries=3,                        # attempts after the first call
    strategy=RetryStrategy.EXPONENTIAL,
    base_delay=1.0,                       # seconds
    max_delay=60.0,                       # ceiling on any single delay
    backoff_factor=2.0,                   # exponential only
    retry_on_status=[429, 503, 504],      # default
)

RetryHandler() with no config uses exactly these defaults.

The four strategies

Strategy Delay before attempt n With defaults
EXPONENTIAL base_delay ร— factor^(n-1) 1s, 2s, 4s, 8sโ€ฆ
LINEAR base_delay ร— n 1s, 2s, 3s, 4sโ€ฆ
FIXED base_delay 1s, 1s, 1sโ€ฆ
NONE 0 immediate retries

Every delay is capped at max_delay.

from smartratelimit.retry import RetryConfig, RetryStrategy

# Patient: a rate limit that resets on a long window
RetryConfig(strategy=RetryStrategy.EXPONENTIAL, base_delay=2.0, max_delay=120.0)

# Predictable: a flaky upstream where jitter isn't the issue
RetryConfig(strategy=RetryStrategy.FIXED, base_delay=5.0, max_retries=5)

# Impatient: retry only transient server errors, never rate limits
RetryConfig(strategy=RetryStrategy.LINEAR, retry_on_status=[502, 503, 504])

No jitter

Delays are deterministic. If many workers hit the same limit at the same moment, they retry in lockstep. Add your own jitter when that matters โ€” time.sleep(random.uniform(0, 1)) before the call is usually enough.

Which statuses retry

retry_on_status decides. The default [429, 503, 504] covers "you're going too fast" and "the upstream is briefly unwell". Anything else โ€” a 400, a 404, a 500 โ€” is returned immediately, because retrying it will not help.

Note that RetryHandler counts a 429 response as a reason to retry, while RateLimiter.request() separately honours Retry-After by sleeping and retrying once on its own. Stacking them is fine and usually right: the limiter handles the polite single retry, the handler handles persistence.

Exceptions are retried too

If the callable raises โ€” a connection reset, a read timeout โ€” the handler waits and tries again on the same schedule. After the last attempt, the final exception is re-raised unchanged:

import requests

handler = RetryHandler(RetryConfig(max_retries=3, base_delay=1.0))

try:
    response = handler.retry_sync(
        lambda: limiter.request("GET", url, timeout=5)
    )
except requests.Timeout:
    # all four attempts timed out
    ...

When retries are exhausted on a status code (rather than an exception), the last response is returned rather than raised โ€” check response.status_code afterwards.

response = handler.retry_sync(lambda: limiter.request("GET", url))

if response.status_code == 429:
    # still rate limited after every retry
    ...

Async

retry_async is the same logic with await asyncio.sleep() between attempts. It reads .status_code or .status, so both httpx and aiohttp responses work:

import httpx
from smartratelimit import AsyncRateLimiter
from smartratelimit.retry import RetryConfig, RetryHandler, RetryStrategy

handler = RetryHandler(RetryConfig(max_retries=3, strategy=RetryStrategy.EXPONENTIAL))

async with AsyncRateLimiter() as limiter:
    async with httpx.AsyncClient() as client:

        async def call():
            return await limiter.arequest_httpx(client, "GET", url)

        response = await handler.retry_async(call)

Pass a coroutine function, not an already-awaited coroutine โ€” the handler calls it once per attempt.

Arguments

Both methods forward extra arguments to the callable, which is often tidier than a closure:

def fetch(user, timeout=10):
    return limiter.request("GET", f"https://api.github.com/users/{user}", timeout=timeout)

response = handler.retry_sync(fetch, "octocat", timeout=5)

Picking numbers

  • max_retries โ€” 3 is a reasonable default. Past 5, you are usually queuing work that should be re-run later, not held in memory.
  • base_delay โ€” start near the API's typical reset granularity. For a per-second limit, 1s; for a per-hour quota, retries won't save you and you want pacing plus a job queue instead.
  • max_delay โ€” cap it below your caller's own timeout. An exponential schedule with a 60s ceiling and 5 retries can wait over two minutes in total.

Total wait, at a glance

Worst case with the defaults (EXPONENTIAL, base_delay=1, factor=2, max_retries=3):

attempt 1  โ†’  fail  โ†’  wait 1s
attempt 2  โ†’  fail  โ†’  wait 2s
attempt 3  โ†’  fail  โ†’  wait 4s
attempt 4  โ†’  result returned (or exception raised)

Seven seconds of waiting, four calls.