FastAPI Agent APIs

9 min read Module 8 of 10 Topic 22 of 30

What you'll learn

  • Build a REST API that exposes agent functionality with proper request/response schemas
  • Implement async background task execution for long-running agent runs
  • Add API key authentication and rate limiting to protect your agent endpoints
  • Create a WebSocket endpoint for real-time agent streaming
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

The Agent API Architecture

flowchart LR
    subgraph EP["Endpoints"]
        R1["POST /agent/run\nsync, short tasks"]
        R2["POST /agent/start\nasync, returns task_id"]
        R3["GET /agent/status\npoll task status"]
        R4["GET /agent/stream\nSSE token streaming"]
    end
    subgraph Infra["Infrastructure"]
        A["Auth + Rate\nLimiting"]
        Q[("Task Queue\nRedis")]
        W["Background\nWorkers"]
    end
    EP --> A --> Q --> W
    style A fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style Q fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style W fill:#fff7ed,stroke:#f59e0b,color:#0F172A

Complete FastAPI Agent Service

# src/api/main.py
from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks, Header
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from typing import Optional
import uuid, json, asyncio
from datetime import datetime
from contextlib import asynccontextmanager

# Request/Response schemas: FastAPI reads these to validate requests and generate OpenAPI docs
class AgentRunRequest(BaseModel):
    query: str = Field(min_length=1, max_length=10000)  # reject empty or oversized queries automatically
    thread_id: Optional[str] = None  # if provided, the agent continues an existing conversation
    model: str = Field(default="gpt-5.6-sol", pattern="^(gpt-5.6-sol|gpt-5.6-luna|claude-sonnet-5)$")  # allowlist via regex
    max_iterations: int = Field(default=20, ge=1, le=50)  # ge/le enforce min/max without custom validators
    stream: bool = Field(default=False)

class AgentRunResponse(BaseModel):
    run_id: str
    status: str  # pending | running | completed | failed
    result: Optional[str] = None
    error: Optional[str] = None
    usage: Optional[dict] = None
    created_at: str
    completed_at: Optional[str] = None

class TaskStatusResponse(BaseModel):
    run_id: str
    status: str
    progress: Optional[str] = None
    result: Optional[str] = None
    error: Optional[str] = None

# In-memory task store (use Redis in production)
# keyed by run_id so any worker can look up any task status
task_store: dict[str, dict] = {}

# lifespan context manager runs setup/teardown once at server start/stop: not per request
@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup
    await memory_store.initialize()
    yield
    # Shutdown: clean up resources

app = FastAPI(
    title="AI Agent API",
    version="1.0.0",
    lifespan=lifespan,  # pass lifespan so FastAPI runs startup/shutdown hooks
)

# CORS middleware allows browser clients from your frontend domain to call this API
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://your-frontend.com"],  # restrict to your own domain in production
    allow_methods=["*"],
    allow_headers=["*"],
)

Authentication & Rate Limiting

# src/api/auth.py
from fastapi import HTTPException, Depends, Header
from functools import lru_cache
import hashlib

# API key store (use a database in production)
# storing hashes instead of raw keys: if this dict leaks, keys can't be used directly
API_KEYS = {
    hashlib.sha256("sk-prod-xxxx".encode()).hexdigest(): {
        "tier": "premium",
        "rate_limit_per_min": 60,    # premium tier gets 60 requests per minute
        "cost_limit_usd_per_day": 50.0,
    },
    hashlib.sha256("sk-free-yyyy".encode()).hexdigest(): {
        "tier": "free",
        "rate_limit_per_min": 10,    # free tier is throttled more aggressively
        "cost_limit_usd_per_day": 1.0,
    },
}

# Header(...) makes X-API-Key a required header: FastAPI returns 422 if it's missing
def get_api_key(x_api_key: str = Header(..., alias="X-API-Key")) -> dict:
    """Validate API key and return key metadata."""
    # hash the incoming key before lookup so raw keys never touch our comparisons
    key_hash = hashlib.sha256(x_api_key.encode()).hexdigest()
    
    if key_hash not in API_KEYS:
        raise HTTPException(status_code=401, detail="Invalid API key")
    
    return API_KEYS[key_hash]

# Rate limiting with in-memory sliding window
from collections import defaultdict, deque
import time

# defaultdict(deque) auto-creates an empty deque for each new key_id on first access
request_windows: dict[str, deque] = defaultdict(deque)

# Depends(get_api_key) runs get_api_key first and injects the result: dependency injection
def check_rate_limit(api_key_meta: dict = Depends(get_api_key)) -> dict:
    key_id = api_key_meta.get("tier", "unknown")
    limit = api_key_meta["rate_limit_per_min"]
    window = request_windows[key_id]
    
    now = time.time()
    # Remove requests older than 60 seconds: sliding window only counts the last minute
    while window and window[0] < now - 60:
        window.popleft()
    
    if len(window) >= limit:
        raise HTTPException(
            status_code=429,  # 429 Too Many Requests is the standard rate-limit status code
            detail=f"Rate limit exceeded ({limit} req/min). Try again shortly.",
            headers={"Retry-After": "10"},  # tell the client to wait 10 seconds before retrying
        )
    
    window.append(now)  # record this request's timestamp in the sliding window
    return api_key_meta

Async Background Task Endpoints

# src/api/endpoints.py

# response_model=AgentRunResponse tells FastAPI to validate and serialize the return value
@app.post("/agent/start", response_model=AgentRunResponse)
async def start_agent_run(
    request: AgentRunRequest,
    background_tasks: BackgroundTasks,  # FastAPI injects this; used to schedule async work
    api_meta: dict = Depends(check_rate_limit),  # runs auth + rate limit before the handler
):
    """Start an agent run asynchronously. Returns immediately with a run_id to poll."""
    run_id = str(uuid.uuid4())  # universally unique ID so clients can poll the right task
    thread_id = request.thread_id or run_id  # use provided thread_id for conversation continuity
    
    # record "pending" before adding the task so polling can start immediately
    task_store[run_id] = {
        "status": "pending",
        "progress": "Initializing agent...",
        "created_at": datetime.utcnow().isoformat(),
    }
    
    async def run_agent_task():
        task_store[run_id]["status"] = "running"
        try:
            config = {"configurable": {"thread_id": thread_id}}  # thread_id routes to the right checkpointer memory
            result = await agent.ainvoke(
                {"messages": [HumanMessage(content=request.query)], "iterations": 0, "final_answer": None},
                config=config,
            )
            # filter to only AI messages: ignore ToolMessages and HumanMessages in the output
            ai_msgs = [m for m in result["messages"] if isinstance(m, AIMessage)]
            
            task_store[run_id].update({
                "status": "completed",
                "result": ai_msgs[-1].content if ai_msgs else "",
                "completed_at": datetime.utcnow().isoformat(),
            })
        except Exception as e:
            # catch all exceptions so a crashing agent doesn't leave the task stuck in "running"
            task_store[run_id].update({
                "status": "failed",
                "error": str(e),
                "completed_at": datetime.utcnow().isoformat(),
            })
    
    # background_tasks.add_task() returns immediately: the agent runs in a separate coroutine
    background_tasks.add_task(run_agent_task)
    
    # return "pending" right away so the client gets a run_id to poll
    return AgentRunResponse(
        run_id=run_id,
        status="pending",
        created_at=task_store[run_id]["created_at"],
    )

@app.get("/agent/status/{run_id}", response_model=TaskStatusResponse)
async def get_run_status(run_id: str, api_meta: dict = Depends(check_rate_limit)):
    """Poll the status of a background agent run."""
    if run_id not in task_store:
        raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
    
    task = task_store[run_id]
    return TaskStatusResponse(
        run_id=run_id,
        status=task["status"],
        progress=task.get("progress"),  # optional, may be None if progress tracking isn't set
        result=task.get("result"),
        error=task.get("error"),
    )

@app.post("/agent/run", response_model=AgentRunResponse)
async def run_agent_sync(
    request: AgentRunRequest,
    api_meta: dict = Depends(check_rate_limit),
):
    """Synchronous agent run (for quick tasks only, max 30s)."""
    if request.max_iterations > 10:
        # enforce a lower iteration cap on the sync endpoint to stay under load-balancer timeouts
        raise HTTPException(
            status_code=400,
            detail="Sync endpoint limited to max_iterations=10. Use /agent/start for longer runs."
        )
    
    try:
        # asyncio.wait_for() enforces a hard timeout: raises TimeoutError if agent exceeds 30 seconds
        result = await asyncio.wait_for(
            agent.ainvoke(
                {"messages": [HumanMessage(content=request.query)], "iterations": 0, "final_answer": None}
            ),
            timeout=30.0,
        )
        
        ai_msgs = [m for m in result["messages"] if isinstance(m, AIMessage)]
        return AgentRunResponse(
            run_id=str(uuid.uuid4()),
            status="completed",
            result=ai_msgs[-1].content if ai_msgs else "",
            created_at=datetime.utcnow().isoformat(),
            completed_at=datetime.utcnow().isoformat(),
        )
    
    except asyncio.TimeoutError:
        # return 504 Gateway Timeout: the standard HTTP code for a timed-out upstream service
        raise HTTPException(status_code=504, detail="Agent run timed out (30s). Use /agent/start.")

Health Check & Metrics

@app.get("/health")
async def health_check():
    """Liveness check for load balancer."""
    # liveness endpoint: if this returns 200, the container is alive (but not necessarily ready)
    return {"status": "ok", "timestamp": datetime.utcnow().isoformat()}

@app.get("/health/ready")
async def readiness_check():
    """Readiness check, returns 503 if dependencies are down."""
    # readiness endpoint: Kubernetes removes the pod from the load balancer if this fails
    checks = {}
    
    try:
        # actively probe the OpenAI API: a 401 or timeout here means the pod can't serve requests
        await openai_client.models.list()
        checks["openai"] = "ok"
    except Exception as e:
        checks["openai"] = f"error: {e}"
    
    all_ok = all(v == "ok" for v in checks.values())
    # return 503 if any dependency is unhealthy so orchestrators stop routing traffic here
    return {"status": "ready" if all_ok else "degraded", "checks": checks}

@app.get("/metrics")
async def get_metrics(api_meta: dict = Depends(check_rate_limit)):
    """Basic operational metrics."""
    # count tasks by status: useful for dashboards and alerting on failed run rates
    statuses = [t["status"] for t in task_store.values()]
    return {
        "total_runs": len(task_store),
        "pending": statuses.count("pending"),
        "running": statuses.count("running"),
        "completed": statuses.count("completed"),
        "failed": statuses.count("failed"),  # a rising failed count is the first sign of trouble
    }

Running the Service

# Development: --reload watches for file changes and restarts automatically
uvicorn src.api.main:app --reload --port 8000

# Production: --workers 4 spawns 4 processes; uvloop is a faster event loop for Linux
uvicorn src.api.main:app --host 0.0.0.0 --port 8000 --workers 4 --loop uvloop

With multiple workers, ensure your agent state store (LangGraph checkpointer) uses a shared backend (Postgres, Redis) rather than in-memory, so all worker processes access the same state.

Knowledge Check

3 questions to test your understanding

1 Why should long-running agent tasks (10+ seconds) be handled as background tasks rather than synchronous HTTP responses?

2 What is the purpose of Pydantic models for FastAPI request/response schemas in an agent API?

3 An agent API is called 1000 times per minute from untrusted clients. What should you implement to prevent abuse?

Discussion

Questions and notes from learners on this topic

Loading discussion…

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan