Skip to main content

FastAPI for ML Microservices with Pydantic v2

Build ML microservices with FastAPI (Python 3.12) and Pydantic v2: request/response validation, async/await, lifespan for model loading at boot, dependency injection, health checks, graceful degradation with on-prem Ollama.

What do you think about this article?

FastAPI for ML Microservices with Pydantic v2
AI EngineeringPhotos:RDNE Stock projectPexels

At a Glance

Build ML microservices with FastAPI (Python 3.12) and Pydantic v2: request/response validation, async/await, lifespan for model loading at boot, dependency injection, health checks, graceful degradation with on-prem Ollama.

FastAPI is the fastest Python framework for building production-ready ML microservices. Combined with Pydantic v2 for declarative validation, native async/await, and on-prem Ollama as the AI provider, you can deploy services with latency < 200ms, automatic OpenAPI docs, and graceful degradation when the model is unavailable. This article shows how to integrate Pydantic v2, manage model lifecycle at boot, implement robust health checks, and handle errors enterprise-style — starting from real production examples.

Why FastAPI for ML Microservices?

In 2026, deploying ML models in production requires three things: speed (latency < 200ms), rigorous validation (no garbage input), and resilience (fallback when the model is down). FastAPI + Pydantic v2 + Python 3.12 answers these requirements natively.

Unlike Flask (legacy, slow, no type hints), FastAPI offers:

  • Native async/await: handle hundreds of concurrent requests on a single CPU core.
  • Built-in Pydantic v2: schema-first validation, automatic serialization, precise error messages.
  • Auto-generated OpenAPI docs: Swagger UI for free, no manual docstrings.
  • Type hints: code intelligence in IDEs, catch bugs before runtime.
  • Dependency injection: inject models, cache, logger, settings declaratively.

In Federico Calò's portfolio, the FastAPI ML microservice on VPS Hetzner serves 40+ RAG endpoints with average latency 85ms (p99 < 350ms) with zero downtime, handling 2M requests/month.

Pydantic v2: Models and Validators

Defining Request/Response Models

In Pydantic v2, a model is a class that inherits from BaseModel. The magic: type hints → automatic validation.


from pydantic import BaseModel, Field, field_validator
from typing import Optional
from enum import Enum

class EmbeddingRequest(BaseModel):
    """Embedding request: text → vector."""
    text: str = Field(..., min_length=1, max_length=2000, description="Text to embed")
    model: str = Field(default="sentence-transformers/all-MiniLM-L6-v2")
    normalize: bool = Field(default=True, description="Normalize vector?")

    @field_validator('text')
    @classmethod
    def text_not_only_spaces(cls, v):
        if not v.strip():
            raise ValueError("Text cannot be only spaces")
        return v.strip()

class EmbeddingResponse(BaseModel):
    """Embedding response."""
    embedding: list[float] = Field(..., description="Embedding vector (dim 384)")
    model: str
    dims: int
    computation_time_ms: float = Field(..., ge=0, description="Processing time")
  

Notes:

  • Field(...) = required, Field(default=...) = optional.
  • field_validator = custom validator executed after parsing.
  • ge=0 = greater or equal (schema validation).

SettingsModel for Configuration

Pydantic v2 introduces BaseSettings to load environment variables with type hints and validation:


from pydantic_settings import BaseSettings
from pydantic import Field

class MLServiceSettings(BaseSettings):
    """ML microservice configuration."""
    ollama_url: str = Field(default="http://localhost:11434", description="Ollama local URL")
    ollama_model: str = Field(default="llama3.1:8b")
    embedding_model: str = Field(default="sentence-transformers/all-MiniLM-L6-v2")
    max_context_length: int = Field(default=2048, ge=256)
    health_check_interval: float = Field(default=30.0, gt=0)
    timeout_seconds: float = Field(default=60.0, gt=0)

    class Config:
        env_file = ".env"
        env_file_encoding = "utf-8"

settings = MLServiceSettings()
  

Loads from .env or system variables. If OLLAMA_URL is invalid (e.g., non-URL), Pydantic raises ValidationError at startup — fail-fast.

Async/Await for Non-Blocking I/O

FastAPI is built on async. Every endpoint must be async def to leverage I/O multiplexing via Uvicorn (ASGI server).


import httpx
import asyncio

class OllamaClient:
    """Async client to on-prem Ollama."""

    def __init__(self, base_url: str, timeout: float = 60.0):
        self.base_url = base_url.rstrip('/')
        self.timeout = timeout
        self._client: Optional[httpx.AsyncClient] = None

    async def __aenter__(self):
        self._client = httpx.AsyncClient(timeout=self.timeout)
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        if self._client:
            await self._client.aclose()

    async def generate(self, prompt: str, model: str = "llama3.1:8b") -> str:
        """Generate text via Ollama (streaming)."""
        if not self._client:
            raise RuntimeError("Client not initialized")

        response = await self._client.post(
            f"{self.base_url}/api/generate",
            json={"model": model, "prompt": prompt, "stream": False}
        )
        response.raise_for_status()
        return response.json()["response"]
  

With async with OllamaClient(...) as client: text = await client.generate(...), the Uvicorn thread doesn't block while waiting for Ollama — it can serve other requests in parallel.

Lifespan: Model Loading at Boot

The most critical section. With lifespan, you load the model once at startup (not per request). FastAPI v0.93+ uses @asynccontextmanager:


from contextlib import asynccontextmanager
from fastapi import FastAPI
import asyncio

# Global state (singleton)
class ModelCache:
    embedding_model = None
    ollama_client = None

@asynccontextmanager
async def lifespan(app: FastAPI):
    # STARTUP: load models
    print("[STARTUP] Loading models...")

    try:
        # Load embedding model in-memory
        from sentence_transformers import SentenceTransformer
        ModelCache.embedding_model = SentenceTransformer(
            "sentence-transformers/all-MiniLM-L6-v2",
            device="cpu"  # or "cuda" if GPU available
        )
        print("[STARTUP] Embedding model loaded")

        # Create Ollama client
        ModelCache.ollama_client = OllamaClient(
            base_url=settings.ollama_url,
            timeout=settings.timeout_seconds
        )
        await ModelCache.ollama_client.__aenter__()
        print("[STARTUP] Ollama client connected")

    except Exception as e:
        print(f"[ERROR] Startup failed: {e}")
        raise

    yield  # App runs here

    # SHUTDOWN: cleanup
    print("[SHUTDOWN] Closing resources...")
    if ModelCache.ollama_client:
        await ModelCache.ollama_client.__aexit__(None, None, None)
    print("[SHUTDOWN] Cleanup complete")

app = FastAPI(
    title="ML Microservice",
    version="1.0.0",
    lifespan=lifespan
)
  

The lifespan ensures:

  • Models loaded ONCE at boot (not per request).
  • If loading fails, the app doesn't start (fail-fast).
  • Orderly shutdown: close connections, free memory.

Dependency Injection

FastAPI's Depends() injects dependencies into endpoints declaratively:


from fastapi import FastAPI, Depends

def get_embedding_model():
    """Dependency: return loaded embedding model."""
    if ModelCache.embedding_model is None:
        raise RuntimeError("Embedding model not loaded")
    return ModelCache.embedding_model

def get_ollama_client():
    """Dependency: return Ollama client."""
    if ModelCache.ollama_client is None:
        raise RuntimeError("Ollama client unavailable")
    return ModelCache.ollama_client

@app.post("/embed", response_model=EmbeddingResponse)
async def embed(
    request: EmbeddingRequest,
    model = Depends(get_embedding_model),
    ollama_client = Depends(get_ollama_client)
):
    """Embedding endpoint with injected dependencies."""
    import time
    start = time.perf_counter()

    # Use model and ollama_client
    embedding = model.encode(request.text).tolist()

    elapsed_ms = (time.perf_counter() - start) * 1000

    return EmbeddingResponse(
        embedding=embedding,
        model=request.model,
        dims=len(embedding),
        computation_time_ms=elapsed_ms
    )
  

Benefits: testability (mock dependencies), reusability (same dependency in multiple endpoints), and error propagation (if model is None, FastAPI responds 500 with clear message).

Automatic Request/Response Validation

Pydantic v2 validates automatically:


# Valid request
POST /embed
{"text": "Hello world", "normalize": true}
→ 200 OK

# Invalid request (text too long)
POST /embed
{"text": "x" * 2001}
→ 422 Unprocessable Entity
{
  "detail": [
    {
      "type": "string_too_long",
      "loc": ["body", "text"],
      "msg": "String should have at most 2000 characters",
      "input": "xxxx..."
    }
  ]
}
  

No manual try/catch for input validation — Pydantic does it automatically.

Structured Error Handling

Define custom exceptions and a global exception handler:


from fastapi import HTTPException, status

class OllamaUnavailableError(Exception):
    """Ollama unavailable."""
    pass

class ModelInferenceError(Exception):
    """Error during inference."""
    pass

@app.exception_handler(OllamaUnavailableError)
async def handle_ollama_unavailable(request, exc):
    return JSONResponse(
        status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
        content={
            "error": "ollama_unavailable",
            "message": "Ollama AI provider temporarily unavailable",
            "retry_after_seconds": 30
        }
    )

@app.exception_handler(ModelInferenceError)
async def handle_inference_error(request, exc):
    return JSONResponse(
        status_code=status.HTTP_400_BAD_REQUEST,
        content={
            "error": "inference_failed",
            "message": str(exc),
            "request_id": request.headers.get("X-Request-ID", "unknown")
        }
    )
  

Health Check and Graceful Degradation

A health check endpoint that monitors the status of dependent services:


from enum import Enum
from datetime import datetime

class HealthStatus(str, Enum):
    HEALTHY = "healthy"
    DEGRADED = "degraded"
    UNHEALTHY = "unhealthy"

class HealthCheck(BaseModel):
    status: HealthStatus
    timestamp: str
    services: dict[str, dict]
    version: str = "1.0.0"

@app.get("/health", response_model=HealthCheck)
async def health_check():
    """Health check with status of all dependent services."""
    import time

    services = {}
    overall_status = HealthStatus.HEALTHY

    # Check embedding model
    if ModelCache.embedding_model is None:
        services["embedding_model"] = {"status": "down"}
        overall_status = HealthStatus.UNHEALTHY
    else:
        services["embedding_model"] = {"status": "up"}

    # Check Ollama
    if ModelCache.ollama_client is None:
        services["ollama"] = {"status": "down", "message": "Client not initialized"}
        overall_status = HealthStatus.UNHEALTHY
    else:
        try:
            start = time.perf_counter()
            response = await ModelCache.ollama_client._client.get(
                f"{ModelCache.ollama_client.base_url}/api/tags",
                timeout=5.0
            )
            elapsed_ms = (time.perf_counter() - start) * 1000

            if response.status_code == 200:
                services["ollama"] = {
                    "status": "up",
                    "latency_ms": round(elapsed_ms, 2)
                }
            else:
                services["ollama"] = {
                    "status": "degraded",
                    "http_code": response.status_code
                }
                overall_status = HealthStatus.DEGRADED

        except Exception as e:
            services["ollama"] = {"status": "down", "error": str(e)}
            overall_status = HealthStatus.UNHEALTHY

    return HealthCheck(
        status=overall_status,
        timestamp=datetime.utcnow().isoformat(),
        services=services
    )
  

Graceful Degradation: Deterministic Fallback

If Ollama doesn't respond, the service returns a credible result (not crash):


class ChatRequest(BaseModel):
    query: str = Field(..., max_length=1000)
    system_prompt: Optional[str] = None

class ChatResponse(BaseModel):
    response: str
    model: str
    ollama_available: bool
    latency_ms: float

@app.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
    """Chat endpoint with graceful degradation."""
    import time
    start = time.perf_counter()

    ollama_available = False
    response_text = ""

    try:
        # Try Ollama
        async with OllamaClient(settings.ollama_url, timeout=10.0) as client:
            response_text = await client.generate(
                prompt=request.query,
                model=settings.ollama_model
            )
            ollama_available = True

    except asyncio.TimeoutError:
        # Ollama timeout: fallback
        response_text = f"[FALLBACK] Ollama did not respond in timeout. Query: {request.query[:100]}..."
        ollama_available = False

    except Exception as e:
        # Any other error
        response_text = f"[FALLBACK] Service error: {type(e).__name__}"
        ollama_available = False

    elapsed_ms = (time.perf_counter() - start) * 1000

    return ChatResponse(
        response=response_text,
        model=settings.ollama_model,
        ollama_available=ollama_available,
        latency_ms=round(elapsed_ms, 2)
    )
  

Complete RAG Example: Embedding + Retrieval + Generation

A realistic RAG microservice combining everything:


import numpy as np
from typing import List

class DocumentStore:
    """In-memory document store with embeddings."""

    def __init__(self):
        self.docs: List[dict] = []
        self.embeddings: List[np.ndarray] = []

    def add_document(self, doc_id: str, text: str, embedding: np.ndarray):
        """Add document with embedding."""
        self.docs.append({"id": doc_id, "text": text})
        self.embeddings.append(embedding)

    async def search(self, query_embedding: np.ndarray, top_k: int = 3) -> List[dict]:
        """Retrieval: return top-k most similar documents."""
        if not self.embeddings:
            return []

        scores = [
            np.dot(query_embedding, emb) / (np.linalg.norm(query_embedding) * np.linalg.norm(emb) + 1e-8)
            for emb in self.embeddings
        ]

        top_indices = np.argsort(scores)[-top_k:][::-1]
        return [
            {"doc": self.docs[i], "score": float(scores[i])}
            for i in top_indices
            if scores[i] > 0.3  # Only relevant documents
        ]

# Global instance
document_store = DocumentStore()

class RAGRequest(BaseModel):
    query: str = Field(..., max_length=500)
    top_k: int = Field(default=3, ge=1, le=10)
    context_window: int = Field(default=2000, ge=256, le=4096)

class RAGResponse(BaseModel):
    query: str
    retrieved_docs: List[dict]
    generated_response: str
    model: str
    retrieval_latency_ms: float
    generation_latency_ms: float

@app.post("/rag", response_model=RAGResponse)
async def rag_search_and_generate(
    request: RAGRequest,
    embedding_model = Depends(get_embedding_model),
    ollama_client = Depends(get_ollama_client)
):
    """RAG: retrieval + generation."""
    import time

    # 1. Embed query
    retrieval_start = time.perf_counter()
    query_embedding = embedding_model.encode(request.query)

    # 2. Retrieve related documents
    retrieved = await document_store.search(query_embedding, top_k=request.top_k)
    retrieval_ms = (time.perf_counter() - retrieval_start) * 1000

    # 3. Build context
    context = "\n".join([
        f"[Doc {i}] {doc['doc']['text'][:200]}"
        for i, doc in enumerate(retrieved)
    ])

    # 4. Generate response via Ollama
    generation_start = time.perf_counter()
    rag_prompt = f"""
Context:
{context}

Question: {request.query}

Answer:"""

    try:
        generated = await ollama_client.generate(prompt=rag_prompt)
    except Exception as e:
        generated = f"[Generation error]: {str(e)}"

    generation_ms = (time.perf_counter() - generation_start) * 1000

    return RAGResponse(
        query=request.query,
        retrieved_docs=retrieved,
        generated_response=generated,
        model=settings.ollama_model,
        retrieval_latency_ms=round(retrieval_ms, 2),
        generation_latency_ms=round(generation_ms, 2)
    )
  

Async Testing with pytest-asyncio


import pytest
from fastapi.testclient import TestClient

@pytest.mark.asyncio
async def test_health_check():
    """Test health check when models are loaded."""
    async with client as c:
        response = await c.get("/health")
        assert response.status_code == 200
        data = response.json()
        assert data["status"] in ["healthy", "degraded"]
        assert "services" in data

@pytest.mark.asyncio
async def test_embed_validation():
    """Test Pydantic validation on invalid request."""
    async with client as c:
        response = await c.post("/embed", json={"text": ""})
        assert response.status_code == 422  # Unprocessable Entity
        errors = response.json()["detail"]
        assert any("String should have at most" in e.get("msg", "") for e in errors)
  

Performance Optimization

In Federico Calò's production environment (VPS Hetzner CCX13, 4 CPU cores):

  • Embedding latency: 5-8ms (for text < 100 words).
  • RAG retrieval latency: 2-3ms (cosine similarity on 10k docs).
  • Ollama generation latency: 150-300ms (model llama3.1:8b, p99).
  • Throughput: 40+ concurrent requests without stress.

Bottleneck: the LLM model (Ollama) — 85% of total latency. Solution: cache common query generations (Redis or in-memory).

FAQ

Q: Can I use FastAPI with a PostgreSQL database?

A: Yes, via SQLAlchemy async + Alembic. In the portfolio, all tool generators use SQLAlchemy with async connection pooling for fast queries (< 50ms).

Q: How do I handle rate limiting?

A: Use slowapi (FastAPI library) or implement custom middleware with Redis. In the ML microservice: 120 req/min per client IP (via X-Forwarded-For header).

Q: Is Pydantic v2 backward-compatible with v1?

A: No, significant breaking changes. Migration path: Config becomes model_config, parse_obj becomes model_validate. See official docs for complete list.

Q: How do I monitor the microservice in production?

A: Expose Prometheus metrics via prometheus-client: request latency, errors by type, memory usage. In the portfolio: Prometheus scrapes every 30s + Grafana dashboard for visualization.

Q: Do I need to use Docker?

A: Recommended but not required. In the portfolio: Docker Compose for dev, systemd unit for VPS production. Standard Dockerfile: Python 3.12 slim + pip install + CMD uvicorn.

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects