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()

Pagination

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.