mrkeyoor.com_
Fri 07 Aug 22:52 UTC
PyPIUtilsupdated 07 Aug 2026

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.

Verdict

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.

API stability3/5The current surface has not moved since 2.9.1 in November 2024, but the README's own breaking-changes section lists a yanked range from 2.7.0 to 2.8.3 and a get_timeout signature change at 2.5.6 that broke custom RetryOptions subclasses
Docs3/5The README is the whole documentation and is honest about the awkward parts, including that the last attempt is returned as is and that response can be None inside get_timeout; there is no hosted site, no API reference, and it tells you to read the tests for more examples
Maintenance3/5The repository was pushed 2026-04-08 so it is not abandoned, yet no release has reached PyPI since 2.9.1 on 2024-11-06; 8 open issues on a 273-star single-maintainer project
Ecosystem3/58.7M weekly downloads far outrun 273 stars, which is what a transitive dependency of larger tools looks like rather than a community; there are no plugins, and the API is tied to whatever aiohttp's ClientSession accepts

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
Skip it if

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

PackageRegistryPick it when
tenacityPyPIYou want one retry decorator for HTTP, database and queue calls, sync or async, independent of client library
staminaPyPIYou want tenacity's engine with safer defaults, jitter on by default and a testing mode that turns retries off
aiohttpPyPIYou only need one retry in one place and a small loop around the call is honestly cheaper than a dependency