A well-designed API client turns complex HTTP interactions into simple method calls. It handles authentication, retries, errors, and serialization β so users don’t have to.
These patterns create clients that developers actually enjoy using.
Basic Structure#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| import httpx
from typing import Optional
from dataclasses import dataclass
@dataclass
class APIConfig:
base_url: str
api_key: str
timeout: float = 30.0
max_retries: int = 3
class APIClient:
def __init__(self, config: APIConfig):
self.config = config
self._client = httpx.Client(
base_url=config.base_url,
timeout=config.timeout,
headers={"Authorization": f"Bearer {config.api_key}"}
)
def _request(self, method: str, path: str, **kwargs) -> dict:
response = self._client.request(method, path, **kwargs)
response.raise_for_status()
return response.json()
def close(self):
self._client.close()
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
|
Resource-Based Design#
Organize by resource, not by HTTP method:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| class UsersResource:
def __init__(self, client: APIClient):
self._client = client
def list(self, page: int = 1, per_page: int = 20) -> list:
return self._client._request(
"GET", "/users",
params={"page": page, "per_page": per_page}
)
def get(self, user_id: str) -> dict:
return self._client._request("GET", f"/users/{user_id}")
def create(self, email: str, name: str) -> dict:
return self._client._request(
"POST", "/users",
json={"email": email, "name": name}
)
def update(self, user_id: str, **fields) -> dict:
return self._client._request(
"PATCH", f"/users/{user_id}",
json=fields
)
def delete(self, user_id: str) -> None:
self._client._request("DELETE", f"/users/{user_id}")
class APIClient:
def __init__(self, config: APIConfig):
# ... init code ...
self.users = UsersResource(self)
self.orders = OrdersResource(self)
self.products = ProductsResource(self)
# Usage
client = APIClient(config)
users = client.users.list()
user = client.users.create(email="alice@example.com", name="Alice")
|
Type-Safe Responses#
Return dataclasses or Pydantic models, not raw dicts:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| from pydantic import BaseModel
from datetime import datetime
from typing import List, Optional
class User(BaseModel):
id: str
email: str
name: str
created_at: datetime
class UserList(BaseModel):
users: List[User]
total: int
page: int
per_page: int
class UsersResource:
def list(self, page: int = 1) -> UserList:
data = self._client._request("GET", "/users", params={"page": page})
return UserList(**data)
def get(self, user_id: str) -> User:
data = self._client._request("GET", f"/users/{user_id}")
return User(**data)
# Usage - IDE autocomplete works!
user = client.users.get("123")
print(user.email) # Typed as str
print(user.created_at) # Typed as datetime
|
Error Handling#
Create specific exception types:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
| class APIError(Exception):
def __init__(self, message: str, status_code: int = None, response: dict = None):
self.message = message
self.status_code = status_code
self.response = response
super().__init__(message)
class AuthenticationError(APIError):
"""Invalid or expired API key"""
pass
class RateLimitError(APIError):
"""Rate limit exceeded"""
def __init__(self, retry_after: int = None, **kwargs):
super().__init__(**kwargs)
self.retry_after = retry_after
class NotFoundError(APIError):
"""Resource not found"""
pass
class ValidationError(APIError):
"""Invalid request data"""
def __init__(self, errors: list = None, **kwargs):
super().__init__(**kwargs)
self.errors = errors or []
def _handle_error(response: httpx.Response):
data = response.json() if response.content else {}
if response.status_code == 401:
raise AuthenticationError("Invalid API key", status_code=401)
elif response.status_code == 404:
raise NotFoundError(data.get("message", "Not found"), status_code=404)
elif response.status_code == 422:
raise ValidationError(
data.get("message", "Validation failed"),
errors=data.get("errors", []),
status_code=422
)
elif response.status_code == 429:
raise RateLimitError(
"Rate limit exceeded",
retry_after=int(response.headers.get("Retry-After", 60)),
status_code=429
)
else:
raise APIError(
data.get("message", f"API error: {response.status_code}"),
status_code=response.status_code,
response=data
)
|
Automatic Retries#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| import time
import random
from functools import wraps
def with_retry(max_retries: int = 3, backoff_base: float = 1.0):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except RateLimitError as e:
last_exception = e
time.sleep(e.retry_after or 60)
except (httpx.ConnectError, httpx.ReadTimeout) as e:
last_exception = e
if attempt < max_retries - 1:
delay = backoff_base * (2 ** attempt) + random.uniform(0, 1)
time.sleep(delay)
raise last_exception
return wrapper
return decorator
class APIClient:
@with_retry(max_retries=3)
def _request(self, method: str, path: str, **kwargs) -> dict:
response = self._client.request(method, path, **kwargs)
if not response.is_success:
_handle_error(response)
return response.json()
|
Async Support#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| import httpx
class AsyncAPIClient:
def __init__(self, config: APIConfig):
self.config = config
self._client = httpx.AsyncClient(
base_url=config.base_url,
timeout=config.timeout,
headers={"Authorization": f"Bearer {config.api_key}"}
)
async def _request(self, method: str, path: str, **kwargs) -> dict:
response = await self._client.request(method, path, **kwargs)
if not response.is_success:
_handle_error(response)
return response.json()
async def close(self):
await self._client.aclose()
async def __aenter__(self):
return self
async def __aexit__(self, *args):
await self.close()
# Usage
async with AsyncAPIClient(config) as client:
users = await client.users.list()
|
Make pagination effortless:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| class UsersResource:
def list(self, page: int = 1, per_page: int = 20) -> UserList:
data = self._client._request(
"GET", "/users",
params={"page": page, "per_page": per_page}
)
return UserList(**data)
def list_all(self) -> Iterator[User]:
"""Iterate through all users, handling pagination automatically"""
page = 1
while True:
result = self.list(page=page)
for user in result.users:
yield user
if page * result.per_page >= result.total:
break
page += 1
# Usage
for user in client.users.list_all():
print(user.email)
|
Configuration#
Support multiple configuration methods:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| import os
class APIClient:
@classmethod
def from_env(cls) -> "APIClient":
"""Create client from environment variables"""
return cls(APIConfig(
base_url=os.environ.get("API_BASE_URL", "https://api.example.com"),
api_key=os.environ["API_KEY"],
))
@classmethod
def from_file(cls, path: str) -> "APIClient":
"""Create client from config file"""
import json
with open(path) as f:
config = json.load(f)
return cls(APIConfig(**config))
# Usage
client = APIClient.from_env()
client = APIClient.from_file("~/.myapi/config.json")
client = APIClient(APIConfig(base_url="...", api_key="..."))
|
Logging and Debugging#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| import logging
logger = logging.getLogger("myapi")
class APIClient:
def __init__(self, config: APIConfig, debug: bool = False):
self.config = config
self.debug = debug
if debug:
logger.setLevel(logging.DEBUG)
def _request(self, method: str, path: str, **kwargs) -> dict:
logger.debug(f"Request: {method} {path}")
response = self._client.request(method, path, **kwargs)
logger.debug(f"Response: {response.status_code}")
if self.debug:
logger.debug(f"Body: {response.text[:500]}")
return response.json()
|
Complete Example#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
| """
MyAPI Python Client
Usage:
from myapi import Client
client = Client.from_env()
users = client.users.list()
"""
import httpx
from typing import Iterator, Optional
from pydantic import BaseModel
from dataclasses import dataclass
# Models
class User(BaseModel):
id: str
email: str
name: str
class UserList(BaseModel):
users: list[User]
total: int
page: int
# Exceptions
class APIError(Exception): ...
class NotFoundError(APIError): ...
class ValidationError(APIError): ...
# Config
@dataclass
class Config:
api_key: str
base_url: str = "https://api.example.com/v1"
timeout: float = 30.0
# Client
class Client:
def __init__(self, config: Config):
self._http = httpx.Client(
base_url=config.base_url,
timeout=config.timeout,
headers={"Authorization": f"Bearer {config.api_key}"}
)
self.users = UsersResource(self)
@classmethod
def from_env(cls) -> "Client":
import os
return cls(Config(api_key=os.environ["MYAPI_KEY"]))
def _request(self, method: str, path: str, **kwargs) -> dict:
resp = self._http.request(method, path, **kwargs)
if resp.status_code == 404:
raise NotFoundError("Resource not found")
resp.raise_for_status()
return resp.json()
class UsersResource:
def __init__(self, client: Client):
self._client = client
def get(self, id: str) -> User:
return User(**self._client._request("GET", f"/users/{id}"))
def list(self, page: int = 1) -> UserList:
return UserList(**self._client._request("GET", "/users", params={"page": page}))
def create(self, email: str, name: str) -> User:
return User(**self._client._request("POST", "/users", json={"email": email, "name": name}))
|
Good API clients abstract complexity without hiding capability. They handle the boring parts (auth, retries, serialization) while exposing the interesting parts (resources, operations, data).
Type hints, specific exceptions, and pagination helpers transform a raw HTTP wrapper into a tool developers reach for. The client that feels natural gets adopted; the client that fights you gets replaced.