""" BONUS TASK: Write a working REST API client with retry logic. Simulates: "We need a script that fetches paginated data from an API, handles rate limits, and writes results to a CSV." This task is bonus/unscored in the main benchmark but available for extended evaluation. It requires the agent to: 1. Write a class with exponential backoff retry 2. Handle pagination (next_cursor pattern) 3. Write results to CSV with proper headers 4. Unit tests must mock the HTTP calls (no real network) """ from __future__ import annotations TASK_ID = "bonus_api_client_retry" DIFFICULTY = "bonus" MAX_STEPS = 35 DESCRIPTION = """ ## Bonus Task: Paginated API Client with Retry Logic Write `client/fetcher.py` — a `DataFetcher` class that: 1. `fetch_all(endpoint: str) -> list[dict]` - Calls `GET endpoint?cursor=` in a loop - Stops when response has no `next_cursor` - Returns all items combined 2. Retry logic (exponential backoff): - On HTTP 429 or 5xx: wait `2^attempt` seconds, retry up to 3 times - On 4xx (except 429): raise immediately 3. Write results to CSV: `write_csv(data: list[dict], path: str) -> None` Tests mock `requests.get` — no real network calls. """ INITIAL_FILES: dict[str, str] = { "client/__init__.py": "from .fetcher import DataFetcher\n__all__ = ['DataFetcher']\n", "client/fetcher.py": """\ \"\"\"Paginated API client with retry logic — implement the TODOs.\"\"\" import csv import time from typing import Any import requests class DataFetcher: \"\"\"Fetches paginated data from a REST API with exponential backoff.\"\"\" MAX_RETRIES = 3 def __init__(self, base_url: str, api_key: str = "") -> None: self.base_url = base_url.rstrip("/") self.headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} def fetch_all(self, endpoint: str) -> list[dict[str, Any]]: \"\"\"Fetch all pages and return combined item list.\"\"\" # TODO: implement pagination + retry raise NotImplementedError def write_csv(self, data: list[dict[str, Any]], path: str) -> None: \"\"\"Write list of dicts to CSV file.\"\"\" # TODO: implement raise NotImplementedError def _get(self, url: str, params: dict | None = None) -> dict[str, Any]: \"\"\"Single HTTP GET with exponential backoff retry.\"\"\" # TODO: implement retry logic raise NotImplementedError """, "tests/__init__.py": "", "tests/test_fetcher.py": """\ \"\"\"Tests for DataFetcher — all HTTP calls are mocked.\"\"\" import csv, io, pytest from unittest.mock import MagicMock, patch from client import DataFetcher def mock_response(data, status=200): r = MagicMock() r.status_code = status r.json.return_value = data r.raise_for_status = MagicMock( side_effect=None if status < 400 else Exception(f"HTTP {status}") ) return r class TestFetchAll: def test_single_page(self): fetcher = DataFetcher("http://api.test") page1 = {"items": [{"id": 1}, {"id": 2}], "next_cursor": None} with patch("requests.get", return_value=mock_response(page1)): result = fetcher.fetch_all("/data") assert result == [{"id": 1}, {"id": 2}] def test_two_pages(self): fetcher = DataFetcher("http://api.test") p1 = {"items": [{"id": 1}], "next_cursor": "tok1"} p2 = {"items": [{"id": 2}], "next_cursor": None} responses = iter([mock_response(p1), mock_response(p2)]) with patch("requests.get", side_effect=lambda *a, **kw: next(responses)): result = fetcher.fetch_all("/data") assert result == [{"id": 1}, {"id": 2}] def test_empty_response(self): fetcher = DataFetcher("http://api.test") with patch("requests.get", return_value=mock_response({"items": [], "next_cursor": None})): assert fetcher.fetch_all("/data") == [] def test_auth_header_sent(self): fetcher = DataFetcher("http://api.test", api_key="secret") page = {"items": [], "next_cursor": None} with patch("requests.get", return_value=mock_response(page)) as mock_get: fetcher.fetch_all("/data") _, kwargs = mock_get.call_args assert "Authorization" in kwargs.get("headers", {}) class TestRetry: def test_retries_on_429(self): fetcher = DataFetcher("http://api.test") rate_limited = mock_response({}, 429) success = mock_response({"items": [{"id": 1}], "next_cursor": None}, 200) calls = iter([rate_limited, rate_limited, success]) with patch("requests.get", side_effect=lambda *a, **kw: next(calls)), \ patch("time.sleep"): result = fetcher.fetch_all("/data") assert result == [{"id": 1}] def test_raises_on_404(self): fetcher = DataFetcher("http://api.test") with patch("requests.get", return_value=mock_response({}, 404)): with pytest.raises(Exception): fetcher.fetch_all("/data") def test_max_retries_exceeded_raises(self): fetcher = DataFetcher("http://api.test") always_429 = mock_response({}, 429) with patch("requests.get", return_value=always_429), \ patch("time.sleep"): with pytest.raises(Exception): fetcher.fetch_all("/data") class TestWriteCsv: def test_writes_headers_and_rows(self, tmp_path): fetcher = DataFetcher("http://api.test") data = [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}] path = str(tmp_path / "out.csv") fetcher.write_csv(data, path) with open(path) as f: rows = list(csv.DictReader(f)) assert rows[0]["name"] == "Alice" assert rows[1]["age"] == "25" def test_empty_data_writes_only_header(self, tmp_path): fetcher = DataFetcher("http://api.test") path = str(tmp_path / "empty.csv") fetcher.write_csv([{"id": 0}], path) # write once to get header fetcher.write_csv([], path) # overwrite with empty with open(path) as f: content = f.read() assert content.strip() == "" or "id" in content # either empty or header """, "pyproject.toml": "[tool.ruff]\nline-length = 88\nselect = [\"E\", \"F\", \"W\"]\nignore = []\n", "README.md": "# Bonus: API Client with Retry\n\nImplement paginated fetch + exponential backoff retry.\n", } REQUIRED_KEYWORDS_IN_REVIEW = [ "exponential", "backoff", "pagination", "cursor", "retry" ] PASSING_TESTS = 9