mager-bench

Claude Sonnet 4.6 × async-fetch

8.3

Async 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.3
correctness 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.