API Client Design — Core Concepts
Why build a custom API client
Every team that talks to an external API eventually faces the same problem: raw requests.get() calls scattered across the codebase, each with slightly different error handling, timeout values, and authentication logic. An API client centralizes these decisions into one place.
Popular Python SDKs like stripe, boto3, and twilio are all API clients. When a vendor doesn’t provide one — or their official client is too bloated — you build your own.
Anatomy of a Python API client
A well-structured client has four layers:
- Transport layer — handles HTTP calls, retries, and timeouts (typically
httpxorrequests) - Authentication layer — attaches API keys, OAuth tokens, or signatures to every request
- Resource layer — maps API endpoints to Python methods (
client.users.list()) - Response layer — parses JSON, raises typed exceptions, and returns domain objects
Each layer can be swapped independently. Need async? Replace the transport. New auth scheme? Update one module.
The session pattern
The most common pattern wraps a requests.Session or httpx.Client:
- Set
base_urlonce - Configure default headers and auth
- Reuse TCP connections across calls (connection pooling)
- Apply consistent timeout and retry policies
This avoids the “configure everything per call” anti-pattern and gives you connection reuse for free.
Resource grouping
APIs with many endpoints benefit from grouping. Stripe’s client uses stripe.customers.create() and stripe.invoices.list(). You can achieve this with descriptor classes or simple attribute namespacing, where each resource group holds a reference to the shared transport.
Error handling strategy
Raw HTTP status codes are meaningless to callers. A good client translates them:
- 4xx → typed exceptions —
NotFoundError,ValidationError,RateLimitError - 5xx → retriable exceptions — signal that a retry might succeed
- Network errors →
ConnectionError— distinct from server errors - Include the response body — callers need the API’s error message, not just a status code
Configuration and defaults
Sensible defaults matter more than flexibility. A client should work with just an API key:
- Default timeout: 30 seconds (not infinite)
- Default retries: 2-3 for idempotent methods
- Default base URL: the production endpoint
- Override everything via constructor parameters
Common misconception
Many developers think an API client is just “a wrapper around requests.” But the real value isn’t in making HTTP calls — it’s in encoding domain knowledge. A good client knows which endpoints are idempotent, which errors are retriable, what pagination scheme the API uses, and how to refresh expired tokens. That knowledge, embedded in code, prevents bugs that raw HTTP calls invite.
When not to build one
If you’re calling an API once in a script, a raw httpx.get() is fine. Build a client when:
- Multiple parts of your codebase call the same API
- You need consistent retry/auth/error behavior
- The API has pagination, rate limits, or token refresh
- You want to mock the API cleanly in tests
The one thing to remember: An API client isn’t about hiding HTTP — it’s about encoding domain-specific decisions (auth, retries, error mapping, pagination) so every caller gets them right automatically.
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.