Backend / Web frameworks / FastAPI / 14_external_api_client.md

Writing a client for an external API

Updated 7 interview angles 4 min read source
On this page6
  1. One client, for the whole process lifetime
  2. The client owns error mapping
  3. Retries: only what is safe, with jitter
  4. Failing fast when it is genuinely down
  5. Testing it
  6. Interview angle

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

python
@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

python
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

python
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 POST can 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:

python
class Billing(Protocol):
    async def invoice(self, id: str) -> Invoice: ...

See Mocking external APIs.

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?” - respx against recorded real payloads for the client itself, and a fake implementing the same Protocol for service-level tests.