SDK Design Patterns — Deep Dive
SDK architecture decisions
Building a Python SDK involves a series of interconnected design decisions. Each choice ripples through the entire API surface. This guide covers the patterns used by the most successful SDKs in the Python ecosystem and the tradeoffs behind each.
Dual sync/async support
Modern Python services need both sync and async interfaces. The naive approach — maintaining two codebases — is a maintenance nightmare. Three viable strategies exist:
Strategy 1: Async-first with sync wrapper
import asyncio
import httpx
from typing import TypeVar, Callable, Any
from functools import wraps
T = TypeVar("T")
def sync_wrapper(async_fn: Callable[..., Any]) -> Callable[..., Any]:
@wraps(async_fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(async_fn(*args, **kwargs))
finally:
loop.close()
return wrapper
class AsyncUsers:
def __init__(self, client: httpx.AsyncClient):
self._client = client
async def get(self, user_id: int) -> dict:
resp = await self._client.get(f"/users/{user_id}")
resp.raise_for_status()
return resp.json()
class SyncUsers:
def __init__(self, async_users: AsyncUsers):
self.get = sync_wrapper(async_users.get)
Strategy 2: Code generation from OpenAPI spec. Tools like openapi-python-client generate both sync and async versions from the same spec. This is how Google’s gapic clients work — a protobuf definition generates the entire SDK.
Strategy 3: Transport abstraction. Define business logic once with a transport interface, then provide sync and async implementations:
from abc import ABC, abstractmethod
from typing import Any
class BaseTransport(ABC):
@abstractmethod
def request(self, method: str, path: str, **kwargs: Any) -> dict:
...
class SyncTransport(BaseTransport):
def __init__(self, base_url: str, api_key: str):
import httpx
self._client = httpx.Client(
base_url=base_url,
headers={"Authorization": f"Bearer {api_key}"},
)
def request(self, method: str, path: str, **kwargs: Any) -> dict:
resp = self._client.request(method, path, **kwargs)
resp.raise_for_status()
return resp.json()
The tradeoff: Strategy 1 is simplest but creates a new event loop per call (slow for repeated sync usage). Strategy 2 requires build tooling. Strategy 3 has the cleanest separation but more boilerplate.
Resource descriptor pattern
Large APIs benefit from lazy resource initialization. Python descriptors make this elegant:
from typing import TypeVar, Generic, Type
T = TypeVar("T")
class ResourceDescriptor(Generic[T]):
def __init__(self, resource_cls: Type[T]):
self._resource_cls = resource_cls
self._attr_name = ""
def __set_name__(self, owner: type, name: str) -> None:
self._attr_name = f"_{name}_instance"
def __get__(self, obj: Any, objtype: type = None) -> T:
if obj is None:
return self # type: ignore
if not hasattr(obj, self._attr_name):
setattr(
obj,
self._attr_name,
self._resource_cls(obj._transport),
)
return getattr(obj, self._attr_name)
class PaymentClient:
users = ResourceDescriptor(UsersResource)
invoices = ResourceDescriptor(InvoicesResource)
webhooks = ResourceDescriptor(WebhooksResource)
def __init__(self, api_key: str):
self._transport = SyncTransport(
base_url="https://api.pay.com/v1",
api_key=api_key,
)
Resources are instantiated only when first accessed. The client class stays clean — adding a new resource is one line.
Automatic pagination with iterators
Production pagination must handle cursor-based, offset-based, and link-header patterns:
from typing import Iterator, Any, Callable
from dataclasses import dataclass
@dataclass
class Page:
items: list[dict]
next_cursor: str | None
class PaginatedIterator:
def __init__(
self,
fetch_page: Callable[[str | None], Page],
):
self._fetch_page = fetch_page
self._current_page: Page | None = None
self._index = 0
self._cursor: str | None = None
self._exhausted = False
def __iter__(self) -> Iterator[dict]:
return self
def __next__(self) -> dict:
if self._current_page is None or self._index >= len(
self._current_page.items
):
if self._exhausted:
raise StopIteration
self._current_page = self._fetch_page(self._cursor)
self._index = 0
self._cursor = self._current_page.next_cursor
if self._cursor is None:
self._exhausted = True
if not self._current_page.items:
raise StopIteration
item = self._current_page.items[self._index]
self._index += 1
return item
Usage: for user in client.users.list() — pages load transparently as iteration proceeds.
Type safety with Pydantic models
Return typed models instead of raw dicts. This gives users autocomplete, validation, and serialization:
from pydantic import BaseModel, Field
from datetime import datetime
class User(BaseModel):
id: int
email: str
name: str
created_at: datetime = Field(alias="createdAt")
class Config:
populate_by_name = True
Use model_validate() in resource methods. Invalid API responses surface immediately as validation errors rather than causing KeyError downstream.
Middleware and hooks
Extensible SDKs provide hooks for logging, metrics, and custom behavior:
from typing import Protocol
class RequestHook(Protocol):
def before_request(
self, method: str, url: str, kwargs: dict
) -> None: ...
def after_response(
self, method: str, url: str, response: Any
) -> None: ...
class LoggingHook:
def before_request(
self, method: str, url: str, kwargs: dict
) -> None:
print(f"→ {method} {url}")
def after_response(
self, method: str, url: str, response: Any
) -> None:
print(f"← {response.status_code} {url}")
Register hooks at client construction: client = MyClient(key, hooks=[LoggingHook()]).
Idempotency key management
For SDKs wrapping financial or state-changing APIs, idempotency keys prevent duplicate operations:
import uuid
def create_payment(
self, amount: int, currency: str, idempotency_key: str | None = None
) -> Payment:
key = idempotency_key or str(uuid.uuid4())
resp = self._transport.request(
"POST",
"/payments",
json={"amount": amount, "currency": currency},
headers={"Idempotency-Key": key},
)
return Payment.model_validate(resp)
Auto-generating a key per call means retries are safe by default. Callers can pass their own key for explicit control.
Real-world SDK teardowns
Stripe: Uses module-level configuration (stripe.api_key = "...") which is convenient but not thread-safe. Their resource classes use a custom metaclass for REST method generation. The auto_paging_iter() method is the gold standard for pagination.
boto3: Uses a service model (JSON resource definitions) to generate methods at runtime. This means the SDK updates when AWS adds endpoints without code changes. The downside: IDE autocomplete is limited because methods are dynamic.
Twilio: Clean client.messages.create(to=..., from_=..., body=...) pattern. Uses from_ instead of from (a Python keyword) — a small but important detail in Pythonic naming.
Testing SDK consumers
Design your SDK so users can mock it easily. Provide either:
- A protocol/interface class that users can stub
- A built-in mock client for testing (like
motofor boto3) - A transport injection point where
httpx.MockTransportslots in
Option 3 is cheapest to maintain. Option 2 provides the best user experience.
| Approach | Maintenance cost | User experience | Flexibility |
|---|---|---|---|
| Protocol stubs | Low | Medium | High |
| Built-in mock client | High | Excellent | Medium |
| Transport injection | Low | Good | High |
The one thing to remember: The best Python SDKs feel like they were designed by someone who has to use them daily — typed responses, lazy pagination, sensible defaults, and the flexibility to override everything when you need to.
See Also
- Python Aiohttp Client Understand Aiohttp Client through a practical analogy so your Python decisions become faster and clearer.
- Python Api Client Design Why building your own API client in Python is like creating a TV remote that only has the buttons you actually need.
- 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.