Calling External APIs from FastAPI

Most production FastAPI applications do not exist in isolation โ€” they integrate with payment processors, OAuth providers, cloud storage APIs, notification services, and data providers. Calling external APIs from FastAPI route handlers requires careful design: async HTTP clients to avoid blocking, structured error handling for API failures, response validation with Pydantic models, and caching to avoid redundant calls that slow responses or exceed rate limits. In this lesson you will build a reusable external API integration pattern that is clean, testable, and resilient.

The External API Client Pattern

# app/services/github_service.py
import httpx
from pydantic import BaseModel
from typing import Optional

# โ”€โ”€ Pydantic model for the external API response โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class GitHubUser(BaseModel):
    login:      str
    name:       Optional[str] = None
    email:      Optional[str] = None
    public_repos: int
    followers:  int
    created_at: str

    model_config = {"extra": "ignore"}   # ignore unknown fields from the API

# โ”€โ”€ Service class โ€” wraps all interactions with GitHub API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
class GitHubService:
    BASE_URL = "https://api.github.com"

    def __init__(self, client: httpx.AsyncClient):
        self.client = client   # injected โ€” makes it testable

    async def get_user(self, username: str) -> GitHubUser:
        """Fetch a GitHub user's public profile."""
        try:
            response = await self.client.get(f"{self.BASE_URL}/users/{username}")
        except httpx.TimeoutException:
            raise ExternalAPIError("GitHub API timed out")
        except httpx.RequestError as e:
            raise ExternalAPIError(f"Cannot reach GitHub API: {e}")

        if response.status_code == 404:
            raise NotFoundError("GitHub user", username)
        if response.status_code == 403:
            raise ExternalAPIError("GitHub API rate limit exceeded")
        if response.status_code != 200:
            raise ExternalAPIError(f"GitHub API error: {response.status_code}")

        return GitHubUser.model_validate(response.json())

    async def get_repos(self, username: str, per_page: int = 10) -> list[dict]:
        response = await self.client.get(
            f"{self.BASE_URL}/users/{username}/repos",
            params={"per_page": per_page, "sort": "updated"},
        )
        response.raise_for_status()
        return [
            {"name": r["name"], "stars": r["stargazers_count"], "url": r["html_url"]}
            for r in response.json()
        ]
Note: Wrapping external API calls in a service class (rather than making httpx calls directly in route handlers) has three benefits: it is easier to test (inject a mock client), it centralises error handling (one place to handle rate limits, retries, and error mapping), and it makes the route handler clean (business logic, not HTTP plumbing). This is the repository pattern applied to external APIs.
Tip: Use model_config = {"extra": "ignore"} in Pydantic models that represent external API responses. External APIs change over time โ€” new fields are added. Without extra="ignore", adding a new field to the external API’s response will cause a Pydantic ValidationError in your application the moment they deploy their change. With extra="ignore", unknown fields are silently discarded and your application continues to work.
Warning: Never pass raw external API responses directly to your clients. Always validate and transform them with Pydantic models. External APIs can return unexpected data types, null values where you expect strings, or fields in different formats depending on the endpoint version. Validating with Pydantic catches these inconsistencies early โ€” at the API boundary โ€” rather than letting malformed data propagate through your application and cause cryptic errors later.

Integrating the Service into FastAPI

# app/main.py โ€” register shared HTTP client on lifespan
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends
import httpx

from app.services.github_service import GitHubService
from app.exceptions import ExternalAPIError, NotFoundError

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Create shared client once โ€” connection pooling
    app.state.http_client = httpx.AsyncClient(
        timeout = httpx.Timeout(connect=5.0, read=30.0),
        headers = {"Accept": "application/json",
                   "User-Agent": "MyFastAPIApp/1.0"},
    )
    yield
    await app.state.http_client.aclose()

app = FastAPI(lifespan=lifespan)

# Dependency โ€” injects the service into route handlers
def get_github_service(request: Request) -> GitHubService:
    return GitHubService(client=request.app.state.http_client)

# Route handler โ€” clean, no httpx code visible
@app.get("/github/{username}")
async def get_github_profile(
    username: str,
    github: GitHubService = Depends(get_github_service),
):
    user  = await github.get_user(username)
    repos = await github.get_repos(username, per_page=5)
    return {"profile": user.model_dump(), "top_repos": repos}

Caching External API Responses

import time
from functools import lru_cache

# Simple in-memory cache with TTL
class TTLCache:
    def __init__(self, ttl_seconds: int = 300):
        self._cache: dict = {}
        self._ttl   = ttl_seconds

    def get(self, key: str):
        if key in self._cache:
            value, expires_at = self._cache[key]
            if time.time() < expires_at:
                return value
            del self._cache[key]   # expired
        return None

    def set(self, key: str, value):
        self._cache[key] = (value, time.time() + self._ttl)

    def invalidate(self, key: str):
        self._cache.pop(key, None)

# Cache GitHub user profiles for 5 minutes
_cache = TTLCache(ttl_seconds=300)

async def get_github_user_cached(username: str, service: GitHubService):
    cache_key = f"github:user:{username}"
    cached = _cache.get(cache_key)
    if cached:
        return cached

    user = await service.get_user(username)
    _cache.set(cache_key, user)
    return user

# For production: use Redis instead of in-memory
# pip install redis
# await redis_client.setex(f"github:user:{username}", 300, user.model_dump_json())

Handling Rate Limits

import asyncio

async def get_with_retry(
    client: httpx.AsyncClient,
    url: str,
    max_retries: int = 3,
    backoff_seconds: float = 1.0,
) -> httpx.Response:
    """Retry on rate limit (429) or server errors (5xx) with exponential backoff."""
    for attempt in range(max_retries):
        try:
            response = await client.get(url)

            if response.status_code == 429:
                # Respect Retry-After header if present
                retry_after = int(response.headers.get("Retry-After", backoff_seconds))
                await asyncio.sleep(retry_after)
                continue

            if response.status_code >= 500 and attempt < max_retries - 1:
                await asyncio.sleep(backoff_seconds * (2 ** attempt))  # exponential backoff
                continue

            return response

        except httpx.TimeoutException:
            if attempt < max_retries - 1:
                await asyncio.sleep(backoff_seconds)
            else:
                raise

    raise ExternalAPIError(f"Max retries exceeded for {url}")

Common Mistakes

Mistake 1 โ€” Passing external API errors directly to clients

โŒ Wrong โ€” exposing external API error details:

except httpx.HTTPStatusError as e:
    raise HTTPException(e.response.status_code, detail=e.response.text)
# Returns GitHub's raw error HTML/JSON to your API client

โœ… Correct โ€” map to your own error format:

except httpx.HTTPStatusError as e:
    logger.error(f"GitHub API error: {e.response.text}")
    raise ExternalAPIError("GitHub service unavailable")   # โœ“ clean message

Mistake 2 โ€” Using Pydantic without extra="ignore" for external APIs

โŒ Wrong โ€” breaks when external API adds a new field:

class GitHubUser(BaseModel):
    login: str   # no extra="ignore" โ†’ ValidationError when new fields arrive

โœ… Correct:

class GitHubUser(BaseModel):
    model_config = {"extra": "ignore"}   # โœ“ future-proof
    login: str

Mistake 3 โ€” No caching on frequently-called external APIs

โŒ Wrong โ€” calling GitHub API on every request for the same user:

@app.get("/user/{username}")
async def show_user(username: str):
    return await github.get_user(username)   # GitHub called on every page load!

โœ… Correct โ€” cache responses with appropriate TTL:

return await get_github_user_cached(username, github)   # โœ“ cached for 5 min

Quick Reference

Concern Approach
HTTP client Shared AsyncClient created at startup
API wrapper Service class with injected client
Response validation Pydantic model with extra="ignore"
Error mapping httpx errors โ†’ domain exceptions โ†’ HTTP responses
Caching In-memory TTLCache or Redis for repeated calls
Rate limits Retry with exponential backoff + Retry-After header

🧠 Test Yourself

You add a new Pydantic model field to parse an external API's response. Two weeks later your application breaks with a ValidationError on a field called sponsored that you never defined. What is the most likely cause and fix?