Claude Sonnet 4.6 × api-client
3.7Class design + error handling + type hints + docs
correctness 2.0quality 5.0documentation 4.037832ms
$ cat api-client.prompt — what the model was asked
Write a Python class `APIClient` that wraps the `requests` library for a REST API. It should: (1) accept a base_url and optional api_key in __init__, (2) have a `get(path, params=None)` method and a `post(path, data)` method, (3) raise a custom `APIError` exception with the status code and message on non-2xx responses, (4) include type hints throughout, (5) have a complete docstring on the class and each public method. Show a usage example at the bottom.
run 1 of 1
3.7correctness 2.0quality 5.0documentation 4.037832ms
# judge: Response is cut off mid-docstring for post(), missing the actual implementation and the required usage example, making the class non-functional and the task incomplete despite good style in the parts that exist.
$ cat claude-sonnet-4-6.api-client.r1.response
```python
"""
REST API Client wrapper around the requests library.
"""
import requests
from typing import Any, Optional
class APIError(Exception):
"""
Custom exception raised when an API response has a non-2xx status code.
Attributes:
status_code (int): The HTTP status code returned by the API.
message (str): A descriptive error message.
"""
def __init__(self, status_code: int, message: str) -> None:
"""
Initialize APIError with a status code and message.
Args:
status_code (int): The HTTP status code returned by the API.
message (str): A descriptive error message.
"""
self.status_code = status_code
self.message = message
super().__init__(f"APIError {status_code}: {message}")
class APIClient:
"""
A reusable HTTP client that wraps the `requests` library for interacting
with a REST API.
This client handles:
- Base URL management so callers only supply relative paths.
- Optional API key authentication via the Authorization header.
- Automatic JSON serialization/deserialization for requests and responses.
- Consistent error handling: non-2xx responses raise `APIError`.
Attributes:
base_url (str): The root URL of the API (e.g., "https://api.example.com").
session (requests.Session): The underlying requests session, which
enables connection reuse and shared headers across all calls.
Example:
>>> client = APIClient("https://jsonplaceholder.typicode.com")
>>> post = client.get("/posts/1")
>>> print(post["title"])
"""
def __init__(self, base_url: str, api_key: Optional[str] = None) -> None:
"""
Initialize the API client.
Args:
base_url (str): The root URL of the API. Trailing slashes are
stripped automatically so path joining is consistent.
api_key (Optional[str]): If provided, this value is sent as a
Bearer token in the `Authorization` header of every request.
Example:
>>> client = APIClient("https://api.example.com", api_key="secret")
"""
self.base_url = base_url.rstrip("/")
self.session = requests.Session()
# Set shared headers for every request made by this session.
self.session.headers.update({"Content-Type": "application/json",
"Accept": "application/json"})
if api_key:
self.session.headers.update({"Authorization": f"Bearer {api_key}"})
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _build_url(self, path: str) -> str:
"""
Combine the base URL with a relative path.
Args:
path (str): The API endpoint path (e.g., "/users/42").
Returns:
str: The fully-qualified URL.
"""
return f"{self.base_url}/{path.lstrip('/')}"
def _handle_response(self, response: requests.Response) -> Any:
"""
Inspect an HTTP response and either return its parsed JSON body or
raise `APIError` for non-2xx status codes.
Args:
response (requests.Response): The response object from requests.
Returns:
Any: The parsed JSON payload (dict, list, etc.).
Raises:
APIError: If the response status code is not in the 2xx range.
"""
if not response.ok: # ok is True for 2xx status codes
# Attempt to extract a descriptive message from the response body.
try:
detail = response.json()
# Many APIs nest the error message under "message" or "error".
message = (
detail.get("message")
or detail.get("error")
or str(detail)
)
except ValueError:
# Fall back to raw text if the body is not valid JSON.
message = response.text or response.reason
raise APIError(status_code=response.status_code, message=message)
# Return parsed JSON when the response body is not empty.
if response.content:
return response.json()
return None
# ------------------------------------------------------------------
# Public interface
# ------------------------------------------------------------------
def get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any:
"""
Send an HTTP GET request to the specified endpoint.
Query parameters are URL-encoded and appended to the request URL
automatically by the underlying requests library.
Args:
path (str): The API endpoint path relative to the base URL
(e.g., "/users" or "users/42").
params (Optional[dict[str, Any]]): A dictionary of query string
parameters. Defaults to ``None`` (no query parameters).
Returns:
Any: The parsed JSON response body.
Raises:
APIError: If the server returns a non-2xx status code.
requests.exceptions.RequestException: For low-level network errors
(connection refused, timeout, etc.).
Example:
>>> client = APIClient("https://jsonplaceholder.typicode.com")
>>> users = client.get("/users", params={"_limit": 3})
>>> len(users)
3
"""
url = self._build_url(path)
response = self.session.get(url, params=params)
return self._handle_response(response)
def post(self, path: str, data: dict[str, Any]) -> Any:
"""
Send an HTTP POST request with a JSON body to the specified endpoint.
The ``data`` dictionary is serialized to JSON automatically. The
``Content-Type: application/json`` header is set for every request via
the shared session, so no extra configuration is required.
Args:
path (str): The API endpoint path relative to the base URL
(e.g., "/posts" or "auth/login").
data (dict[str, Any]): The request payload. Must be a dictionary
that is JSON-serializable.
Returns:
Any: The parsed JSON response body (often the newly created
resource, depending on the API).
Raises:
APIError: If the server returns a non-2xx status code.
requests.exceptions.RequestException: For low-level network errors
(connection refused, timeout, etc.).
Example:
>>> client = APIClient("https://jsonplaceholder.typicode.com")
>>> new_post = client.post("/posts", data={"title": "Hello", "body": "World", "userId": 1})
>>> print(new_post["id"])
101
"""
url = self._build_url(path)
response = self.session.post(url, json=data)
return self._handle_response(response)
# ---------------------------------------------------------------------------
# Usage example
# ---------------------------------------------------------------------------
if __name__ == "__main__":
# We use JSONPlaceholder, a free public REST API, so this example requires
# no credentials and can be run as-is.
BASE_URL = "https://jsonplaceholder.typicode.com"
# ── 1. Create the client (no API key needed for JSONPlaceholder) ─────────
client = APIClient(BASE_URL)
# ── 2. GET a single resource ─────────────────────────────────────────────
print("── GET /posts/1 ─────────────────────────────────────────────────")
try:
post = client.get("/posts/1")
print(f" id : {post['id']}")
print(f" title : {post['title']}")
except APIError as exc:
print(f" Request failed → {exc}")
# ── 3. GET with query parameters ─────────────────────────────────────────
print("\n── GET /posts?_limit=3 ──────────────────────────────────────────")
try:
posts = client.get("/posts", params={"_limit": 3})
for p in posts:
print(f" [{p['id']}] {p['title'][:50]}")
except APIError as exc:
print(f" Request failed → {exc}")
# ── 4. POST a new resource ───────────────────────────────────────────────
print("\n── POST /posts ──────────────────────────────────────────────────")
try:
new_post = client.post(
"/posts",
data={"title": "My New Post", "body": "Hello, world!", "userId": 1},
)
print(f" Created post with id: {new_post['id']}")
print(f" Title : {new_post['title']}")
except APIError as exc:
print(f" Request failed → {exc}")
# ── 5. Demonstrate error handling ────────────────────────────────────────
print("\n── GET /posts/99999 (non-existent → 404) ────────────────────────")
try:
client.get("/posts/99999")
except APIError as exc:
# APIError carries the status code separately so callers can branch on it.
print(f" Caught APIError!")
print(f" Status code : {exc.status_code}")
print(f" Message : {exc.message}")
```
---
## How it works — key design decisions
| Decision | Rationale |
|---|---|
| **`requests.Session`** | Reuses the TCP connection across multiple calls and centralises shared headers (auth, content-type), avoiding repetition in every method. |
| **`_build_url`** strips slashes | Prevents double-slash URLs regardless of whether the caller writes `"/users"` or `"users"`. |
| **`_handle_response`** is private | Keeps error-handling logic in one place; both `get` and `post` delegate to it without duplication. |
| **`response.ok`** | A clean `requests` shortcut that is `True` for any 2xx status code. |
| **`APIError` carries `status_code`** | Callers can catch the exception and inspect the code to implement retry logic or branch on specific errors (401 → re-auth, 429 → back-off, etc.). |
| **`json=data` in POST** | Lets `requests` handle serialization *and* automatically sets the correct `Content-Type`, removing a common source of bugs. |