Testing and Monitoring APIs

Build reliable APIs with comprehensive testing and production monitoring

Untested code is broken code. APIs that power production systems need comprehensive testing at every level, unit tests for business logic, integration tests for database interactions, contract tests for client compatibility, and load tests for performance under stress.

Testing finds bugs before users do. But once in production, monitoring catches issues in real-time. Metrics track performance trends, logs capture errors, traces reveal bottlenecks, and alerts notify you when things go wrong.

In this lesson, you'll implement the testing pyramid with pytest, mock external dependencies, write integration tests with test databases, run load tests with Locust, create health check endpoints, collect metrics with Prometheus, implement distributed tracing with OpenTelemetry, structure logs with correlation IDs, and set up alerting with SLOs.

Lesson Sections

Testing Pyramid for APIs

The testing pyramid guides how much effort to invest at each testing level. The foundation is unit tests (fast, many), the middle is integration tests (moderate speed, fewer), and the top is end-to-end tests (slow, fewest). This balance ensures comprehensive coverage without sacrificing speed.

E2E Tests~10% (Slow, Expensive)
Full system, UI to database
Integration Tests~30% (Moderate)
API + database, external services
Unit Tests~60% (Fast, Cheap)
Individual functions, business logic
Test Level Breakdown
Unit Tests (60% of tests)

Test individual functions in isolation. Mock all external dependencies (database, API calls, file system). Focus on business logic correctness.

# What to test:
✓ Input validation (valid/invalid data)
✓ Business logic (calculations, transformations)
✓ Edge cases (empty lists, null values, boundaries)
✓ Error handling (exceptions raised correctly)

# Characteristics:
- Fast (milliseconds per test)
- No external dependencies
- Run frequently during development
- Catch bugs early

# Example: Testing a price calculator
def calculate_total(subtotal, tax_rate):
    return subtotal * (1 + tax_rate)

def test_calculate_total():
    assert calculate_total(100, 0.1) == 110.0
    assert calculate_total(0, 0.1) == 0.0
    assert calculate_total(100, 0) == 100.0
Integration Tests (30% of tests)

Test components working together. Use real database (test instance), verify API endpoints with actual HTTP requests, check data persistence.

# What to test:
✓ API endpoints (request → response)
✓ Database interactions (CRUD operations)
✓ Authentication/authorization flows
✓ Data serialization (JSON, validation)

# Characteristics:
- Slower (100ms - 1s per test)
- Uses test database
- Runs before deployment
- Catches integration bugs

# Example: Testing POST endpoint
def test_create_user_integration(client, test_db):
    response = client.post("/users", json={
        "username": "alice",
        "email": "alice@example.com"
    })
    assert response.status_code == 201
    # Verify database
    user = test_db.query(User).filter_by(username="alice").first()
    assert user is not None
    assert user.email == "alice@example.com"
End-to-End Tests (10% of tests)

Test complete user workflows. Simulate real user interactions from UI to backend, verify entire system works together.

# What to test:
✓ Critical user journeys (signup → login → use feature)
✓ Multi-step workflows (checkout process)
✓ Cross-service interactions (microservices)
✓ Production-like environment

# Characteristics:
- Slowest (seconds per test)
- Most fragile (many failure points)
- Run before major releases
- Catch system-level bugs

# Example: User registration flow (Selenium 4+)
from selenium.webdriver.common.by import By

def test_user_registration_e2e(browser):
    # 1. Visit homepage
    browser.get("https://app.example.com")

    # 2. Click signup
    browser.find_element(By.ID, "signup").click()

    # 3. Fill form
    browser.find_element(By.NAME, "email").send_keys("alice@example.com")
    browser.find_element(By.NAME, "password").send_keys("secret123")

    # 4. Submit
    browser.find_element(By.ID, "submit").click()

    # 5. Verify redirected to dashboard
    assert "dashboard" in browser.current_url
The Testing Pyramid Principle

Write more tests at the base (unit), fewer at the top (E2E). Unit tests are fast and catch most bugs early. Integration tests verify components work together. E2E tests confirm critical user flows. Anti-pattern: "Ice cream cone" (mostly E2E tests) is slow and fragile. Stick to 60/30/10 ratio for balanced coverage and speed.

Unit Testing with pytest

pytest is Python's most popular testing framework. It provides simple syntax, powerful fixtures, detailed assertions, and excellent FastAPI integration through TestClient.

Setting Up pytest with FastAPI

Install pytest and dependencies:

pip install pytest pytest-asyncio httpx

Project structure for tests:

project/
├── app/
│   ├── main.py
│   ├── models.py
│   └── routes.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py      # Shared fixtures
│   ├── test_routes.py   # API endpoint tests
│   └── test_models.py   # Model/business logic tests
└── pytest.ini           # pytest configuration
Testing FastAPI Endpoints with TestClient

TestClient simulates HTTP requests without running a server.

# app/main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

class User(BaseModel):
    username: str
    email: str

users_db = {}

@app.post("/users", status_code=201)
async def create_user(user: User):
    if user.username in users_db:
        raise HTTPException(status_code=400, detail="User already exists")
    users_db[user.username] = user
    return user

@app.get("/users/{username}")
async def get_user(username: str):
    if username not in users_db:
        raise HTTPException(status_code=404, detail="User not found")
    return users_db[username]

Test file:

# tests/test_routes.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)


def test_create_user_success():
    """Test successful user creation"""
    response = client.post("/users", json={
        "username": "alice",
        "email": "alice@example.com"
    })

    assert response.status_code == 201
    assert response.json() == {
        "username": "alice",
        "email": "alice@example.com"
    }


def test_create_user_duplicate():
    """Test creating duplicate user returns 400"""
    # Create first user
    client.post("/users", json={
        "username": "bob",
        "email": "bob@example.com"
    })

    # Try to create same user again
    response = client.post("/users", json={
        "username": "bob",
        "email": "bob2@example.com"
    })

    assert response.status_code == 400
    assert "already exists" in response.json()["detail"]


def test_get_user_success():
    """Test retrieving existing user"""
    # Create user
    client.post("/users", json={
        "username": "charlie",
        "email": "charlie@example.com"
    })

    # Get user
    response = client.get("/users/charlie")

    assert response.status_code == 200
    assert response.json()["username"] == "charlie"


def test_get_user_not_found():
    """Test retrieving non-existent user returns 404"""
    response = client.get("/users/nonexistent")

    assert response.status_code == 404
    assert "not found" in response.json()["detail"]

Run tests:

pytest tests/ -v
Result: Test Output
tests/test_routes.py::test_create_user_success PASSED         [ 25%]
tests/test_routes.py::test_create_user_duplicate PASSED       [ 50%]
tests/test_routes.py::test_get_user_success PASSED            [ 75%]
tests/test_routes.py::test_get_user_not_found PASSED          [100%]

====================== 4 passed in 0.15s ======================
Using Fixtures for Test Setup

Fixtures provide reusable test setup and teardown logic.

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app, users_db


@pytest.fixture
def client():
    """Create test client"""
    return TestClient(app)


@pytest.fixture(autouse=True)
def clear_database():
    """Clear database before each test"""
    users_db.clear()
    yield  # Test runs here
    users_db.clear()  # Cleanup after test


@pytest.fixture
def sample_user():
    """Create sample user data"""
    return {
        "username": "testuser",
        "email": "test@example.com"
    }

Use fixtures in tests:

# tests/test_routes.py
def test_create_user(client, sample_user):
    """Test using fixtures"""
    response = client.post("/users", json=sample_user)

    assert response.status_code == 201
    assert response.json() == sample_user


def test_isolation(client):
    """Test that database is cleared between tests"""
    # Database should be empty due to clear_database fixture
    response = client.get("/users/testuser")
    assert response.status_code == 404
Mocking External Dependencies

Mock external services to test in isolation.

# app/routes.py
import httpx

async def fetch_github_user(username: str):
    """Fetch user from GitHub API"""
    async with httpx.AsyncClient() as client:
        response = await client.get(f"https://api.github.com/users/{username}")
        response.raise_for_status()
        return response.json()

Mock the external API call:

# tests/test_routes.py
import pytest
import httpx
from unittest.mock import AsyncMock, patch


@pytest.mark.asyncio
async def test_fetch_github_user_success():
    """Test GitHub API call with mock"""
    # Mock response
    mock_response = AsyncMock()
    mock_response.json.return_value = {
        "login": "octocat",
        "name": "The Octocat"
    }
    mock_response.raise_for_status = AsyncMock()

    # Mock httpx.AsyncClient
    with patch("httpx.AsyncClient.get", return_value=mock_response):
        result = await fetch_github_user("octocat")

        assert result["login"] == "octocat"
        assert result["name"] == "The Octocat"


@pytest.mark.asyncio
async def test_fetch_github_user_not_found():
    """Test GitHub API 404 error"""
    mock_response = AsyncMock()
    mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
        "404 Not Found",
        request=None,
        response=None
    )

    with patch("httpx.AsyncClient.get", return_value=mock_response):
        with pytest.raises(httpx.HTTPStatusError):
            await fetch_github_user("nonexistent")
When to Mock
  • External APIs: GitHub, Stripe, payment gateways (unreliable, rate limits)
  • File system: Disk I/O, file uploads (slow, test isolation)
  • Time/dates: datetime.now() (non-deterministic)
  • Email/SMS: Sending notifications (avoid spam, cost)
  • Don't mock: Your own code, database in unit tests (use test DB in integration tests)

Integration and Contract Testing

Integration tests verify that components work together correctly. This includes API endpoints with databases, authentication flows, and interactions with external services. Contract testing ensures API providers and consumers agree on API structure without tightly coupling their test suites.

Integration Testing with Test Database

Use a separate test database to verify real database interactions.

# tests/conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.database import Base
from app.main import app
from fastapi.testclient import TestClient

# Test database URL (use in-memory SQLite for speed)
TEST_DATABASE_URL = "sqlite:///./test.db"

@pytest.fixture(scope="function")
def test_db():
    """Create test database for each test"""
    engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})

    # Create all tables
    Base.metadata.create_all(bind=engine)

    # Create session
    TestingSessionLocal = sessionmaker(bind=engine)
    db = TestingSessionLocal()

    try:
        yield db
    finally:
        db.close()
        # Drop all tables after test
        Base.metadata.drop_all(bind=engine)

@pytest.fixture(scope="function")
def client(test_db):
    """Create test client with test database"""
    def override_get_db():
        try:
            yield test_db
        finally:
            test_db.close()

    app.dependency_overrides[get_db] = override_get_db

    with TestClient(app) as test_client:
        yield test_client

    app.dependency_overrides.clear()

Integration test example:

# tests/test_integration.py
def test_create_and_retrieve_user(client, test_db):
    """Test full user lifecycle: create, retrieve, update"""
    # 1. Create user
    create_response = client.post("/users", json={
        "username": "alice",
        "email": "alice@example.com"
    })
    assert create_response.status_code == 201
    user_id = create_response.json()["id"]

    # 2. Verify user exists in database
    from app.models import User
    db_user = test_db.query(User).filter(User.id == user_id).first()
    assert db_user is not None
    assert db_user.username == "alice"

    # 3. Retrieve via API
    get_response = client.get(f"/users/{user_id}")
    assert get_response.status_code == 200
    assert get_response.json()["username"] == "alice"

    # 4. Update user
    update_response = client.put(f"/users/{user_id}", json={
        "email": "alice.updated@example.com"
    })
    assert update_response.status_code == 200

    # 5. Verify update persisted
    test_db.refresh(db_user)
    assert db_user.email == "alice.updated@example.com"


def test_user_authentication_flow(client, test_db):
    """Test signup → login → access protected endpoint"""
    # 1. Signup
    signup_response = client.post("/auth/signup", json={
        "username": "bob",
        "password": "secret123",
        "email": "bob@example.com"
    })
    assert signup_response.status_code == 201

    # 2. Login
    login_response = client.post("/auth/login", json={
        "username": "bob",
        "password": "secret123"
    })
    assert login_response.status_code == 200
    token = login_response.json()["access_token"]

    # 3. Access protected endpoint
    headers = {"Authorization": f"Bearer {token}"}
    protected_response = client.get("/users/me", headers=headers)
    assert protected_response.status_code == 200
    assert protected_response.json()["username"] == "bob"
API Mocking with Prism

Prism creates mock API servers from OpenAPI specifications. This enables parallel development: frontend teams can develop against mock APIs while backend teams implement real endpoints. Mocks return realistic responses based on your OpenAPI schema.

# Install Prism
npm install -g @stoplight/prism-cli

# Start mock server from OpenAPI spec
prism mock openapi.yaml

# Output:
# [1:23:45 PM] › [CLI] …  awaiting  Starting Prism…
# [1:23:45 PM] › [CLI] ℹ  info      GET     http://127.0.0.1:4010/users
# [1:23:45 PM] › [CLI] ℹ  info      POST    http://127.0.0.1:4010/users
# [1:23:45 PM] › [CLI] ▶  start     Prism is listening on http://127.0.0.1:4010

OpenAPI specification example:

# openapi.yaml
openapi: 3.0.0
info:
  title: User API
  version: 1.0.0

paths:
  /users:
    get:
      summary: List users
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/User'
              example:
                - id: 1
                  username: "alice"
                  email: "alice@example.com"
                - id: 2
                  username: "bob"
                  email: "bob@example.com"

    post:
      summary: Create user
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserCreate'
      responses:
        '201':
          description: User created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'

components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: integer
        username:
          type: string
        email:
          type: string
          format: email

    UserCreate:
      type: object
      required:
        - username
        - email
      properties:
        username:
          type: string
        email:
          type: string
          format: email

Test against Prism mock server:

# tests/test_with_prism.py
import httpx
import pytest

PRISM_BASE_URL = "http://127.0.0.1:4010"

@pytest.mark.asyncio
async def test_get_users_from_mock():
    """Test frontend code against Prism mock"""
    async with httpx.AsyncClient(base_url=PRISM_BASE_URL) as client:
        response = await client.get("/users")

        assert response.status_code == 200
        users = response.json()
        assert isinstance(users, list)
        assert len(users) > 0
        assert "username" in users[0]
        assert "email" in users[0]


@pytest.mark.asyncio
async def test_create_user_mock():
    """Test POST endpoint against mock"""
    async with httpx.AsyncClient(base_url=PRISM_BASE_URL) as client:
        response = await client.post("/users", json={
            "username": "charlie",
            "email": "charlie@example.com"
        })

        assert response.status_code == 201
        user = response.json()
        assert user["username"] == "charlie"

# Benefits of Prism:
# ✓ Frontend development doesn't wait for backend
# ✓ Consistent with OpenAPI spec (catches schema drift)
# ✓ Dynamic responses based on request data
# ✓ Validates requests against schema automatically
Contract Testing with Pact

Pact implements consumer-driven contract testing. Consumers (frontend, mobile apps) define their expectations of the API. Providers (backend) verify they meet those expectations. This prevents breaking changes without tight coupling between test suites.

Consumer Side (Frontend)

Consumer defines expectations and generates a contract (pact file) describing the API interactions it needs.

  • Define expected requests
  • Define expected responses
  • Generate pact file
Provider Side (Backend)

Provider verifies it can fulfill the contract by replaying consumer expectations against the real API.

  • Load consumer pact file
  • Replay requests to real API
  • Verify responses match expectations

Consumer test (Frontend defines contract):

# Install pact-python
pip install pact-python

# tests/test_consumer_pact.py
import pytest
from pact import Consumer, Provider, Like, EachLike

pact = Consumer("UserWebApp").has_pact_with(
    Provider("UserAPI"),
    pact_dir="./pacts"
)

@pytest.fixture(scope="module")
def pact_server():
    """Start Pact mock server"""
    pact.start_service()
    yield pact
    pact.stop_service()

import requests

def test_get_user_contract(pact_server):
    """Consumer expects: GET /users/1 returns user object"""
    expected = {
        "id": 1,
        "username": Like("alice"),
        "email": Like("alice@example.com")
    }

    (pact
     .given("User 1 exists")
     .upon_receiving("a request for user 1")
     .with_request("GET", "/users/1")
     .will_respond_with(200, body=expected))

    with pact:
        # Make request to pact mock server
        response = requests.get(f"{pact.uri}/users/1")
        assert response.status_code == 200
        assert response.json()["username"] == "alice"

    # Pact file generated: pacts/UserWebApp-UserAPI.json

def test_create_user_contract(pact_server):
    """Consumer expects: POST /users creates user"""
    request_body = {
        "username": "bob",
        "email": "bob@example.com"
    }

    expected_response = {
        "id": Like(2),
        "username": "bob",
        "email": "bob@example.com"
    }

    (pact
     .given("User does not exist")
     .upon_receiving("a request to create user")
     .with_request("POST", "/users", body=request_body)
     .will_respond_with(201, body=expected_response))

    with pact:
        response = requests.post(f"{pact.uri}/users", json=request_body)
        assert response.status_code == 201
        assert "id" in response.json()

Provider verification (Backend verifies contract):

# tests/test_provider_pact.py
import pytest
from pact import Verifier
from app.main import app
from app.database import Base, engine

@pytest.fixture(scope="module")
def provider_app():
    """Setup provider app with test data"""
    # Create tables
    Base.metadata.create_all(bind=engine)

    # Add test data for "User 1 exists" state
    from app.models import User
    db = SessionLocal()
    user = User(id=1, username="alice", email="alice@example.com")
    db.add(user)
    db.commit()
    db.close()

    yield app

    # Cleanup
    Base.metadata.drop_all(bind=engine)

def test_verify_pact(provider_app):
    """Verify provider meets consumer contract"""
    verifier = Verifier(
        provider="UserAPI",
        provider_base_url="http://localhost:8000"
    )

    # Verify against pact file from consumer
    success, logs = verifier.verify_pacts(
        "./pacts/UserWebApp-UserAPI.json",
        provider_states_setup_url="http://localhost:8000/_pact/provider_states"
    )

    assert success == 0  # 0 = success, 1 = failure

# If verification fails:
# - Provider changed API without updating consumer
# - Breaking change detected
# - Fix provider OR update consumer contract
Mock Servers for Parallel Development

Mock servers enable frontend and backend teams to work independently.

ToolUse CaseBest For
PrismOpenAPI-driven mock serverTeams with OpenAPI specs, schema validation
json-serverQuick REST API from JSON fileRapid prototyping, simple CRUD APIs
MockServerProgrammable expectationsComplex scenarios, stateful mocking
WireMockHTTP mock with recordingJava/JVM teams, stubbing external APIs
PactContract testingPreventing breaking changes, microservices

json-server example (fastest setup):

# Install json-server
npm install -g json-server

# Create db.json
{
  "users": [
    { "id": 1, "username": "alice", "email": "alice@example.com" },
    { "id": 2, "username": "bob", "email": "bob@example.com" }
  ],
  "posts": [
    { "id": 1, "userId": 1, "title": "Hello World", "body": "First post" }
  ]
}

# Start server
json-server --watch db.json --port 3000

# Auto-generated endpoints:
# GET    /users
# GET    /users/1
# POST   /users
# PUT    /users/1
# PATCH  /users/1
# DELETE /users/1
# GET    /users?username=alice  (filtering)
# GET    /posts?_expand=user     (join)
Contract Testing Benefits
  • Parallel development: Frontend and backend teams work independently
  • Catch breaking changes early: Contract verification fails before deployment
  • No tight coupling: Teams don't share test code, only contracts
  • Living documentation: Pact files document actual API usage
  • Microservices: Services verify contracts with each other
  • Consumer-driven: API evolves based on actual consumer needs

Load and Performance Testing

APIs that pass unit and integration tests can still fail under load. Load testing simulates real-world traffic to find performance bottlenecks, memory leaks, and concurrency issues before users do.

Load Testing with Locust

Install Locust:

pip install locust
# locustfile.py
import random
from locust import HttpUser, task, between


class APIUser(HttpUser):
    """Simulates realistic user behavior against the API."""
    wait_time = between(1, 3)  # Wait 1-3 seconds between tasks

    def on_start(self):
        """Called when a simulated user starts. Login and get token."""
        response = self.client.post("/auth/login", json={
            "username": "testuser",
            "password": "testpass"
        })
        self.token = response.json().get("access_token", "")
        self.headers = {"Authorization": f"Bearer {self.token}"}

    @task(5)  # Weight: 5x more likely than other tasks
    def list_users(self):
        """Most common operation: list users"""
        self.client.get("/users?limit=20", headers=self.headers)

    @task(3)
    def get_user(self):
        """Get individual user"""
        user_id = random.randint(1, 100)
        self.client.get(f"/users/{user_id}", headers=self.headers)

    @task(1)
    def create_user(self):
        """Least common: create user"""
        self.client.post("/users", json={
            "username": f"user_{random.randint(1000, 9999)}",
            "email": f"user{random.randint(1000, 9999)}@example.com"
        }, headers=self.headers)

Run load test:

# Start Locust web UI
locust -f locustfile.py --host=http://localhost:8000

# Open http://localhost:8089 in browser
# Configure: 100 users, spawn rate 10/s

# Or run headless (for CI/CD)
locust -f locustfile.py \
    --host=http://localhost:8000 \
    --users 100 \
    --spawn-rate 10 \
    --run-time 60s \
    --headless \
    --csv=results
Key Metrics to Watch
  • Response time (p50, p95, p99): p95 under 500ms is a good target for most APIs
  • Throughput (RPS): Requests per second the API can sustain
  • Error rate: Should stay below 1% under normal load
  • Concurrent users: Maximum users before degradation
  • Resource usage: CPU, memory, DB connections during test
Performance Testing Strategies
Test TypePurposeConfiguration
Load TestVerify performance under expected trafficExpected users for 10-30 minutes
Stress TestFind breaking pointGradually increase until failure
Spike TestHandle sudden traffic surgesSudden jump from 10 to 1000 users
Soak TestFind memory leaks over timeNormal load for 2-24 hours

Health Checks and Observability

Health checks tell you if your API is running and ready to serve traffic. Kubernetes, load balancers, and monitoring systems rely on health endpoints to make routing and alerting decisions.

Liveness and Readiness Probes
# app/health.py
from fastapi import APIRouter, Response
import asyncio

router = APIRouter(tags=["health"])

# Database and cache clients (injected or imported)
from app.database import SessionLocal
from app.cache import redis_client


@router.get("/health/live")
async def liveness():
    """
    Liveness probe: Is the process alive?

    Returns 200 if the application is running.
    Kubernetes restarts the pod if this fails.
    """
    return {"status": "alive"}


@router.get("/health/ready")
async def readiness():
    """
    Readiness probe: Can the app serve traffic?

    Checks all dependencies (database, cache, etc.).
    Load balancer stops routing traffic if this fails.
    """
    checks = {}

    # Check database
    try:
        db = SessionLocal()
        db.execute("SELECT 1")
        db.close()
        checks["database"] = "ok"
    except Exception as e:
        checks["database"] = f"error: {str(e)}"

    # Check Redis cache
    try:
        redis_client.ping()
        checks["cache"] = "ok"
    except Exception as e:
        checks["cache"] = f"error: {str(e)}"

    # Overall status
    all_ok = all(v == "ok" for v in checks.values())
    status_code = 200 if all_ok else 503

    return Response(
        content={
            "status": "ready" if all_ok else "not_ready",
            "checks": checks
        },
        status_code=status_code
    )
Kubernetes Health Probe Configuration
# kubernetes/deployment.yaml
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: api
          image: myapi:latest
          ports:
            - containerPort: 8000

          # Liveness: restart if process hangs
          livenessProbe:
            httpGet:
              path: /health/live
              port: 8000
            initialDelaySeconds: 10
            periodSeconds: 15
            failureThreshold: 3

          # Readiness: stop traffic if dependencies down
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 8000
            initialDelaySeconds: 5
            periodSeconds: 10
            failureThreshold: 2

          # Startup: give time for initialization
          startupProbe:
            httpGet:
              path: /health/live
              port: 8000
            initialDelaySeconds: 5
            periodSeconds: 5
            failureThreshold: 30  # 30 × 5s = 150s max startup
Liveness vs Readiness
  • Liveness: "Is the process alive?", If it fails, Kubernetes restarts the pod. Keep it simple (no dependency checks).
  • Readiness: "Can it serve traffic?", If it fails, traffic is routed away. Check all dependencies (DB, cache, external services).

Monitoring and Metrics

Metrics quantify your API's behavior over time. Response times, error rates, and throughput are the foundation of observability. Prometheus collects metrics, and Grafana visualizes them.

Prometheus Metrics with FastAPI

Install the Prometheus client:

pip install prometheus-client
# app/metrics.py
import time
from fastapi import FastAPI, Request, Response
from prometheus_client import (
    Counter, Histogram, Gauge,
    generate_latest, CONTENT_TYPE_LATEST
)

# Define metrics
REQUEST_COUNT = Counter(
    "http_requests_total",
    "Total HTTP requests",
    ["method", "endpoint", "status_code"]
)

REQUEST_DURATION = Histogram(
    "http_request_duration_seconds",
    "HTTP request duration in seconds",
    ["method", "endpoint"],
    buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0]
)

ACTIVE_REQUESTS = Gauge(
    "http_active_requests",
    "Number of active HTTP requests"
)

ERROR_COUNT = Counter(
    "http_errors_total",
    "Total HTTP errors",
    ["method", "endpoint", "error_type"]
)


def setup_metrics(app: FastAPI):
    """Add metrics middleware to FastAPI app."""

    @app.middleware("http")
    async def metrics_middleware(request: Request, call_next):
        # Track active requests
        ACTIVE_REQUESTS.inc()
        start_time = time.time()

        try:
            response = await call_next(request)

            # Record metrics
            duration = time.time() - start_time
            endpoint = request.url.path
            method = request.method

            REQUEST_COUNT.labels(
                method=method,
                endpoint=endpoint,
                status_code=response.status_code
            ).inc()

            REQUEST_DURATION.labels(
                method=method,
                endpoint=endpoint
            ).observe(duration)

            if response.status_code >= 400:
                ERROR_COUNT.labels(
                    method=method,
                    endpoint=endpoint,
                    error_type=f"http_{response.status_code}"
                ).inc()

            return response
        finally:
            ACTIVE_REQUESTS.dec()

    @app.get("/metrics")
    async def metrics():
        """Prometheus scrape endpoint."""
        return Response(
            content=generate_latest(),
            media_type=CONTENT_TYPE_LATEST
        )
The Four Golden Signals

Google's Site Reliability Engineering book defines four golden signals that every service should monitor:

Latency

How long requests take. Track p50, p95, and p99 percentiles. Distinguish between successful and failed request latency.

Errors

Rate of failed requests. Track HTTP 5xx errors, timeouts, and application-level errors. Error rate = errors / total requests.

Traffic

How much demand the service is handling. Measured in requests per second (RPS), broken down by endpoint and method.

Saturation

How "full" the service is. Track CPU usage, memory, DB connection pool utilization, and queue depth.

Distributed Tracing and Logging

Metrics tell you something is wrong; traces and logs tell you why. Distributed tracing follows a request across services, while structured logging captures context at each step. Together they make debugging production issues fast and systematic.

OpenTelemetry with FastAPI

Install OpenTelemetry packages:

pip install opentelemetry-api opentelemetry-sdk \
    opentelemetry-instrumentation-fastapi \
    opentelemetry-exporter-otlp
# app/tracing.py
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor

def setup_tracing(app):
    """Configure OpenTelemetry tracing for FastAPI."""

    # Create tracer provider
    provider = TracerProvider()

    # Export traces to collector (Jaeger, Zipkin, etc.)
    exporter = OTLPSpanExporter(endpoint="http://localhost:4317")
    provider.add_span_processor(BatchSpanProcessor(exporter))

    # Set global tracer
    trace.set_tracer_provider(provider)

    # Auto-instrument FastAPI
    FastAPIInstrumentor.instrument_app(app)


# Using traces in your code
tracer = trace.get_tracer(__name__)

async def get_user_with_posts(user_id: str):
    """Example: trace a multi-step operation."""

    with tracer.start_as_current_span("get_user_with_posts") as span:
        # Add attributes for debugging
        span.set_attribute("user.id", user_id)

        # Child span: database query
        with tracer.start_as_current_span("db.get_user"):
            user = await database.get_user(user_id)
            span.set_attribute("user.found", user is not None)

        # Child span: fetch posts
        with tracer.start_as_current_span("db.get_posts"):
            posts = await database.get_posts_by_user(user_id)
            span.set_attribute("posts.count", len(posts))

        return {"user": user, "posts": posts}

# Trace output (visible in Jaeger UI):
# get_user_with_posts [45ms]
#   ├── db.get_user [12ms]
#   └── db.get_posts [28ms]
Structured Logging with Correlation IDs
# app/logging_config.py
import logging
import json
import uuid
from fastapi import FastAPI, Request


class JSONFormatter(logging.Formatter):
    """Output logs as structured JSON for log aggregation."""

    def format(self, record):
        log_data = {
            "timestamp": self.formatTime(record),
            "level": record.levelname,
            "message": record.getMessage(),
            "module": record.module,
            "function": record.funcName,
        }

        # Add correlation ID if available
        if hasattr(record, "correlation_id"):
            log_data["correlation_id"] = record.correlation_id

        # Add extra fields
        if hasattr(record, "user_id"):
            log_data["user_id"] = record.user_id

        return json.dumps(log_data)


def setup_logging(app: FastAPI):
    """Configure structured logging with correlation IDs."""

    # Setup JSON formatter
    handler = logging.StreamHandler()
    handler.setFormatter(JSONFormatter())
    logging.root.handlers = [handler]
    logging.root.setLevel(logging.INFO)

    @app.middleware("http")
    async def correlation_id_middleware(request: Request, call_next):
        # Get or generate correlation ID
        correlation_id = request.headers.get(
            "X-Correlation-ID",
            str(uuid.uuid4())
        )

        # Store in request state
        request.state.correlation_id = correlation_id

        # Process request
        response = await call_next(request)

        # Add to response headers
        response.headers["X-Correlation-ID"] = correlation_id
        return response


# Usage in endpoints
logger = logging.getLogger(__name__)

async def create_user(request: Request, user_data: dict):
    correlation_id = request.state.correlation_id

    logger.info(
        "Creating user",
        extra={
            "correlation_id": correlation_id,
            "user_id": user_data["username"]
        }
    )

    # ... create user ...

    logger.info(
        "User created successfully",
        extra={
            "correlation_id": correlation_id,
            "user_id": user_data["username"]
        }
    )

# Log output (structured JSON):
# {"timestamp": "2024-01-15 10:30:00", "level": "INFO",
#  "message": "Creating user", "correlation_id": "abc-123",
#  "user_id": "alice"}
#
# Trace the full request across services
# by searching for correlation_id: "abc-123"
The Three Pillars of Observability
  • Metrics (Prometheus): Aggregated numbers, "What is happening?" (request rate, error rate, latency)
  • Traces (OpenTelemetry/Jaeger): Request flow across services, "Where is it slow?" (span timing, service dependencies)
  • Logs (structured JSON): Detailed events, "Why did it fail?" (error messages, stack traces, context)

Production Best Practices

Production reliability requires Service Level Objectives (SLOs) that define acceptable performance targets, and alerting rules that notify you when those targets are at risk.

Defining SLOs and SLIs
# Service Level Indicators (SLIs) - What you measure
# Service Level Objectives (SLOs) - Your targets
# Service Level Agreements (SLAs) - Your promises to customers

# Example SLOs for a REST API:

# Availability SLO
# SLI: Percentage of successful requests (non-5xx)
# SLO: 99.9% availability (43.8 minutes downtime/month)
availability = successful_requests / total_requests
# Target: availability >= 0.999

# Latency SLO
# SLI: p95 response time
# SLO: 95% of requests complete within 500ms
latency_p95 = percentile(request_durations, 95)
# Target: latency_p95 <= 0.5

# Error Budget
# If SLO is 99.9%, error budget is 0.1%
# = 43.8 minutes of downtime per month
# Use error budget for deployments and experiments
# When budget is exhausted: freeze deployments, fix reliability
Alerting Rules
# prometheus/alert_rules.yml
groups:
  - name: api_alerts
    rules:
      # High error rate
      - alert: HighErrorRate
        expr: |
          rate(http_errors_total[5m])
          / rate(http_requests_total[5m]) > 0.01
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Error rate above 1% for 5 minutes"

      # Slow responses
      - alert: HighLatency
        expr: |
          histogram_quantile(0.95,
            rate(http_request_duration_seconds_bucket[5m])
          ) > 0.5
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "p95 latency above 500ms for 10 minutes"

      # Service down
      - alert: ServiceDown
        expr: up == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "API service is down"
Production Testing and Monitoring Checklist
Complete Checklist

Testing

  • Unit tests for business logic (60%)
  • Integration tests with test database (30%)
  • Contract tests for API consumers
  • Load tests before major releases
  • Tests run in CI/CD pipeline

Monitoring

  • Health check endpoints (liveness + readiness)
  • Prometheus metrics (4 golden signals)
  • Distributed tracing (OpenTelemetry)
  • Structured logging with correlation IDs
  • SLOs defined with alerting rules

Key Takeaways

  • Follow the testing pyramid, 60% unit tests, 30% integration tests, 10% E2E tests for balanced coverage and speed
  • Use pytest + TestClient for FastAPI, fixtures for setup/teardown, mocks for external dependencies, dependency overrides for test databases
  • Contract testing prevents breaking changes, Pact lets consumers define expectations that providers verify, catching incompatibilities before deployment
  • Load test before production, Locust simulates realistic traffic to find performance bottlenecks, memory leaks, and concurrency issues
  • Health checks are non-negotiable, liveness probes detect crashes, readiness probes check dependencies, both essential for Kubernetes and load balancers
  • Monitor the four golden signals, latency, errors, traffic, and saturation give a complete picture of service health
  • Traces and logs explain failures, OpenTelemetry traces request flow across services, structured logs with correlation IDs connect related events
  • Define SLOs and alert on error budget, set measurable targets (99.9% availability, p95 under 500ms) and alert when the budget is at risk