aiohttp-retry
aiohttp-retry adds automatic retries to aiohttp, which has none of its own. You swap aiohttp.ClientSession for RetryClient and keep writing the same get/post/put calls; when a request raises or comes back with a 5xx, the client sleeps and tries again. Backoff strategy is a plain object you pass in, with ExponentialRetry, RandomRetry, ListRetry, FibonacciRetry and JitterRetry built in, or your own subclass of RetryOptionsBase. It is a small wrapper, roughly one module of real code, and it stays small on purpose: no circuit breaking, no rate limiting, no caching.
The path of least resistance if you are already on aiohttp and want retries at the client level rather than the call site. Just go in knowing it is a thin wrapper with a stalled release cadence, and that anything beyond retries is your problem.
Use it if
- You already use aiohttp and want retries without writing your own attempt loop around every call site
- You want the retry decision to consider the response, not just exceptions, via statuses lists or an evaluate_response_callback
- You want to vary the request between attempts (different URL, different headers) rather than repeating an identical call
- You want typed code: the project ships type hints and states it is mypy compatible
- You are not on aiohttp: this wraps aiohttp's client specifically, so httpx or requests users want tenacity or stamina instead
- You need retries plus circuit breaking, timeouts, and rate limiting as one policy: this only does retries, and stacking three small libraries usually ends in a home grown wrapper anyway
- You care about release cadence: the last PyPI release is 2.9.1 from November 2024, so anything merged since then only reaches you from git
- Your retry logic is unusual enough that you would subclass RetryOptionsBase anyway, at which point a generic library gives you more room
- You need retries around non-HTTP work too, since a generic decorator covers database calls and queue publishes with the same policy
Setup reality
Installation is trivial: a pure Python wheel with aiohttp as the single dependency, no compilation and no version pin on aiohttp itself. The sharp edges are behavioural. The final attempt is returned as is, so a request that fails every time hands you the failing response or re-raises the last exception rather than a retry-specific error. Ownership of the session is yours to track: RetryClient() creates one internally and needs close() or the async with form, but if you pass client_session yourself then closing it is your job. If you wrote a custom RetryOptions before 2.5.6, get_timeout gained a response parameter that can be None, and the README notes an entire yanked release range from 2.7.0 up to 2.8.3 caused by an evaluate_response_callback bug that produced infinite retries.
Patterns
Retry a GET with exponential backoffbasic-retry
from aiohttp_retry import RetryClient, ExponentialRetry
async def main():
retry_options = ExponentialRetry(attempts=3)
retry_client = RetryClient(retry_options=retry_options)
async with retry_client.get('https://example.com') as response:
print(response.status)
await retry_client.close()attempts is the total number of tries, not the number of extra tries after the first one.
Let the context manager close the clientcontext-manager
from aiohttp_retry import RetryClient
async def main():
async with RetryClient() as client:
async with client.get('https://example.com') as response:
print(response.status)Two nested async with blocks: the outer one owns the session, the inner one owns the response.
Wrap a ClientSession you already havewrap-existing-session
from aiohttp import ClientSession
from aiohttp_retry import RetryClient
async def main():
client_session = ClientSession()
retry_client = RetryClient(client_session=client_session)
async with retry_client.get('https://example.com') as response:
print(response.status)
await client_session.close()When you pass client_session, closing it stays your responsibility; RetryClient.close() will not do it for you.
Retry on specific status codesretry-on-statuses
from aiohttp_retry import ExponentialRetry
retry_options = ExponentialRetry(
attempts=5,
statuses={429, 502, 503, 504},
)All 5xx responses are retried by default; statuses adds to that set rather than replacing it.
Take control of which 5xx codes retrydisable-server-error-retry
retry_options = ExponentialRetry(
attempts=3,
retry_all_server_errors=False,
statuses={503},
)Set retry_all_server_errors=False first, otherwise your statuses set is a superset of every 500 anyway.
Add randomness to the backoffjitter-backoff
from aiohttp_retry import RetryClient, JitterRetry
retry_client = RetryClient(retry_options=JitterRetry(attempts=4))Plain exponential backoff synchronises every client that failed at the same moment; JitterRetry is the version you want in a fleet.
Spell out the wait between each attemptfixed-backoff-list
from aiohttp_retry import ListRetry
retry_options = ListRetry(timeouts=[0.5, 2.0, 5.0])The list length sets the attempt count for ListRetry; FibonacciRetry is the other option when you want growth without configuring each step.
Retry based on the response bodyevaluate-response
from aiohttp import ClientResponse
from aiohttp_retry import ExponentialRetry
async def is_bad(response: ClientResponse) -> bool:
data = await response.json()
return data.get('status') != 'ok'
retry_options = ExponentialRetry(
attempts=3,
evaluate_response_callback=is_bad,
)This callback is the feature behind the yanked 2.7.0 to 2.8.3 releases, so stay on 2.8.3 or later when you use it.
Override retry options for one callper-request-options
from aiohttp_retry import RetryClient, ExponentialRetry, RandomRetry
retry_client = RetryClient(retry_options=ExponentialRetry(attempts=3))
async with retry_client.get(
'https://example.com/flaky',
retry_options=RandomRetry(attempts=6),
) as response:
print(response.status)Options passed to a method fully replace the ones on the constructor for that call, they do not merge.
Change headers or URL between attemptsvary-request-between-attempts
from aiohttp_retry import RetryClient, RequestParams
async def main():
retry_client = RetryClient(raise_for_status=False)
async with retry_client.requests(
params_list=[
RequestParams(method='GET', url='https://example.com'),
RequestParams(
method='GET',
url='https://backup.example.com',
headers={'x-fallback': '1'},
),
]
) as response:
print(response.status)
await retry_client.close()If params_list is shorter than attempts, the last entry is reused for the remaining attempts.
Log each attempt with a trace configlog-attempts
from aiohttp import TraceConfig
from aiohttp_retry import RetryClient, ExponentialRetry
retry_options = ExponentialRetry(attempts=2)
async def on_request_start(session, ctx, params):
attempt = ctx.trace_request_ctx['current_attempt']
if retry_options.attempts <= attempt:
print('last attempt')
trace_config = TraceConfig()
trace_config.on_request_start.append(on_request_start)
retry_client = RetryClient(
retry_options=retry_options,
trace_configs=[trace_config],
)current_attempt is injected into aiohttp's trace_request_ctx, which is the only hook you get for observing retries.
Write your own backoff curvecustom-backoff
from aiohttp_retry import RetryOptionsBase
class CappedRetry(RetryOptionsBase):
def get_timeout(self, attempt: int, response=None) -> float:
return min(2 ** attempt, 30)The response parameter was added in 2.5.6 and can be None when no response arrived or raise_for_status is on, so never dereference it unguarded.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| tenacity | PyPI | You want one retry decorator for HTTP, database and queue calls, sync or async, independent of client library |
| stamina | PyPI | You want tenacity's engine with safer defaults, jitter on by default and a testing mode that turns retries off |
| aiohttp | PyPI | You only need one retry in one place and a small loop around the call is honestly cheaper than a dependency |