Python aiohttp Timeout, Retries, and Exception Handling
python aiohttp timeout retries and exception handling: Configure aiohttp timeouts, implement retry logic with exponential backoff, and handle client exceptions correct...
When you build an HTTP client with aiohttp, you are responsible for three behaviors that the library deliberately leaves to you: timeouts, retries, and exception handling. Python aiohttp timeout retries and exception handling are not automatic. A request that hangs will block the coroutine for the default total timeout of 300 seconds, and a failed request will never be retried unless you write the loop yourself. This article shows how to configure each piece correctly and combine them into a reliable client.
Why Timeouts and Retries Are Not Automatic in aiohttp
aiohttp is an asynchronous HTTP client built on asyncio. It gives you fine-grained control over connection behavior, but it does not make policy decisions about how long a request may take or whether a failed request should be attempted again. The default ClientTimeout is total=300, meaning a request can occupy a coroutine for five minutes before raising asyncio.TimeoutError. That is rarely what a production service wants.
Retries are similarly absent. If a connection is refused, a DNS lookup fails, or the server drops the connection mid-response, aiohttp raises an exception and the request is over. There is no retries parameter on ClientSession.get(). You must implement retry behavior around the request call, and you must decide which failures are safe to retry.
The practical consequence is that a robust aiohttp client needs three coordinated pieces: an explicit timeout configuration, a retry loop with backoff, and exception handling that distinguishes transient failures from permanent ones.
Configuring Timeouts with ClientTimeout
aiohttp.ClientTimeout controls four distinct limits. Each is expressed in seconds, and None disables that particular limit.
| Parameter | What it limits |
|---|---|
total | The entire request, including connection, sending, and reading |
connect | Establishing the TCP connection and completing the TLS handshake |
sock_connect | Connecting the underlying socket |
sock_read | Waiting for the next chunk of data from the socket |
A common configuration for a service that must fail fast looks like this:
from aiohttp import ClientTimeout timeout = ClientTimeout(total=30, connect=5, sock_read=10)
total is the overall budget and is the limit that matters most. If the server accepts the connection but never sends a response, sock_read fires after 10 seconds of silence, which is usually more useful than waiting for the full 30-second total. connect catches unreachable hosts quickly instead of letting the OS connection timeout dominate.
You can pass a timeout per request, which overrides the session default:
async with session.get(url, timeout=timeout) as response: ...
If you do not pass one, the session uses its own default, which is 300 seconds total unless you configured ClientSession(timeout=...). Set the timeout explicitly on the session so every request inherits sane limits.
What Exceptions aiohttp Raises
Most failures surface as subclasses of aiohttp.ClientError. The ones you will handle most often are:
aiohttp.ClientConnectorError— the connection could not be established. This wraps DNS failures, refused connections, and unreachable hosts.aiohttp.ClientResponseError— raised when you callresponse.raise_for_status()and the status is 4xx or 5xx. The exception carries the status code and the response body.asyncio.TimeoutError— raised when any configured timeout expires. In Python 3.11 and later,asyncio.TimeoutErroris an alias for the built-inTimeoutError, so catching either works.
A minimal handler that separates these cases looks like this:
import asyncio import aiohttp async def fetch(session, url): try: async with session.get(url) as response: response.raise_for_status() return await response.text() except aiohttp.ClientConnectorError as exc: # Host unreachable, DNS failure, connection refused raise except asyncio.TimeoutError: # total, connect, or sock_read limit expired raise except aiohttp.ClientResponseError as exc: # Non-2xx status; exc.status tells you which one raise
Note that ClientConnectorError is a subclass of ClientError, and ClientResponseError is too. If you catch ClientError broadly, you will catch both. That is useful for logging, but it hides the distinction between a connection failure and a bad status code, so catch the specific types when the retry decision depends on the cause.
Implementing Retry Logic Manually
Because aiohttp has no built-in retry, the standard approach is a loop that wraps the request and sleeps between attempts. The loop should re-raise the last exception when the attempt budget is exhausted.
import asyncio import aiohttp async def fetch_with_retry(session, url, *, max_attempts=4): last_exc = None for attempt in range(1, max_attempts + 1): try: async with session.get(url) as response: response.raise_for_status() return await response.text() except (asyncio.TimeoutError, aiohttp.ClientConnectorError) as exc: last_exc = exc if attempt < max_attempts: await asyncio.sleep(0.5 * attempt) raise last_exc
This retries only on timeouts and connection failures, which are the classic transient conditions. A fixed sleep of 0.5 * attempt is a start, but it has a problem: every concurrent request that fails at the same moment will retry at the same moment, which can amplify load on an already struggling server.
Adding Exponential Backoff and Jitter
Exponential backoff spaces retries out over time, and jitter prevents synchronized retry waves. A common formula is min(cap, base * 2 ** (attempt - 1)) + random.uniform(0, jitter).
import random def backoff_delay(attempt, base=0.5, cap=10.0): exponential = min(cap, base * 2 ** (attempt - 1)) return exponential + random.uniform(0, 0.5)
The cap keeps the delay from growing without bound, and the jitter term breaks the correlation between requests that started together. Use this in the retry loop instead of the fixed sleep:
await asyncio.sleep(backoff_delay(attempt))
For a service under load, the jitter matters more than the exact base value. Without it, a burst of failed requests retries in lockstep and can turn a small outage into a thundering herd.
Retrying Safely: Idempotency and Response Handling
Not every request should be retried. A retry repeats the request, and if the original request reached the server and produced a side effect, the retry may produce it again. GET, HEAD, PUT, and DELETE are generally safe to retry because they are idempotent. POST is not, unless your API explicitly supports retry-safe semantics such as idempotency keys.
Connection failures and timeouts that occur before a response is received are usually safe to retry even for POST, because the server may never have processed the request. But a timeout that fires after the server sent a response, or a 5xx status that indicates the server did process the request, is ambiguous. The conservative rule is: retry idempotent methods on any transient failure, and retry non-idempotent methods only when the failure happened before any response was received.
You should also decide which status codes are retryable. 408 Request Timeout, 429 Too Many Requests, and the 5xx range are reasonable candidates. A 404 or 400 will not become successful on a second attempt, so retrying them wastes time and load.
import aiohttp RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}
When you call response.raise_for_status(), a ClientResponseError is raised. Check exc.status against this set to decide whether to retry or re-raise immediately.
A Complete Implementation
The pieces come together into a single function that configures timeouts, retries transient failures with backoff, and re-raises permanent errors immediately.
import asyncio import logging import random import aiohttp from aiohttp import ClientTimeout logger = logging.getLogger(__name__) RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504} def backoff_delay(attempt, base=0.5, cap=10.0): exponential = min(cap, base * 2 ** (attempt - 1)) return exponential + random.uniform(0, 0.5) async def fetch_with_retry(session, url, *, max_attempts=4, timeout=None): timeout = timeout or ClientTimeout(total=30, connect=5, sock_read=10) last_exc = None for attempt in range(1, max_attempts + 1): try: async with session.get(url, timeout=timeout) as response: response.raise_for_status() return await response.text() except asyncio.TimeoutError as exc: last_exc = exc logger.warning("attempt %d timed out for %s", attempt, url) except aiohttp.ClientConnectorError as exc: last_exc = exc logger.warning("attempt %d connection failed for %s", attempt, url) except aiohttp.ClientResponseError as exc: if exc.status in RETRYABLE_STATUS: last_exc = exc logger.warning("attempt %d got retryable status %d", attempt, exc.status) else: raise if attempt < max_attempts: await asyncio.sleep(backoff_delay(attempt)) raise last_exc
The function returns the response body on success. On permanent errors, such as a 404, it re-raises immediately without wasting an attempt. On transient failures, it logs each attempt and raises the final exception only after the budget is exhausted.
Use it with a single session that lives for the lifetime of the application:
async def main(): async with aiohttp.ClientSession() as session: html = await fetch_with_retry(session, "https://example.com") print(html) if __name__ == "__main__": asyncio.run(main())
Production Considerations: Session Reuse, Concurrency, and Observability
Creating a new ClientSession for every request defeats aiohttp's connection pooling. Each session maintains a pool of keep-alive connections and reuses them across requests, which avoids repeated TCP and TLS handshakes. Create one session at application startup and pass it to every request path. The retry function above is designed for exactly that: it takes the session as an argument and never creates one internally.
When you run many requests concurrently with asyncio.gather, each coroutine gets its own timeout and retry loop, so one slow request does not block others. The total timeout is per request, not per batch, so a batch of 100 requests can take much longer than 30 seconds overall. If you need an overall deadline for a batch, wrap the gather call in asyncio.timeout rather than lowering the per-request timeout.
Observability matters because retries hide failures. Log each retry with the attempt number, the URL, and the exception, as the implementation above does. If you have a metrics system, count retries and final failures separately so an increase in retry volume is visible before it becomes a user-facing outage.
One subtlety: the retry loop sleeps with asyncio.sleep, which yields control to the event loop. That is correct for an async client, but it means a cancelled task, such as one cancelled by an outer asyncio.timeout, will raise CancelledError during the sleep. That is the desired behavior, because a cancelled request should not continue retrying. Do not catch CancelledError in the retry loop; let it propagate so cancellation works as expected.
Finally, decide what happens when the retry budget is exhausted. Re-raising the last exception preserves the original error type and message, which keeps the failure mode predictable for callers. Wrapping it in a custom exception can be useful if you want to attach the URL and attempt count, but keep the original exception as the cause so debugging does not lose the underlying failure.