Claude Sonnet 4.6 × async-fetch
8.3Async concurrency patterns + retry/timeout handling
correctness 8.0quality 8.0documentation 9.029212ms
$ cat async-fetch.prompt — what the model was asked
Write a Python async function `fetch_all(urls: list[str], timeout: float = 5.0, retries: int = 2) -> list[dict]` that fetches all URLs concurrently using `aiohttp` and returns a list of result dicts. Each result dict must have: - `url`: the original URL - `status`: HTTP status code (int), or None on error - `body`: response text (str), or None on error - `error`: error message string, or None on success Requirements: 1. All URLs must be fetched concurrently (not sequentially) 2. Each fetch must respect the `timeout` (per-request, not total) 3. Retry up to `retries` times on network errors or 5xx responses (exponential backoff: 0.5s, 1s, ...) 4. Never raise — errors go into the result dict 5. Type-hint everything; add a docstring 6. Show a `__main__` block that fetches 3 URLs and prints results
run 1 of 1
8.3correctness 8.0quality 8.0documentation 9.029212ms
# judge: Solid implementation with proper concurrency, per-request timeout, exponential backoff, and never-raise behavior, though the response is truncated mid-explanation and uses a slightly odd backoff scheme (0s on first attempt) plus TCPConnector(limit=0) which removes any concurrency control.'
$ cat claude-sonnet-4-6.async-fetch.r1.response
```python
"""
Async URL fetcher with concurrency, per-request timeout, retries, and error handling.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Optional
import aiohttp
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
async def _fetch_one(
session: aiohttp.ClientSession,
url: str,
timeout: float,
retries: int,
) -> dict:
"""
Fetch a single URL with retry logic.
Args:
session: Shared aiohttp ClientSession.
url: The URL to fetch.
timeout: Per-request timeout in seconds.
retries: Maximum number of retry attempts on network errors or 5xx responses.
Returns:
A dict with keys ``url``, ``status``, ``body``, and ``error``.
"""
last_error: Optional[str] = None
for attempt in range(retries + 1): # attempt 0 is the first try
# Exponential back-off: 0 s on the first attempt, 0.5 s, 1 s, 2 s, …
if attempt > 0:
backoff: float = 0.5 * (2 ** (attempt - 1))
logger.info("Retry %d/%d for %s — waiting %.1fs", attempt, retries, url, backoff)
await asyncio.sleep(backoff)
try:
client_timeout = aiohttp.ClientTimeout(total=timeout)
async with session.get(url, timeout=client_timeout, allow_redirects=True) as response:
status: int = response.status
body: str = await response.text()
if status >= 500:
# Treat 5xx as a retryable error
last_error = f"Server error: HTTP {status}"
logger.warning("Got %d from %s (attempt %d/%d)", status, url, attempt, retries)
continue # try again
# Success (any non-5xx response is considered a valid result)
return {
"url": url,
"status": status,
"body": body,
"error": None,
}
except asyncio.TimeoutError:
last_error = f"Request timed out after {timeout}s"
logger.warning("Timeout on %s (attempt %d/%d)", url, attempt, retries)
except aiohttp.ClientConnectionError as exc:
last_error = f"Connection error: {exc}"
logger.warning("Connection error on %s (attempt %d/%d): %s", url, attempt, retries, exc)
except aiohttp.ClientError as exc:
# Non-retryable client-side errors (e.g. invalid URL scheme)
last_error = f"Client error: {exc}"
logger.error("Non-retryable client error on %s: %s", url, exc)
break # do NOT retry
except Exception as exc: # pragma: no cover — safety net
last_error = f"Unexpected error: {exc}"
logger.exception("Unexpected error on %s", url)
break
# All attempts exhausted (or a non-retryable error occurred)
return {
"url": url,
"status": None,
"body": None,
"error": last_error,
}
async def fetch_all(
urls: list[str],
timeout: float = 5.0,
retries: int = 2,
) -> list[dict]:
"""
Fetch all URLs concurrently and return a list of result dicts.
Each result dict contains:
- ``url`` (str) – the original URL
- ``status`` (int | None) – HTTP status code, or None on error
- ``body`` (str | None) – response text, or None on error
- ``error`` (str | None) – error message, or None on success
Args:
urls: List of URLs to fetch.
timeout: Per-request timeout in seconds (default 5.0).
retries: Number of retry attempts on network errors or 5xx (default 2).
Retries use exponential back-off: 0.5s, 1s, 2s, …
Returns:
A list of result dicts in the **same order** as the input ``urls``.
Notes:
- All URLs are fetched concurrently via ``asyncio.gather``.
- This function never raises; all errors are captured in the result dict.
- A shared ``aiohttp.ClientSession`` is used for connection pooling.
"""
if not urls:
return []
# A single shared session reuses connections efficiently (connection pooling)
connector = aiohttp.TCPConnector(limit=0) # no per-host limit; caller controls concurrency
async with aiohttp.ClientSession(connector=connector) as session:
tasks = [
_fetch_one(session, url, timeout, retries)
for url in urls
]
# gather preserves order and never cancels sibling tasks on failure
results: list[dict] = await asyncio.gather(*tasks, return_exceptions=False)
return results
# ---------------------------------------------------------------------------
# __main__ demo
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import json
TEST_URLS: list[str] = [
"https://httpbin.org/get", # should succeed (200)
"https://httpbin.org/status/503", # 5xx → retried, then error
"https://this-domain-does-not-exist.xyz/", # DNS failure → error
]
async def main() -> None:
print("Fetching URLs …\n")
results = await fetch_all(TEST_URLS, timeout=5.0, retries=2)
for result in results:
# Truncate body for readability
display = result.copy()
if display["body"] and len(display["body"]) > 120:
display["body"] = display["body"][:120] + "…"
print(json.dumps(display, indent=2))
print("-" * 60)
asyncio.run(main())
```
---
## How it works
### Concurrency
`asyncio.gather(*tasks)` launches **all** coroutines at the same time. The event loop interleaves I/O waits so no URL blocks another.
### Per-request timeout
`aiohttp.ClientTimeout(total=timeout)` is created fresh for every `GET` call, so each URL gets its own independent countdown.
### Retry strategy
| Condition | Retried? | Back-off |
|-----------|----------|---------|
| Network / DNS error (`ClientConnectionError`) | ✅ | 0.5 s, 1 s, 2 s … |
| Timeout | ✅ | same |
| HTTP 5xx | ✅ | same |
| HTTP 4xx / 2xx / 3xx | ❌ | — (treated as a valid response) |
| Bad URL scheme etc. (`ClientError`) | ❌ | — (non-retryable) |
Back-off formula: `0.5 × 2^(attempt−1)` → **0.5 s → 1 s → 2 s → …**
### Error safety
Every exception is caught inside `_fetch_one`; `fetch_all` uses `return_exceptions=False` (the default) because individual errors are already handled — nothing propagates out.
### Order preservation
`asyncio.gather` always returns results **in the same order** as the input list, regardless of which request finishes first.