API Client Design — Deep Dive

Architecture overview

A production API client in Python is more than a convenience wrapper — it’s a contract between your application and an external service. The design choices you make here affect reliability, testability, and developer experience across your entire codebase.

The architecture follows a layered approach: transport → middleware → resource → response model.

Transport layer with httpx

Modern Python API clients benefit from httpx over requests because it supports both sync and async from the same codebase:

import httpx
from typing import Any


class Transport:
    def __init__(
        self,
        base_url: str,
        timeout: float = 30.0,
        max_retries: int = 3,
    ):
        self._client = httpx.Client(
            base_url=base_url,
            timeout=httpx.Timeout(timeout),
            limits=httpx.Limits(
                max_connections=100,
                max_keepalive_connections=20,
            ),
        )
        self._max_retries = max_retries

    def request(
        self, method: str, path: str, **kwargs: Any
    ) -> httpx.Response:
        response = self._client.request(method, path, **kwargs)
        return response

    def close(self) -> None:
        self._client.close()

The httpx.Limits configuration controls the connection pool. Setting max_keepalive_connections prevents socket exhaustion while maintaining reuse. The default in httpx is 20, which works for most single-service clients. High-throughput services may need 50-100.

Authentication middleware

Authentication should be injected, not hardcoded. Use httpx’s auth protocol or a custom event hook:

import httpx
import time
from dataclasses import dataclass


@dataclass
class TokenAuth(httpx.Auth):
    access_token: str
    refresh_token: str
    expires_at: float
    token_url: str

    def auth_flow(self, request: httpx.Request):
        if time.time() >= self.expires_at - 30:
            self._refresh()
        request.headers["Authorization"] = f"Bearer {self.access_token}"
        yield request

    def _refresh(self) -> None:
        resp = httpx.post(
            self.token_url,
            data={
                "grant_type": "refresh_token",
                "refresh_token": self.refresh_token,
            },
        )
        resp.raise_for_status()
        data = resp.json()
        self.access_token = data["access_token"]
        self.expires_at = time.time() + data["expires_in"]

This pattern handles token refresh transparently. The auth_flow generator is called for every request, so expiry checks are automatic.

Typed exception hierarchy

Map HTTP status codes to a typed exception tree:

class APIError(Exception):
    def __init__(self, status_code: int, message: str, response_body: dict):
        self.status_code = status_code
        self.message = message
        self.response_body = response_body
        super().__init__(f"{status_code}: {message}")


class NotFoundError(APIError):
    pass


class ValidationError(APIError):
    pass


class RateLimitError(APIError):
    def __init__(self, retry_after: float, **kwargs):
        super().__init__(**kwargs)
        self.retry_after = retry_after


class ServerError(APIError):
    """5xx errors — typically retriable."""
    pass


def raise_for_status(response: httpx.Response) -> None:
    if response.is_success:
        return

    body = response.json() if response.headers.get(
        "content-type", ""
    ).startswith("application/json") else {}
    message = body.get("error", {}).get("message", response.reason_phrase)

    if response.status_code == 404:
        raise NotFoundError(
            status_code=404, message=message, response_body=body
        )
    elif response.status_code == 422:
        raise ValidationError(
            status_code=422, message=message, response_body=body
        )
    elif response.status_code == 429:
        retry_after = float(
            response.headers.get("Retry-After", 1.0)
        )
        raise RateLimitError(
            retry_after=retry_after,
            status_code=429,
            message=message,
            response_body=body,
        )
    elif response.status_code >= 500:
        raise ServerError(
            status_code=response.status_code,
            message=message,
            response_body=body,
        )
    else:
        raise APIError(
            status_code=response.status_code,
            message=message,
            response_body=body,
        )

Callers can now catch RateLimitError specifically and inspect retry_after, rather than parsing raw responses.

Resource layer pattern

Group endpoints into resource classes that share the transport:

from dataclasses import dataclass
from typing import Optional


@dataclass
class User:
    id: int
    email: str
    name: str


class UsersResource:
    def __init__(self, transport: Transport):
        self._transport = transport

    def get(self, user_id: int) -> User:
        resp = self._transport.request("GET", f"/users/{user_id}")
        raise_for_status(resp)
        data = resp.json()
        return User(id=data["id"], email=data["email"], name=data["name"])

    def list(
        self, page: int = 1, per_page: int = 20
    ) -> list[User]:
        resp = self._transport.request(
            "GET", "/users", params={"page": page, "per_page": per_page}
        )
        raise_for_status(resp)
        return [
            User(id=u["id"], email=u["email"], name=u["name"])
            for u in resp.json()["data"]
        ]

    def create(self, email: str, name: str) -> User:
        resp = self._transport.request(
            "POST", "/users", json={"email": email, "name": name}
        )
        raise_for_status(resp)
        data = resp.json()
        return User(id=data["id"], email=data["email"], name=data["name"])

Composing the client

class MyServiceClient:
    def __init__(
        self,
        api_key: str,
        base_url: str = "https://api.myservice.com/v1",
        timeout: float = 30.0,
    ):
        self._transport = Transport(
            base_url=base_url, timeout=timeout
        )
        self._transport._client.headers["Authorization"] = (
            f"Bearer {api_key}"
        )
        self.users = UsersResource(self._transport)

    def close(self) -> None:
        self._transport.close()

    def __enter__(self):
        return self

    def __exit__(self, *args):
        self.close()

Usage becomes clean and discoverable: client.users.get(42).

Retry strategy with backoff

Embed retry logic in the transport for idempotent methods:

import time
import random


IDEMPOTENT_METHODS = {"GET", "PUT", "DELETE", "HEAD", "OPTIONS"}


def request_with_retry(
    client: httpx.Client,
    method: str,
    path: str,
    max_retries: int = 3,
    **kwargs,
) -> httpx.Response:
    retries = 0
    while True:
        try:
            resp = client.request(method, path, **kwargs)
            if resp.status_code == 429:
                wait = float(resp.headers.get("Retry-After", 1.0))
                time.sleep(wait)
                retries += 1
                if retries > max_retries:
                    raise_for_status(resp)
                continue
            if resp.status_code >= 500 and method in IDEMPOTENT_METHODS:
                retries += 1
                if retries > max_retries:
                    raise_for_status(resp)
                backoff = min(2 ** retries + random.random(), 30)
                time.sleep(backoff)
                continue
            return resp
        except httpx.TransportError:
            retries += 1
            if retries > max_retries:
                raise
            backoff = min(2 ** retries + random.random(), 30)
            time.sleep(backoff)

Key detail: only retry idempotent methods automatically. Retrying a POST without an idempotency key risks duplicate records.

Testing your client

Design the transport as an injectable dependency. In tests, replace it with a mock transport or use httpx.MockTransport:

import httpx


def mock_handler(request: httpx.Request) -> httpx.Response:
    if request.url.path == "/users/1":
        return httpx.Response(
            200,
            json={"id": 1, "email": "test@example.com", "name": "Test"},
        )
    return httpx.Response(404, json={"error": {"message": "Not found"}})


def test_get_user():
    mock_client = httpx.Client(
        transport=httpx.MockTransport(mock_handler),
        base_url="https://api.test.com/v1",
    )
    transport = Transport.__new__(Transport)
    transport._client = mock_client
    transport._max_retries = 0

    users = UsersResource(transport)
    user = users.get(1)
    assert user.email == "test@example.com"

No network calls, fast tests, and you’re testing your parsing and error-handling logic in isolation.

Tradeoffs and design decisions

DecisionTradeoff
Sync vs asyncSync is simpler; async needed for high-concurrency services
Pydantic models vs dataclassesPydantic validates at parse time; dataclasses are lighter
Retry in transport vs callerTransport retry is invisible — callers can’t override per-call
Raising exceptions vs returning Result typesExceptions are Pythonic but can be missed; Result forces handling

Real-world reference implementations

Study these well-designed Python API clients for inspiration:

  • stripe-python: Clean resource grouping, automatic pagination, idempotency keys
  • google-cloud-python: gapic-generated clients with retry config objects
  • github3.py: Session-based auth, lazy iteration over paginated results
  • httpx itself: Its Client class is a masterclass in transport configuration

The one thing to remember: A production API client encodes domain knowledge (which calls are safe to retry, how auth refreshes, what errors mean) into reusable, testable layers — so every caller inherits correct behavior without thinking about it.

pythonapisarchitecture

See Also

  • Python Aiohttp Client Understand Aiohttp Client through a practical analogy so your Python decisions become faster and clearer.
  • Python Api Documentation Swagger Swagger turns your Python API into an interactive playground where anyone can click buttons to try it out — no coding required.
  • Python Api Mocking Responses Why testing with fake API responses is like rehearsing a play with stand-ins before the real actors show up.
  • Python Api Pagination Clients Why APIs send data in pages, and how Python handles it — like reading a book one chapter at a time instead of swallowing the whole thing.
  • Python Beautifulsoup Understand Beautifulsoup through a practical analogy so your Python decisions become faster and clearer.