Writing a client for an external API
One class per provider, constructed once, injected. Business code calls typed methods and never sees HTTP. Auth, logging and caching inside it are Outbound auth, logging and caching; this is the shape and the failure handling.
One client, for the whole process lifetime
@asynccontextmanager
async def lifespan(app: FastAPI):
async with httpx.AsyncClient(
base_url="https://api.example.com",
timeout=httpx.Timeout(5.0, connect=2.0),
limits=httpx.Limits(max_connections=100),
) as http:
app.state.billing = BillingClient(http)
yield
app = FastAPI(lifespan=lifespan)Creating a client per request is the most common performance bug in this area. It throws away the connection pool and the TLS session, so every call pays a fresh handshake — tens of milliseconds, on every request, invisible in application profiling.
The timeout is not optional and is not one number:
| Timeout | Guards against |
|---|---|
connect |
the host being unreachable |
read |
a slow or hanging response |
write |
a stalled upload |
pool |
waiting for a free connection |
A missing timeout means one unresponsive dependency holds a worker forever, which is how a slow third party becomes your outage.
The client owns error mapping
class BillingClient:
def __init__(self, http: httpx.AsyncClient):
self._http = http
async def invoice(self, id: str) -> Invoice:
r = await self._http.get(f"/invoices/{id}")
if r.status_code == 404:
raise InvoiceNotFound(id)
if r.status_code == 429:
raise RateLimited(retry_after(r))
r.raise_for_status()
return Invoice.model_validate(r.json())Callers get InvoiceNotFound, not an httpx.HTTPStatusError they have to
inspect. That is the boundary: HTTP vocabulary stops here, and swapping the
provider does not ripple through the codebase.
Validating into a Pydantic model at the boundary is the other half. An API that
starts returning null for a required field fails here, with a clear message,
rather than three layers away as an AttributeError.
Retries: only what is safe, with jitter
TRANSIENT = (httpx.TransportError, RateLimited)
@retry(
retry=retry_if_exception_type(TRANSIENT),
wait=wait_exponential_jitter(initial=0.5, max=8),
stop=stop_after_attempt(4),
reraise=True,
)
async def invoice(self, id: str) -> Invoice:
...| Condition | Retry? |
|---|---|
| Connection error, timeout | yes |
| 429 | yes, honour Retry-After |
| 500, 502, 503, 504 | yes if the call is idempotent |
| 400, 401, 403, 404, 422 | no — retrying cannot help |
Jitter is the part people omit. Plain exponential backoff makes every client that failed together retry together, so the recovering service is hit by a synchronised wave and fails again.
The other constraint: cap the total time, not just the attempt count. Four attempts at a 5-second timeout is a 20-second worst case, and whoever called you has their own budget.
Gotcha: a retried
POSTcan charge a card twice. Retry non-idempotent calls only with an idempotency key the provider honours — see Idempotency.
Failing fast when it is genuinely down
Retries make a brief blip invisible and a sustained outage worse — every request now takes four attempts before failing. A circuit breaker is the fix: after N consecutive failures, reject immediately for a cooldown, then let one request through to test.
That converts a 20-second timeout into an instant, cheap error, which is what keeps your own latency budget intact. See Timeouts, retries and backoff.
Testing it
Define the client as a Protocol and service tests get a fake; the client’s own
tests hit respx with recorded payloads:
class Billing(Protocol):
async def invoice(self, id: str) -> Invoice: ...Interview angle 7
- “How do you structure an outbound API client?” - one class per provider owning base URL, auth, timeouts, retries and error mapping, constructed once at startup and injected. Business code calls typed methods and never sees HTTP.
- “Why reuse a single
AsyncClient?” - connection pooling and TLS session reuse. Creating a client per request discards both and adds a handshake to every call, which is a measurable and very common performance bug. - “What do you retry, and how?” - transport errors, timeouts, 429 honouring
Retry-After, and 5xx when the call is idempotent. Never 4xx. Exponential backoff with jitter, a bounded attempt count, and a cap on total elapsed time, not just attempts. - “Why does jitter matter?” - without it, every client that failed at the same moment retries at the same moment, so the recovering service takes a synchronised wave and falls over again.
- “Where does the retry policy live?” - in the client, once, not scattered across service methods. Otherwise you cannot reason about total latency or retry amplification.
- “When do retries make things worse?” - during a real outage, when every request costs four attempts before failing. That is what a circuit breaker fixes: fail fast for a cooldown, then probe with one request.
- “How do you test it?” -
respxagainst recorded real payloads for the client itself, and a fake implementing the same Protocol for service-level tests.