M21: API Design & Deployment
Your agent works in a notebook. Now make it work everywhere — as a proper HTTP service with streaming responses, async job queues for long-running work, rate limiting, and a reproducible Docker deployment that any developer can clone and run.
Learning Objectives
- Explain why a stateless HTTP wrapper is the right architecture for an agent service
- Build a FastAPI application with CORS, API key auth, health check, and error middleware
- Implement a streaming endpoint using SSE and wire a JS EventSource client to it
- Add an async job queue pattern for agents that run longer than 30 seconds
- Apply Pydantic validation and a token-bucket rate limiter to protect the service
- Build a multi-stage Dockerfile and docker-compose.yml with an Ollama sidecar
- Deploy the same Docker image to Hetzner/DigitalOcean, Fly.io, and Railway
- Expose Prometheus metrics from M20 on a /metrics endpoint in two lines
Wrapping an Agent as an HTTP Service
Imagine you have built a brilliant research analyst who lives in a locked room. She can answer any question, use any tool, and produce extraordinary results — but the only way to work with her is to walk into that room, hand her a sheet of paper, and wait until she slides the answer back under the door. No other team can access her simultaneously, no system can call her programmatically, and her knowledge of the conversation ends the moment you leave.
The pain is obvious the moment a second team needs the same analyst: they cannot get in, or they must wait while you are already inside. Any automated pipeline that needs her output is blocked on a human gatekeeper. Every call requires a full context handshake from scratch because she remembers nothing between visits. The analyst herself is great; the access model is broken.
The intercom is your HTTP API. It lets any caller — another service, a UI, a cron job — communicate with the agent without entering the room. Each call is independent (stateless), so two callers can reach her at the same time without conflict. The session ID on each message is the label on the envelope: the caller owns the memory, not the analyst. Wrapping your agent as a service does not change what it does; it changes who can reach it and how reliably.
Three Design Principles for Agent APIs
A stateless endpoint means the server holds zero session state between requests. Every HTTP call must include everything the agent needs: the query, the conversation history (or a session ID to look it up in a store), and any configuration. This makes horizontal scaling trivial — any replica can handle any request — and eliminates an entire class of sticky-session bugs. The caller, not the server, is responsible for threading context across calls.
SSEServer-Sent Events: a browser-native protocol where the server pushes a stream of text events over a single long-lived HTTP connection. Each event has a type and data field. The browser's EventSource API reconnects automatically on disconnect. (Server-Sent Events) is a browser-native streaming protocol: the server sends a continuous stream of data: ...\n\n chunks over one long-lived HTTP connection. Unlike WebSockets, SSE is unidirectional (server to client), works through every HTTP proxy, and requires no special setup beyond a Content-Type: text/event-stream header. For agent APIs, SSE is ideal for streaming intermediate steps — thoughts, tool calls, partial results — as they happen.
An idempotency keyA unique client-generated token (UUID) sent in the request header. If the client retries a failed request with the same key, the server recognizes it and returns the cached result instead of running the agent twice. Prevents duplicate billing or duplicate record creation on network failures. is a unique token the client generates and sends with each request (typically as an X-Idempotency-Key header). If a network timeout causes the client to retry, the server detects the duplicate key and returns the already-completed result rather than running the agent again. Without idempotency keys, a client retry during a network blip can trigger the agent twice — double-billing, duplicate writes, or conflicting results.
A FastAPI service wrapping a Mistral-7B-Q4 agent on a Hetzner CX21 (2 vCPU, 4 GB RAM, €3.79/mo) can sustain ~12 concurrent streaming requests with sub-500ms first-token latency. The same agent running as a library function, accessed by multiple callers sharing a Python process, will serialize all calls through the GIL and deliver ~1 concurrent request at 8× the latency. The API wrapper is not overhead — it is what makes concurrency possible.
FastAPI Boilerplate
A production-grade FastAPI service needs five things before you add a single line of agent logic: CORS headers so browsers can call it, Pydantic request/response models so the API is self-documenting, an error middleware to convert exceptions into clean JSON, a health check endpoint for load balancers, and API key authentication to prevent abuse.
Request and Response Models
WHY: FastAPI auto-validates, auto-documents, and auto-serializes these — zero boilerplate JSON parsing
GOTCHA: session_id is optional here; if None, each request starts a fresh context (stateless by design)
# src/models.py
# WHAT: Typed request and response contracts for the agent API
# WHY: Pydantic catches malformed inputs before they reach the agent,
# and FastAPI generates OpenAPI docs from these classes automatically
# GOTCHA: Use Optional[str] not str | None for Python < 3.10 compatibility
from pydantic import BaseModel, Field, field_validator
from typing import Optional, List, Any
import uuid
class AgentRequest(BaseModel):
"""Incoming agent invocation request."""
query: str = Field(
..., # required
min_length=1,
max_length=4096,
description="The question or task to send to the agent",
)
session_id: Optional[str] = Field(
default=None,
description="Resume a prior conversation. None = fresh context.",
)
max_iterations: int = Field(
default=8,
ge=1,
le=20,
description="Max ReAct loop iterations before forced stop",
)
stream: bool = Field(
default=False,
description="If true, return SSE stream instead of JSON blob",
)
@field_validator("query")
@classmethod
def strip_query(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("query must not be empty after stripping whitespace")
return v
class ToolCallRecord(BaseModel):
"""One tool invocation captured from the agent loop."""
tool_name: str
input_summary: str # truncated for log safety
output_summary: str
duration_ms: int
class AgentResponse(BaseModel):
"""Successful synchronous agent response."""
result: str
session_id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Use this in the next request to continue the conversation",
)
iterations: int = Field(description="ReAct loop iterations used")
tool_calls: List[ToolCallRecord] = Field(default_factory=list)
latency_ms: int = Field(description="Total wall-clock time")
model: str = Field(description="Ollama model used")
class ErrorResponse(BaseModel):
"""Standard error envelope returned on 4xx/5xx."""
error: str
detail: Optional[str] = None
request_id: Optional[str] = None
// src/models.js (Zod schemas for Express)
// WHAT: Same request/response contracts in Zod for Node.js
// WHY: Zod gives runtime validation + TypeScript types from one schema
// GOTCHA: z.string().uuid() is strict — pass null for fresh sessions,
// not an empty string
import { z } from 'zod';
import { v4 as uuidv4 } from 'uuid';
export const AgentRequestSchema = z.object({
query: z
.string()
.min(1, 'query must not be empty')
.max(4096)
.transform((s) => s.trim()),
session_id: z.string().uuid().nullable().default(null),
max_iterations: z.number().int().min(1).max(20).default(8),
stream: z.boolean().default(false),
});
export const ToolCallRecordSchema = z.object({
tool_name: z.string(),
input_summary: z.string(),
output_summary: z.string(),
duration_ms: z.number().int(),
});
export const AgentResponseSchema = z.object({
result: z.string(),
session_id: z.string().uuid().default(() => uuidv4()),
iterations: z.number().int(),
tool_calls: z.array(ToolCallRecordSchema).default([]),
latency_ms: z.number().int(),
model: z.string(),
});
export const ErrorResponseSchema = z.object({
error: z.string(),
detail: z.string().nullable().default(null),
request_id: z.string().nullable().default(null),
});
/** @typedef {z.infer<typeof AgentRequestSchema>} AgentRequest */
/** @typedef {z.infer<typeof AgentResponseSchema>} AgentResponse */
Full Application Skeleton
WHY: Load balancers probe /health every 10s — must return 200 or the instance is removed from rotation
GOTCHA: API_KEY must be set in the environment; the app refuses to start if missing
# src/main.py
# WHAT: FastAPI app with CORS, auth, error middleware, and health check
# WHY: Each concern is a separate middleware layer so they can be
# tested and replaced independently
# GOTCHA: Place the error handler FIRST (outermost) so it catches
# exceptions from ALL inner middleware, not just route handlers
import os, time, uuid, logging
from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from .models import AgentRequest, AgentResponse, ErrorResponse
from .agent import run_agent # your ReAct loop from Capstone C3
logger = logging.getLogger(__name__)
API_KEY = os.environ.get("API_KEY")
if not API_KEY:
raise RuntimeError("API_KEY environment variable must be set")
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
app = FastAPI(
title="Entity Resolution Agent API",
version="1.0.0",
docs_url="/docs", # Swagger UI
redoc_url="/redoc",
)
# ── CORS ──────────────────────────────────────────────────────────────
# WHAT: Allow browser frontends to call this API directly
# GOTCHA: In production, replace allow_origins=["*"] with your
# actual frontend domain to prevent cross-origin abuse
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # tighten in production
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
)
# ── API KEY AUTH ───────────────────────────────────────────────────────
async def verify_api_key(request: Request) -> str:
"""Dependency: extract and validate the Bearer token."""
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing Bearer token")
token = auth.removeprefix("Bearer ").strip()
if token != API_KEY:
raise HTTPException(status_code=401, detail="Invalid API key")
return token
# ── GLOBAL ERROR HANDLER ───────────────────────────────────────────────
@app.exception_handler(Exception)
async def global_error_handler(request: Request, exc: Exception):
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
logger.exception("Unhandled error on %s (req=%s)", request.url, request_id)
return JSONResponse(
status_code=500,
content=ErrorResponse(
error="internal_server_error",
detail=str(exc) if os.environ.get("DEBUG") else None,
request_id=request_id,
).model_dump(),
)
# ── HEALTH CHECK ───────────────────────────────────────────────────────
@app.get("/health", tags=["ops"])
async def health():
"""Load balancer probe — returns 200 if the service is alive."""
import httpx
try:
r = httpx.get(f"{OLLAMA_HOST}/api/tags", timeout=3)
ollama_ok = r.status_code == 200
except Exception:
ollama_ok = False
return {
"status": "ok" if ollama_ok else "degraded",
"ollama": ollama_ok,
"version": app.version,
}
# ── AGENT ROUTE ────────────────────────────────────────────────────────
@app.post(
"/agent/run",
response_model=AgentResponse,
responses={422: {"model": ErrorResponse}, 429: {"model": ErrorResponse}},
dependencies=[Depends(verify_api_key)],
tags=["agent"],
)
async def run_agent_endpoint(body: AgentRequest, request: Request):
"""Synchronous agent invocation. For long queries use /agent/jobs."""
start = time.perf_counter()
result_data = await run_agent(
query=body.query,
session_id=body.session_id,
max_iterations=body.max_iterations,
ollama_host=OLLAMA_HOST,
)
latency_ms = int((time.perf_counter() - start) * 1000)
return AgentResponse(latency_ms=latency_ms, **result_data)
// src/app.js (Express)
// WHAT: Express app with CORS, API key auth, error handler, health check
// WHY: Mirrors the Python FastAPI structure so both deploy identically
// GOTCHA: express-async-errors must be required BEFORE route definitions
// to patch async route handlers with automatic error forwarding
import 'express-async-errors'; // patches async route handlers
import express from 'express';
import cors from 'cors';
import { v4 as uuidv4 } from 'uuid';
import { AgentRequestSchema } from './models.js';
import { runAgent } from './agent.js';
import fetch from 'node-fetch';
const API_KEY = process.env.API_KEY;
if (!API_KEY) throw new Error('API_KEY environment variable must be set');
const OLLAMA_HOST = process.env.OLLAMA_HOST ?? 'http://localhost:11434';
export const app = express();
// ── CORS ──────────────────────────────────────────────────────────────
app.use(cors({ origin: '*', methods: ['GET', 'POST', 'OPTIONS'] }));
app.use(express.json({ limit: '1mb' }));
// ── API KEY AUTH MIDDLEWARE ───────────────────────────────────────────
app.use((req, res, next) => {
if (req.path === '/health') return next(); // exempt health probe
const auth = req.headers['authorization'] ?? '';
if (!auth.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing Bearer token' });
}
if (auth.slice(7).trim() !== API_KEY) {
return res.status(401).json({ error: 'Invalid API key' });
}
next();
});
// ── HEALTH CHECK ──────────────────────────────────────────────────────
app.get('/health', async (req, res) => {
let ollamaOk = false;
try {
const r = await fetch(`${OLLAMA_HOST}/api/tags`, { signal: AbortSignal.timeout(3000) });
ollamaOk = r.ok;
} catch { /* degraded */ }
res.json({ status: ollamaOk ? 'ok' : 'degraded', ollama: ollamaOk, version: '1.0.0' });
});
// ── AGENT ROUTE ────────────────────────────────────────────────────────
app.post('/agent/run', async (req, res) => {
const parsed = AgentRequestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(422).json({ error: 'validation_error', detail: parsed.error.format() });
}
const { query, session_id, max_iterations } = parsed.data;
const start = Date.now();
const resultData = await runAgent({ query, sessionId: session_id, maxIterations: max_iterations, ollamaHost: OLLAMA_HOST });
return res.json({ ...resultData, latency_ms: Date.now() - start });
});
// ── GLOBAL ERROR HANDLER ──────────────────────────────────────────────
app.use((err, req, res, _next) => {
const requestId = req.headers['x-request-id'] ?? uuidv4();
console.error(`[${requestId}] Unhandled error:`, err);
res.status(500).json({
error: 'internal_server_error',
detail: process.env.DEBUG ? err.message : undefined,
request_id: requestId,
});
});
You now have a properly layered API skeleton. CORS is outermost so preflight requests are handled before auth runs. Auth is a FastAPI Dependency (or Express middleware) so it composes onto any route without repetition. The global error handler logs with a request ID so you can correlate API logs with Prometheus traces. The health endpoint checks the Ollama sidecar so load balancers know when the service is truly ready.
Streaming with SSE
When you stream, the server sends one SSE event per step of the ReAct loop: a thought event when the model reasons, a tool_call event when it invokes a tool, and a final result event when the loop terminates. The client renders each event immediately, so the user sees progress instead of a spinning indicator.
Every SSEServer-Sent Events: HTTP/1.1 streaming protocol. The server writes "data: ...\n\n" chunks continuously. The browser EventSource reconnects automatically. No WebSocket upgrade needed. event is two lines: event: thought\n then data: {"content":"..."}\n\n. The double newline signals the end of one event. The event: field is the type (optional but recommended); the data: field is the payload. You can also include id: for client-side reconnect position tracking and retry: to control reconnect interval.
WHY: Clients get first token in <1s regardless of total agent runtime
GOTCHA: Set media_type="text/event-stream" AND include Cache-Control: no-cache or Nginx will buffer the entire stream before forwarding
# src/streaming.py
# WHAT: SSE endpoint that emits thought / tool_call / result events
# WHY: ReAct agents can take 30-90s; streaming keeps the UI alive
# GOTCHA: async generators must use "yield" not "return"; returning
# from an async generator closes the stream cleanly
import json, time
from fastapi import APIRouter, Request, Depends
from fastapi.responses import StreamingResponse
from .models import AgentRequest
from .main import verify_api_key, OLLAMA_HOST
router = APIRouter()
async def agent_event_generator(body: AgentRequest):
"""
WHAT: Converts the ReAct loop into an async SSE generator.
WHY: FastAPI's StreamingResponse consumes this generator and
writes each yielded string directly to the HTTP response
without buffering the whole output.
GOTCHA: Every chunk MUST end with \n\n (two newlines). One newline
splits fields within an event; two newlines end the event.
"""
from .agent import stream_agent # yields step dicts
try:
async for step in stream_agent(
query=body.query,
session_id=body.session_id,
max_iterations=body.max_iterations,
ollama_host=OLLAMA_HOST,
):
step_type = step.get("type", "unknown")
payload = json.dumps(step, ensure_ascii=False)
# SSE wire format: "event: {type}\ndata: {json}\n\n"
yield f"event: {step_type}\ndata: {payload}\n\n"
except Exception as e:
# Send a final error event before closing the stream
error_payload = json.dumps({"error": str(e)})
yield f"event: error\ndata: {error_payload}\n\n"
@router.post(
"/agent/stream",
dependencies=[Depends(verify_api_key)],
tags=["agent"],
summary="Stream agent steps as SSE",
)
async def stream_agent_endpoint(body: AgentRequest, request: Request):
"""
WHAT: Wraps the async generator in FastAPI's StreamingResponse
GOTCHA: headers must include Cache-Control: no-cache or reverse
proxies (Nginx, Caddy) will buffer the stream
"""
return StreamingResponse(
agent_event_generator(body),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # disables Nginx proxy buffering
"Connection": "keep-alive",
},
)
// src/routes/stream.js (Express)
// WHAT: SSE streaming route for the ReAct agent loop
// WHY: res.write() sends each chunk immediately; res.end() closes the stream
// GOTCHA: Must call res.flushHeaders() before the first res.write()
// or Nginx will buffer until the response ends
import express from 'express';
import { streamAgent } from '../agent.js';
export const streamRouter = express.Router();
streamRouter.post('/agent/stream', async (req, res) => {
// WHAT: Set SSE headers before any data is written
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('X-Accel-Buffering', 'no');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders(); // flush headers to client immediately
const { query, session_id, max_iterations } = req.body ?? {};
if (!query) {
res.write(`event: error\ndata: ${JSON.stringify({ error: 'query required' })}\n\n`);
return res.end();
}
try {
// WHAT: streamAgent() is an async generator yielding step objects
// GOTCHA: for-await-of automatically calls .return() on break/throw
for await (const step of streamAgent({ query, sessionId: session_id, maxIterations: max_iterations ?? 8 })) {
const eventType = step.type ?? 'unknown';
const payload = JSON.stringify(step);
res.write(`event: ${eventType}\ndata: ${payload}\n\n`);
}
} catch (err) {
res.write(`event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n`);
}
res.end();
});
JavaScript EventSource Client
WHY: EventSource reconnects automatically on network drops — no heartbeat polling needed
GOTCHA: EventSource only supports GET; for POST bodies use fetch with ReadableStream instead
// client/stream.js (browser)
// WHAT: POST to /agent/stream then read SSE events progressively
// WHY: Native EventSource only does GET; we use fetch + ReadableStream
// for POST bodies so we can send { query, session_id }
// GOTCHA: TextDecoderStream is available in all modern browsers;
// for Safari < 14.1, polyfill with text-encoding-utf-8
async function streamAgentQuery(query, sessionId = null) {
const outputDiv = document.getElementById('agent-output');
outputDiv.innerHTML = ''; // clear previous run
const response = await fetch('/agent/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${window.API_KEY}`,
},
body: JSON.stringify({ query, session_id: sessionId, max_iterations: 8 }),
});
if (!response.ok) {
outputDiv.textContent = `Error: ${response.status}`;
return;
}
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
// SSE events are terminated by double newline
const events = buffer.split('\n\n');
buffer = events.pop(); // keep incomplete event in buffer
for (const raw of events) {
if (!raw.trim()) continue;
// Parse "event: TYPE\ndata: JSON"
const lines = raw.split('\n');
const evType = lines.find(l => l.startsWith('event:'))?.slice(6).trim() ?? 'unknown';
const dataLine = lines.find(l => l.startsWith('data:'))?.slice(5).trim() ?? '{}';
let step;
try { step = JSON.parse(dataLine); } catch { step = { raw: dataLine }; }
renderStep(evType, step, outputDiv);
}
}
}
function renderStep(type, step, container) {
const div = document.createElement('div');
div.className = `sse-event sse-${type}`;
switch (type) {
case 'thought':
div.innerHTML = `Thought: ${escapeHtml(step.content ?? '')}`;
break;
case 'tool_call':
div.innerHTML = `Tool: ${escapeHtml(step.tool ?? '')} → ${escapeHtml(step.summary ?? '')}`;
break;
case 'result':
div.innerHTML = `Result: ${escapeHtml(step.content ?? '')}`;
div.style.borderColor = '#10B981';
break;
case 'error':
div.innerHTML = `Error: ${escapeHtml(step.error ?? '')}`;
div.style.borderColor = '#F43F5E';
break;
default:
div.textContent = JSON.stringify(step);
}
container.appendChild(div);
div.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function escapeHtml(s) {
return s.replace(/&/g,'&').replace(//g,'>');
}
// client/stream.node.js (Node.js CLI consumer)
// WHAT: Consume the SSE stream from Node.js using undici fetch
// WHY: Useful for CLI tools, test scripts, or backend-to-backend calls
// GOTCHA: Node.js built-in fetch (v18+) supports streaming;
// for v16, use node-fetch@3 which has the same API
import { fetch } from 'undici'; // or: import fetch from 'node-fetch';
async function streamFromNode(query, apiKey) {
const res = await fetch('http://localhost:8000/agent/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ query, max_iterations: 8 }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const decoder = new TextDecoder();
let buffer = '';
for await (const chunk of res.body) {
buffer += decoder.decode(chunk, { stream: true });
const events = buffer.split('\n\n');
buffer = events.pop();
for (const raw of events) {
if (!raw.trim()) continue;
const lines = raw.split('\n');
const type = lines.find(l => l.startsWith('event:'))?.slice(6).trim() ?? 'data';
const data = lines.find(l => l.startsWith('data:'))?.slice(5).trim() ?? '{}';
let step;
try { step = JSON.parse(data); } catch { step = { raw: data }; }
console.log(`[${type}]`, JSON.stringify(step));
}
}
}
streamFromNode(process.argv[2] ?? 'Resolve Acme Corp', process.env.API_KEY)
.catch(console.error);
The streaming endpoint converts the agent's ReAct loop into a live SSE feed. Each iteration produces at least one thought and one tool_call event before the final result. The client renders each event immediately. A 45-second agent now shows the first step in under 1 second — radically better perceived performance and far better resilience to network timeouts.
Async Job Queue for Long-Running Agents
HTTP 202 AcceptedHTTP status code meaning "the request was accepted for processing, but processing has not been completed." The server returns a job ID and the client polls a separate endpoint to get the eventual result. Used when work takes longer than a client can reasonably wait. means "I received your request and will process it, but I have not finished yet." The server immediately returns a job ID. The client polls GET /jobs/{job_id} until it sees status: complete. This breaks the synchronous request-response link, so agents can run for minutes or hours without a proxy timeout ever occurring.
Simple In-Process Queue (asyncio)
WHY: Breaks the 30s proxy timeout barrier; jobs can run for minutes
GOTCHA: asyncio.Queue lives in memory — all jobs vanish on restart. Use Redis+rq for production durability
# src/jobs.py
# WHAT: In-process asyncio job queue — simple version for dev/staging
# WHY: Zero dependencies; good enough for low-volume or demo deployments
# GOTCHA: State is in-memory. On container restart, pending jobs are lost.
# For production, replace job_store with Redis and use rq or Celery.
import asyncio, uuid, time
from enum import Enum
from typing import Any, Optional, Dict
from fastapi import APIRouter, BackgroundTasks, Depends
from .models import AgentRequest
from .main import verify_api_key, OLLAMA_HOST
from .agent import run_agent
router = APIRouter()
class JobStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETE = "complete"
FAILED = "failed"
# WHAT: In-memory job store — replace with Redis hashes in production
job_store: Dict[str, Dict[str, Any]] = {}
async def _execute_job(job_id: str, body: AgentRequest) -> None:
"""Background coroutine that runs the agent and updates job_store."""
job_store[job_id]["status"] = JobStatus.RUNNING
job_store[job_id]["started_at"] = time.time()
try:
result = await run_agent(
query=body.query,
session_id=body.session_id,
max_iterations=body.max_iterations,
ollama_host=OLLAMA_HOST,
)
job_store[job_id].update({
"status": JobStatus.COMPLETE,
"result": result,
"finished_at": time.time(),
})
except Exception as e:
job_store[job_id].update({
"status": JobStatus.FAILED,
"error": str(e),
"finished_at": time.time(),
})
@router.post(
"/agent/jobs",
status_code=202,
dependencies=[Depends(verify_api_key)],
tags=["agent"],
summary="Submit a long-running agent job",
)
async def submit_job(body: AgentRequest, background_tasks: BackgroundTasks):
"""
WHAT: Enqueue job, return job_id immediately with 202 Accepted.
GOTCHA: BackgroundTasks runs after the response is sent but WITHIN
the same process. For true isolation, use rq or Celery.
"""
job_id = str(uuid.uuid4())
job_store[job_id] = {
"job_id": job_id,
"status": JobStatus.PENDING,
"query": body.query,
"created_at": time.time(),
}
background_tasks.add_task(_execute_job, job_id, body)
return {"job_id": job_id, "status": JobStatus.PENDING, "poll_url": f"/jobs/{job_id}"}
@router.get(
"/jobs/{job_id}",
dependencies=[Depends(verify_api_key)],
tags=["agent"],
summary="Poll job status and retrieve result",
)
async def get_job(job_id: str):
"""
WHAT: Returns current job state. Client polls every 2s until complete.
GOTCHA: Return 404 for unknown IDs so clients detect bad job_ids
rather than polling forever on a silent empty response.
"""
job = job_store.get(job_id)
if not job:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
return job
// src/routes/jobs.js (BullMQ + Redis)
// WHAT: Production-grade job queue using BullMQ backed by Redis
// WHY: Jobs survive process restarts; workers scale horizontally
// GOTCHA: Requires Redis. Set REDIS_URL in env. For local dev,
// run: docker run -p 6379:6379 redis:alpine
import express from 'express';
import { Queue, Worker, QueueEvents } from 'bullmq';
import IORedis from 'ioredis';
import { v4 as uuidv4 } from 'uuid';
import { runAgent } from '../agent.js';
const connection = new IORedis(process.env.REDIS_URL ?? 'redis://localhost:6379', {
maxRetriesPerRequest: null, // BullMQ requirement
});
const agentQueue = new Queue('agent-jobs', { connection });
// WHAT: Worker runs in the same process (or a separate file for scale-out)
// GOTCHA: Worker must be instantiated for jobs to process.
// In production, run workers as a separate Node.js process.
const worker = new Worker('agent-jobs', async (job) => {
const { query, sessionId, maxIterations } = job.data;
return runAgent({ query, sessionId, maxIterations, ollamaHost: process.env.OLLAMA_HOST ?? 'http://localhost:11434' });
}, { connection, concurrency: 4 });
worker.on('failed', (job, err) => {
console.error(`Job ${job?.id} failed:`, err.message);
});
export const jobsRouter = express.Router();
// POST /agent/jobs → 202 Accepted with job_id
jobsRouter.post('/agent/jobs', async (req, res) => {
const { query, session_id, max_iterations = 8 } = req.body ?? {};
if (!query) return res.status(422).json({ error: 'query required' });
const job = await agentQueue.add('run', {
query, sessionId: session_id, maxIterations: max_iterations,
}, {
attempts: 2,
backoff: { type: 'exponential', delay: 2000 },
});
res.status(202).json({
job_id: job.id,
status: 'pending',
poll_url: `/jobs/${job.id}`,
});
});
// GET /jobs/:id → {status, result, error}
jobsRouter.get('/jobs/:id', async (req, res) => {
const job = await agentQueue.getJob(req.params.id);
if (!job) return res.status(404).json({ error: `Job ${req.params.id} not found` });
const state = await job.getState(); // waiting | active | completed | failed
const result = state === 'completed' ? await job.returnvalue : undefined;
const error = state === 'failed' ? job.failedReason : undefined;
res.json({ job_id: job.id, status: state, result, error });
});
Validation & Rate Limiting
A token bucketA rate limiting algorithm: each API key has a "bucket" with a maximum capacity (e.g., 10 tokens). Each request consumes one token. Tokens refill at a constant rate (e.g., 1 per 6 seconds = 10/min). If the bucket is empty, the request is rejected with 429. Allows short bursts up to bucket capacity while enforcing a long-term average rate. rate limiter gives each API key a bucket with N tokens. Each request consumes one token; tokens refill at a constant rate. A key can burst up to bucket capacity then is throttled to the refill rate. This is friendlier than a hard per-minute counter (which punishes clients that legitimately front-load requests) while still enforcing an average rate ceiling. The standard response for a refused request is HTTP 429 with a Retry-After header.
WHY: Prevents a single noisy caller from consuming all Ollama inference capacity
GOTCHA: This in-process bucket is per-worker; behind a multi-replica deployment, use Redis + lua script for shared state
# src/rate_limit.py
# WHAT: In-process token bucket rate limiter, 10 req/min per API key
# WHY: Protect Ollama from inference overload caused by a noisy caller
# GOTCHA: Use a threading.Lock (or asyncio.Lock) around bucket state;
# async routes run concurrently so unsynchronized reads are a race
import time, math
from threading import Lock
from typing import Dict
from fastapi import HTTPException, Request
class TokenBucket:
"""Thread-safe token bucket. Capacity=10, refill=10/min."""
def __init__(self, capacity: int = 10, refill_rate: float = 10 / 60):
self.capacity = capacity
self.refill_rate = refill_rate # tokens per second
self._buckets: Dict[str, Dict] = {}
self._lock = Lock()
def _get_or_create(self, key: str) -> Dict:
if key not in self._buckets:
self._buckets[key] = {"tokens": self.capacity, "last": time.monotonic()}
return self._buckets[key]
def consume(self, key: str) -> float:
"""
WHAT: Try to consume one token. Returns seconds until retry if refused.
WHY: Returns a float so the caller can set Retry-After header precisely.
GOTCHA: math.floor for tokens avoids sub-token accumulation drift.
"""
with self._lock:
bucket = self._get_or_create(key)
now = time.monotonic()
elapsed = now - bucket["last"]
# Refill tokens earned since last call
new_tokens = elapsed * self.refill_rate
bucket["tokens"] = min(self.capacity, bucket["tokens"] + new_tokens)
bucket["last"] = now
if bucket["tokens"] >= 1:
bucket["tokens"] -= 1
return 0.0 # allowed
else:
# seconds until one token is available
return (1 - bucket["tokens"]) / self.refill_rate
limiter = TokenBucket(capacity=10, refill_rate=10 / 60)
async def rate_limit_dependency(request: Request) -> None:
"""
WHAT: FastAPI dependency that rejects over-rate requests with 429.
GOTCHA: Reads the Authorization header to identify the key; use
a different identifier (IP, user ID) if keys are shared.
"""
auth = request.headers.get("Authorization", "")
key = auth.removeprefix("Bearer ").strip() or request.client.host
retry_after = limiter.consume(key)
if retry_after > 0:
raise HTTPException(
status_code=429,
detail={
"error": "rate_limit_exceeded",
"message": f"10 req/min limit reached. Retry in {retry_after:.1f}s.",
"retry_after_seconds": math.ceil(retry_after),
},
headers={"Retry-After": str(math.ceil(retry_after))},
)
// src/middleware/rateLimit.js (Express)
// WHAT: Token bucket rate limiter — 10 req/min per API key
// WHY: Single-dependency implementation; no Redis needed for single-replica
// GOTCHA: For multi-replica deployments replace with rate-limiter-flexible
// backed by Redis so all replicas share bucket state
const buckets = new Map(); // key → { tokens, lastMs }
const CAPACITY = 10;
const REFILL_RATE = 10 / 60; // tokens per second
function consume(key) {
const now = Date.now() / 1000;
let bucket = buckets.get(key);
if (!bucket) {
bucket = { tokens: CAPACITY, last: now };
buckets.set(key, bucket);
}
const elapsed = now - bucket.last;
bucket.tokens = Math.min(CAPACITY, bucket.tokens + elapsed * REFILL_RATE);
bucket.last = now;
if (bucket.tokens >= 1) {
bucket.tokens -= 1;
return 0; // allowed
}
return (1 - bucket.tokens) / REFILL_RATE; // seconds to retry
}
export function rateLimitMiddleware(req, res, next) {
if (req.path === '/health') return next();
const auth = req.headers['authorization'] ?? '';
const key = auth.startsWith('Bearer ') ? auth.slice(7).trim() : req.ip;
const retryAfter = consume(key);
if (retryAfter > 0) {
const retryAfterCeil = Math.ceil(retryAfter);
res.setHeader('Retry-After', String(retryAfterCeil));
return res.status(429).json({
error: 'rate_limit_exceeded',
message: `10 req/min limit reached. Retry in ${retryAfterCeil}s.`,
retry_after_seconds: retryAfterCeil,
});
}
next();
}
Docker Deployment
Mistral-7B-Q4 (the 4-bit quantized version) runs entirely on CPU inside the Ollama container with ~4.5 GB RAM. A Hetzner CX21 (2 vCPU, 4 GB RAM) is borderline; the CX31 (2 vCPU, 8 GB RAM, €5.77/mo) is the sweet spot. Set OLLAMA_NUM_THREADS=2 to use both cores. No nvidia-docker, no GPU drivers, no cloud GPU costs. Inference takes 2-5 seconds per token on CPU, which is fine for typical agent queries.
Multi-Stage Dockerfile
WHY: The builder stage installs gcc/build-tools; the final stage only copies compiled artifacts — cuts image size by ~60%
GOTCHA: HEALTHCHECK is required for docker-compose depends_on: condition: service_healthy to work
# Dockerfile (Python FastAPI)
# Stage 1: builder — installs deps with build tools available
FROM python:3.12-slim AS builder
WORKDIR /build
# WHAT: Install only what's needed to compile native extensions (e.g. httptools)
# GOTCHA: --no-install-recommends saves ~80MB; removes man pages, etc.
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc g++ && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# Stage 2: runtime — lean image with only the compiled packages
FROM python:3.12-slim AS runtime
WORKDIR /app
# WHAT: Copy compiled site-packages from builder without gcc
COPY --from=builder /root/.local /root/.local
COPY src/ ./src/
COPY .env.example ./.env.example
# WHAT: Create non-root user to follow least-privilege principle
# GOTCHA: Files copied before this RUN are owned by root; chown them
RUN useradd --no-create-home appuser && chown -R appuser /app
USER appuser
# WHAT: HEALTHCHECK tells Docker and docker-compose when the app is ready
# WHY: depends_on: condition: service_healthy waits for this to pass
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" \
|| exit 1
ENV PATH=/root/.local/bin:$PATH \
PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
# Dockerfile (TypeScript / Node.js Express)
# Stage 1: build — compile TS to JS
FROM node:20-alpine AS builder
WORKDIR /build
# WHAT: Copy package files first so Docker caches the npm install layer
# GOTCHA: Copy package-lock.json too; npm ci requires it
COPY package.json package-lock.json tsconfig.json ./
RUN npm ci --ignore-scripts
COPY src/ ./src/
RUN npx tsc --outDir dist --noEmit false
# Stage 2: runtime — only production deps + compiled JS
FROM node:20-alpine AS runtime
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts
COPY --from=builder /build/dist ./dist
# WHAT: Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://localhost:8000/health || exit 1
EXPOSE 8000
ENV NODE_ENV=production
CMD ["node", "dist/server.js"]
docker-compose.yml with Ollama Sidecar
:8000
port: 8000:8000
depends_on: ollama
port: 11434:11434
OLLAMA_HOST=0.0.0.0
WHY: depends_on with condition: service_healthy ensures Ollama is ready before the API starts accepting traffic
GOTCHA: OLLAMA_HOST=0.0.0.0 is required inside the ollama container so other containers can connect; default is 127.0.0.1
# docker-compose.yml
# WHAT: Orchestrate api + ollama sidecar on a private bridge network
# WHY: The private network means ollama is NOT exposed to the internet
# unless you explicitly bind its port — only the api container talks to it
# GOTCHA: The model must be pulled once: docker exec -it ollama ollama pull mistral
services:
api:
build:
context: .
target: runtime # use the lean runtime stage
ports:
- "8000:8000"
environment:
API_KEY: ${API_KEY}
OLLAMA_HOST: http://ollama:11434 # DNS name = service name inside compose
DEBUG: "false"
depends_on:
ollama:
condition: service_healthy # waits for ollama HEALTHCHECK to pass
networks:
- agent-network
restart: unless-stopped
stop_grace_period: 30s # WHAT: graceful shutdown window
# GOTCHA: send SIGTERM, not SIGKILL; uvicorn needs time to finish
# in-flight requests. 30s covers even slow Ollama inference.
ollama:
image: ollama/ollama:latest
ports:
- "11434:11434" # expose only for local CLI pulls
environment:
OLLAMA_HOST: "0.0.0.0" # listen on all interfaces, not just 127.0.0.1
OLLAMA_NUM_THREADS: "2"
OLLAMA_MAX_LOADED_MODELS: "1"
volumes:
- ollama-models:/root/.ollama # persist pulled models across restarts
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:11434/api/tags"]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s # Ollama needs ~15s to initialize
networks:
- agent-network
restart: unless-stopped
volumes:
ollama-models: # named volume; survives docker-compose down
networks:
agent-network:
driver: bridge
# 1. Build and start all services
API_KEY=my-secret-key docker-compose up --build -d
# 2. Pull the Mistral model into the ollama sidecar (one-time setup)
docker-compose exec ollama ollama pull mistral:7b-instruct-q4_K_M
# 3. Watch logs from both services
docker-compose logs -f
# 4. Verify health
curl http://localhost:8000/health
# {"status":"ok","ollama":true,"version":"1.0.0"}
# 5. Test the agent
curl -X POST http://localhost:8000/agent/run \
-H "Authorization: Bearer my-secret-key" \
-H "Content-Type: application/json" \
-d '{"query":"Is Acme Corp LLC the same as ACME CORPORATION?"}'
# 6. Graceful shutdown (waits for in-flight requests to complete)
docker-compose down
You now have a fully containerized deployment. The api service never starts until the ollama container passes its health check. Models are stored in a named volume so you only pull once. The agent-network bridge means Ollama is private to the compose stack — it is not reachable from the internet unless you explicitly expose port 11434. Graceful shutdown gives in-flight agent calls 30 seconds to finish before the process is killed.
VPS & Cloud Deployment
The same docker-compose.yml you tested locally deploys to any server that runs Docker. The only three things that change between local and production: TLS termination, secret injection, and Ollama's listen address.
Option 1: Hetzner / DigitalOcean VPS
WHY: Caddy auto-provisions Let's Encrypt certificates and handles TLS termination so your FastAPI app stays plain HTTP internally
GOTCHA: Port 80 and 443 must be open in the server firewall or Caddy cannot complete the ACME challenge
#!/bin/bash
# scripts/provision.sh — run once on a fresh Ubuntu 22.04 server
# WHAT: Install Docker, Caddy, pull repo, inject secrets, start stack
# GOTCHA: Run as root or prefix commands with sudo
set -euo pipefail
# 1. Install Docker
curl -fsSL https://get.docker.com | sh
usermod -aG docker $USER
# 2. Install Caddy (reverse proxy with auto-TLS via Let's Encrypt)
apt-get install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt-get update && apt-get install -y caddy
# 3. Clone repo and create .env
git clone https://github.com/your-org/entity-agent.git /opt/agent
cd /opt/agent
# 4. Inject secrets (never commit these to git!)
cat > .env <
# /etc/caddy/Caddyfile
# WHAT: Caddy terminates TLS and proxies HTTPS→HTTP to FastAPI
# WHY: Caddy auto-renews Let's Encrypt certs; zero certificate management
# GOTCHA: Replace agent.yourdomain.com with your actual domain.
# The ACME challenge requires port 80 open to the internet.
agent.yourdomain.com {
# WHAT: Automatic HTTPS — Caddy provisions and renews the cert
tls {
protocols tls1.2 tls1.3 # disable TLS 1.0 / 1.1
}
# WHAT: Proxy all requests to the FastAPI container on :8000
reverse_proxy localhost:8000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
flush_interval -1 # disable buffering for SSE streams
}
# Security headers
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
X-Frame-Options DENY
-Server # remove Caddy version header
}
# Rate limit logs (Caddy does not rate-limit; your app handles it)
log {
output file /var/log/caddy/agent-access.log
format json
}
}
Option 2: Fly.io
GOTCHA: Mistral-7B-Q4 needs 5 GB RAM minimum; use fly scale memory 6144 on the Ollama machine
# fly.toml (generated by fly launch, then customized)
app = "entity-agent"
primary_region = "iad" # us-east
[build]
dockerfile = "Dockerfile"
[env]
OLLAMA_HOST = "http://entity-agent-ollama.internal:11434"
DEBUG = "false"
[[services]]
protocol = "tcp"
internal_port = 8000
[[services.ports]]
port = 443
handlers = ["tls", "http"]
[[services.ports]]
port = 80
handlers = ["http"]
[services.concurrency]
type = "requests"
hard_limit = 25
soft_limit = 20
[[services.http_checks]]
interval = "15s"
timeout = "5s"
grace_period = "30s"
path = "/health"
[[vm]]
size = "shared-cpu-2x"
memory = "2gb"
# Deploy to Fly.io
# GOTCHA: Install flyctl first: curl -L https://fly.io/install.sh | sh
# 1. Authenticate
flyctl auth login
# 2. Launch (creates fly.toml if not present; skip if you have one)
flyctl launch --no-deploy
# 3. Set secrets (stored in Fly's encrypted vault, not in fly.toml)
flyctl secrets set API_KEY=my-production-key
# 4. Deploy the app
flyctl deploy
# 5. Check status
flyctl status
# 6. Tail live logs
flyctl logs
# 7. For Ollama sidecar: deploy as a separate Fly Machine in same org
flyctl machine run ollama/ollama \
--name entity-agent-ollama \
--vm-memory 6144 \
--env OLLAMA_HOST=0.0.0.0 \
--port 11434/tcp:11434
# 8. Pull model into the Ollama machine
flyctl ssh console --app entity-agent-ollama \
-C "ollama pull mistral:7b-instruct-q4_K_M"
Option 3: Railway
GOTCHA: Railway's free tier is 512 MB RAM — far too small for Mistral-7B. Use the Starter plan ($5/mo) with 8 GB RAM
# Deploy to Railway (CLI)
npm install -g @railway/cli
railway login
# 1. Initialize project linked to GitHub repo
railway init
# 2. Add Ollama as a second service in same project
# (Railway UI: New Service → Docker Image → ollama/ollama)
# Then set OLLAMA_HOST env var in Railway UI to the Ollama service's
# internal URL (Railway provides this automatically)
# 3. Set secrets
railway variables set API_KEY=my-production-key
# 4. Deploy (Railway auto-detects Dockerfile)
railway up
# 5. View logs
railway logs --tail
# WHAT: Railway provides auto-TLS on *.railway.app domains
# No Caddy or cert management needed.
# Production Deployment Checklist
## TLS
- [ ] HTTPS only — HTTP redirects to HTTPS (Caddy / Fly / Railway handle this)
- [ ] TLS 1.2+ only — disable TLS 1.0 and 1.1
## Secrets
- [ ] API_KEY set via env (fly secrets / railway variables / .env on VPS)
- [ ] No API keys committed to git (check with: git log -p | grep API_KEY)
- [ ] .env file has chmod 600 on VPS
## Ollama
- [ ] OLLAMA_HOST=0.0.0.0 inside the Ollama container
- [ ] Model pulled and visible: curl http://ollama:11434/api/tags
- [ ] Port 11434 NOT exposed publicly (only internal network)
## Health Checks
- [ ] /health returns {"status":"ok","ollama":true} before routing traffic
- [ ] depends_on: condition: service_healthy in docker-compose.yml
## Graceful Shutdown
- [ ] stop_grace_period: 30s in docker-compose.yml (or Fly/Railway equivalent)
- [ ] SIGTERM handler in the app (uvicorn handles this by default)
Prometheus Metrics Integration
In M20 you built a MetricsCollector that tracks request count, latency histograms, and active connections. Exposing those metrics from the FastAPI service takes exactly two lines: mount the Prometheus ASGI app at /metrics.
WHY: Grafana dashboards from M20 connect to this endpoint; no new instrumentation code needed
GOTCHA: Restrict /metrics to internal network only in production — never expose raw metrics publicly
# src/main.py (add these two lines to the existing FastAPI app)
# WHAT: Mount prometheus_client's ASGI metrics app at /metrics
# WHY: Zero new instrumentation; reuses all MetricsCollector state from M20
# GOTCHA: prometheus_client.make_asgi_app() must be mounted AFTER
# all routes so it doesn't shadow your own /metrics route
from prometheus_client import make_asgi_app # LINE 1
app.mount("/metrics", make_asgi_app()) # LINE 2
# That's it. Your MetricsCollector from M20 already increments the counters.
# Prometheus scrapes: curl http://localhost:8000/metrics
# Sample output:
#
# agent_requests_total{endpoint="/agent/run",status="200"} 142.0
# agent_request_duration_seconds_bucket{le="0.5",...} 118.0
# agent_active_connections 3.0
// src/app.js (add these two lines to the existing Express app)
// WHAT: Expose /metrics for Prometheus using prom-client
// WHY: Reuses all metrics registered in MetricsCollector from M20
// GOTCHA: Must import prom-client AFTER all MetricsCollector imports
// so the default registry captures all previously registered metrics
import { register } from 'prom-client'; // LINE 1
app.get('/metrics', async (req, res) => { // LINE 2 (two lines, counts as two)
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
});
// Sample output from: curl http://localhost:8000/metrics
// agent_requests_total{endpoint="/agent/run",status="200"} 142
// agent_request_duration_seconds_bucket{le="0.5"} 118
// agent_active_connections 3
Adding two lines to expose /metrics means your Grafana dashboards from M20 immediately show production traffic — request rates, p50/p95 latency, active connections, and error rates — without any new instrumentation work. When you add the job queue in a real deployment, you increment the same Prometheus counters from the worker process too, giving you a unified view of synchronous and async traffic in one dashboard.
Lab: Wrap Capstone C3 as a FastAPI Streaming Service
You built the Capstone C3 entity resolution agent in an earlier module. In this lab, you wrap it as a production FastAPI streaming service, containerize it with Docker Compose and an Ollama sidecar, and verify the end-to-end flow with curl.
Working Capstone C3 agent code, Docker Desktop (or Docker Engine on Linux), and the mistral:7b-instruct-q4_K_M model either already pulled locally or a connection fast enough to pull ~4 GB. Estimated time: 40 minutes.
Create src/main.py using the boilerplate from the FastAPI Setup section. Wire your Capstone C3 run_entity_resolution_agent() function into the /agent/run endpoint.
mkdir -p src && touch src/__init__.py src/main.py src/models.py
pip install fastapi uvicorn[standard] pydantic httpx
uvicorn src.main:app --reload starts without errors. curl http://localhost:8000/health returns {"status":"ok"} (ollama may be false until the sidecar is running — that's expected).
Refactor run_entity_resolution_agent() into an async def stream_entity_resolution_agent() generator that yields step dictionaries. Add the /agent/stream endpoint from the Streaming section.
# In your agent.py: convert synchronous loop to async generator
async def stream_entity_resolution_agent(query, session_id=None, max_iterations=8, ollama_host="http://localhost:11434"):
"""Yields {type, content} dicts for each ReAct step."""
for iteration in range(max_iterations):
# ... your existing ReAct loop logic ...
yield {"type": "thought", "iteration": iteration, "content": thought_text}
# ... call tool ...
yield {"type": "tool_call", "tool": tool_name, "summary": tool_result_summary}
if is_final:
yield {"type": "result", "content": final_answer}
return
yield {"type": "result", "content": "Max iterations reached", "truncated": True}
The /agent/stream endpoint is registered. curl -N -X POST http://localhost:8000/agent/stream -H "Authorization: Bearer $API_KEY" -d '{"query":"test"}' should return a stream of event: thought\ndata: {...}\n\n lines even if Ollama is not yet running (it may return an error event — that's fine).
Set API_KEY and send a real entity resolution query. Watch the SSE events arrive in real-time.
export API_KEY=dev-key-1234
# The -N flag disables buffering so you see events as they arrive
curl -N -s -X POST http://localhost:8000/agent/stream \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"Is Acme Logistics LLC the same as ACME LOGISTICS L.L.C.?","max_iterations":5}'
You see at least one thought event within 3 seconds and a final result event. If you see an error event, check that Ollama is running: curl http://localhost:11434/api/tags.
Write the multi-stage Dockerfile and docker-compose.yml from the Docker Deployment section. Build the image and start the stack.
# Build the image (multi-stage — runtime stage only)
docker build --target runtime -t entity-agent:dev .
# Verify image size (should be < 600 MB for Python, < 400 MB for Node)
docker images entity-agent:dev
# Start the full stack (api + ollama sidecar)
API_KEY=dev-key-1234 docker compose up -d
# Wait for both health checks to pass (about 30s for Ollama to init)
docker compose ps
Both services show (healthy) in docker compose ps. The api service took longer than ollama to reach healthy because it waited for ollama's HEALTHCHECK to pass first. This confirms depends_on: condition: service_healthy is working.
Pull the Mistral model into the Ollama sidecar and run the full streaming test against the containerized stack.
# Pull the quantized model into the sidecar (one-time, ~4.1 GB)
docker compose exec ollama ollama pull mistral:7b-instruct-q4_K_M
# Confirm model is loaded
curl -s http://localhost:11434/api/tags | python -m json.tool
# Full end-to-end test through Docker (same command as Checkpoint 3)
curl -N -s -X POST http://localhost:8000/agent/stream \
-H "Authorization: Bearer dev-key-1234" \
-H "Content-Type: application/json" \
-d '{"query":"Is Acme Logistics LLC the same as ACME LOGISTICS L.L.C.?"}'
# Verify /health sees ollama as healthy
curl -s http://localhost:8000/health | python -m json.tool
# Verify /metrics endpoint
curl -s http://localhost:8000/metrics | grep agent_requests
The Dockerized service streams real agent steps powered by Mistral-7B-Q4 via the Ollama sidecar. The /health endpoint confirms Ollama is reachable. The /metrics endpoint shows the request count increment. You have a fully containerized, production-ready agent API running entirely on open-source components — no external API keys, no GPU, deployable anywhere Docker runs.
Knowledge Check
Test your understanding of API design, streaming, and deployment patterns.
1. An agent takes 45 seconds on average. A client using the synchronous POST /agent/run endpoint reports intermittent 504 errors after 30 seconds. What is the most appropriate fix?
2. What HTTP status code should POST /agent/jobs return when it successfully enqueues a job but has not yet started processing?
3. You deploy the FastAPI + Ollama stack behind Nginx. Users report that the SSE streaming endpoint delivers all events at once at the end instead of progressively. What is the root cause?
media_type in StreamingResponse is wrongX-Accel-Buffering: no to the response headersyield correctly4. In the token bucket rate limiter, a client with a fresh bucket sends 11 requests in rapid succession. What happens to the 11th request?
Retry-After header