0.1% to 34% in Two Minutes and AI Platform Incident Response Playbook

Everything Was Fine Two Minutes Ago

Your phone lights up at 2:47 PM on a Tuesday. Production error rate just jumped from 0.1% to 34%. No recent releases. No config changes. No deployments. The platform just decided to start failing.

This is not a code bug. Code doesn’t change by itself. Something in the environment shifted. Your job: find what, fix it, and figure out how to detect it before it happens again.

Here’s the exact playbook for investigating a production AI incident when the root cause isn’t your code.

Minute 0-2: Acknowledge and Triage

First thing: don’t start debugging yet. Stabilize the situation.

# incident_response/cli.py — how you acknowledge
import fire  # Google's CLI library that turns functions into CLI commands automatically
import requests  # HTTP library for sending API requests to incident management services
from datetime import datetime  # For timestamping the incident with the exact time of acknowledgment

def acknowledge_incident(severity: str = "SEV1"):
    """Acknowledge the incident in your monitoring system."""
    payload = {
        "title": f"AI Platform error rate spike — {datetime.now().isoformat()}",  # Human-readable title with current timestamp
        "severity": severity,  # Severity level (SEV1 = critical, SEV2 = high, etc.)
        "current_error_rate": "34%",  # The observed error rate at the time of acknowledgment
        "baseline": "0.1%",  # The normal baseline error rate for comparison
    }
    # POST to your incident management API (PagerDuty, Opsgenie, etc.)
    resp = requests.post(
        "https://api.pagerduty.com/incidents",  # PagerDuty's incident creation endpoint
        json=payload,  # Send the payload as JSON in the request body
        headers={"Authorization": "Token YOUR_API_KEY"},  # Authentication header with your PagerDuty API token
    )
    print(f"Incident acknowledged: {resp.status_code}")  # Print the HTTP status code to confirm acknowledgment

if __name__ == "__main__":
    fire.Fire(acknowledge_incident)  # Expose the function as a CLI command (e.g., `python cli.py --severity=SEV1`)

Two actions at this stage:

  1. Block the bad traffic if there’s a pattern — e.g. if errors are coming from a specific tenant or region, rate-limit them before they take down the whole cluster
  2. Roll back the last deployment if there was one — in this case there wasn’t, so skip this
# If you identify a bad tenant, rate-limit at the API gateway
from fastapi import FastAPI, Request, HTTPException  # FastAPI web framework components for building the API and returning HTTP errors
from collections import defaultdict  # Dict subclass that provides default values for missing keys
import time  # For tracking rate-limit reset windows

app = FastAPI()  # Initialize the FastAPI application instance

rate_limits = defaultdict(lambda: {"count": 0, "reset": time.time() + 60})  # Per-tenant rate limit state: count and a 60-second reset window

@app.middleware("http")
async def tenant_rate_limit(request: Request, call_next):
    tenant = request.headers.get("X-Tenant-ID", "unknown")  # Extract the tenant identifier from the request header, defaulting to "unknown"

    # If this tenant is causing errors, block them
    if tenant in BLOCKED_TENANTS:  # BLOCKED_TENANTS is a set populated during the incident investigation
        raise HTTPException(429, "Rate limited — suspected error source")  # Return HTTP 429 Too Many Requests, stopping the request here

    response = await call_next(request)  # Pass the request to the next middleware or route handler
    return response  # Return the response back up the middleware chain to the client

You don’t know the root cause yet. But you can stop the bleeding while you investigate.

Minute 2-5: Check the Obvious Things First

Most sudden error spikes come from four sources. Check them in order.

1. Is the Model Server Up?

curl -s http://inference-service:8001/health | jq  # -s for silent mode (no progress meter); pipe to jq for pretty-printed JSON output
# inference_service/health.py
from fastapi import FastAPI, HTTPException  # FastAPI framework and HTTP exception for failing health checks
import torch  # PyTorch — used here to check CUDA GPU status and memory

app = FastAPI()  # Create the FastAPI application that serves the health endpoint

@app.get("/health")
async def health():
    checks = {
        "model_loaded": model is not None,  # Boolean: is the ML model loaded into memory?
        "gpu_available": torch.cuda.is_available(),  # Boolean: does the server have access to a CUDA-capable GPU?
        "gpu_memory_free": torch.cuda.mem_get_info()[0] if torch.cuda.is_available() else 0,  # Free GPU memory in bytes; 0 if no GPU
        "avg_latency_1m": get_recent_avg_latency(),  # Average inference latency over the last minute (defined elsewhere)
    }

    # Fail health check if GPU memory is critically low
    if checks["gpu_available"] and checks["gpu_memory_free"] < 1_000_000_000:  # Less than ~1GB free means the GPU is nearly full
        raise HTTPException(503, detail="GPU memory exhausted")  # Return HTTP 503 Service Unavailable — orchestrator will restart the pod

    return checks  # Return the full health check dictionary as JSON to the caller (probe, load balancer, etc.)

If the model server is healthy, move on.

2. Is the Upstream API Responding?

If your AI platform calls OpenAI, Anthropic, or any external model API, that upstream might be degraded:

# upstream_check.py
import httpx  # Modern async HTTP client for Python, used here to probe external model APIs
import asyncio  # Async I/O library — lets us check multiple upstream providers concurrently
from datetime import datetime  # For measuring request latency with timestamps

async def check_upstream(provider: str, api_key: str) -> dict:
    start = datetime.now()  # Record the start time for latency measurement
    try:
        async with httpx.AsyncClient(timeout=10) as client:  # Create an async HTTP client with a 10-second timeout per request
            if provider == "openai":
                resp = await client.post(
                    "https://api.openai.com/v1/chat/completions",  # OpenAI's chat completions endpoint
                    headers={"Authorization": f"Bearer {api_key}"},  # Bearer token auth using the provided API key
                    json={
                        "model": "gpt-4o-mini",  # Use a cheap, fast model for the health probe
                        "messages": [{"role": "user", "content": "ping"}],  # Minimal message to test the API
                        "max_tokens": 1,  # Request only 1 token to minimize cost and latency
                    },
                )
            elif provider == "anthropic":
                resp = await client.post(
                    "https://api.anthropic.com/v1/messages",  # Anthropic's messages API endpoint
                    headers={"x-api-key": api_key, "anthropic-version": "2023-06-01"},  # API key auth + required version header
                    json={
                        "model": "claude-3-haiku",  # Anthropic's fastest/cheapest model for the probe
                        "messages": [{"role": "user", "content": "ping"}],  # Minimal probe message
                        "max_tokens": 1,  # Single-token response is enough to confirm the API works
                    },
                )
            elapsed = (datetime.now() - start).total_seconds()  # Calculate total round-trip time in seconds
            return {
                "provider": provider,  # Which provider was probed
                "status_code": resp.status_code,  # HTTP status code (200 = OK, 5xx = degraded)
                "latency": elapsed,  # How long the request took
                "healthy": resp.status_code == 200 and elapsed < 5,  # Healthy only if status is 200 AND latency under 5 seconds
            }
    except Exception as e:  # Catches timeouts, connection errors, DNS failures, etc.
        return {"provider": provider, "error": str(e), "healthy": False}  # Report the error and mark as unhealthy

async def check_all_upstreams():
    results = await asyncio.gather(  # Run both health checks concurrently
        check_upstream("openai", os.getenv("OPENAI_API_KEY")),  # Check OpenAI with key from environment variable
        check_upstream("anthropic", os.getenv("ANTHROPIC_API_KEY")),  # Check Anthropic with key from environment variable
    )
    unhealthy = [r for r in results if not r["healthy"]]  # Filter to only failed checks
    if unhealthy:
        # Upstream is partially down — this is likely the root cause
        log_incident("Upstream API degraded", unhealthy)  # Log the upstream failure as part of the incident timeline

If the upstream API is returning 5xx or timing out, that’s your culprit. You can’t fix it, but you can fail over to a different provider or a cached fallback:

# inference_service/router.py
from fastapi import FastAPI, HTTPException  # FastAPI framework plus HTTP exception for the fallback error
import httpx  # Async HTTP client for calling upstream model APIs
import random  # Used to shuffle provider order for basic load distribution

app = FastAPI()  # Initialize the FastAPI application

PROVIDERS = [
    {"name": "openai", "url": "https://api.openai.com/v1/chat/completions", "key": os.getenv("OPENAI_API_KEY")},  # OpenAI provider config with key from env
    {"name": "anthropic", "url": "https://api.anthropic.com/v1/messages", "key": os.getenv("ANTHROPIC_API_KEY")},  # Anthropic provider config with key from env
]

@app.post("/v1/completions")
async def completions(prompt: str):
    # Shuffle providers for load balancing and failover
    random.shuffle(PROVIDERS)  # Randomize provider order so traffic isn't always sent to the same provider first

    for provider in PROVIDERS:  # Try each provider in shuffled order until one succeeds
        try:
            async with httpx.AsyncClient(timeout=30) as client:  # 30-second timeout per provider attempt
                if provider["name"] == "openai":
                    resp = await client.post(
                        provider["url"],  # Use the provider's URL from the config list
                        headers={"Authorization": f"Bearer {provider['key']}"},  # Bearer token authentication
                        json={
                            "model": "gpt-4o-mini",  # Fast, cost-effective model for inference
                            "messages": [{"role": "user", "content": prompt}],  # Wrap the user's prompt in the chat format
                        },
                    )
                elif provider["name"] == "anthropic":
                    resp = await client.post(
                        provider["url"],  # Use the provider's URL from the config list
                        headers={"x-api-key": provider["key"], "anthropic-version": "2023-06-01"},  # Anthropic uses x-api-key header + version header
                        json={
                            "model": "claude-3-haiku",  # Anthropic's fastest model for inference
                            "messages": [{"role": "user", "content": prompt}],  # Wrap the user's prompt in the chat format
                        },
                    )
                resp.raise_for_status()  # Raise an exception for 4xx/5xx status codes — same as catching an HTTP error
                return resp.json()  # Return the successful provider's JSON response to the client
        except Exception as e:
            logger.warning(f"Provider {provider['name']} failed: {e}")  # Log the failure for observability
            continue  # Try the next provider in the list

    raise HTTPException(503, "All upstream providers failed")  # All providers failed — return 503 Service Unavailable

3. Is There a Data Drift?

If the model server and upstream are fine, maybe the input data changed. Your model was trained on data within a certain distribution. If users started sending different kinds of inputs, the model might fail silently:

# monitoring/drift_detector.py
import numpy as np  # NumPy — used here for numerical operations like z-score calculation
from fastapi import FastAPI, Request  # FastAPI framework components for route handling and raw request access
from pydantic import BaseModel  # Pydantic for request/response schema validation and serialization
import time  # Time utilities for potential rate calculations (available but not used in this snippet)

app = FastAPI()  # Initialize the FastAPI application

# Reference distribution from training data
REFERENCE = {
    "text_length_mean": 120,  # Average token/word count in the training dataset
    "text_length_std": 80,  # Standard deviation of token/word counts in the training dataset
}

class PredictionResponse(BaseModel):
    prediction: int  # The model's predicted class label (e.g., 0 = negative, 1 = positive)
    confidence: float  # The model's confidence score for this prediction (0.0 to 1.0)
    drift_warning: str | None = None  # Optional warning if the input distribution has drifted from training

@app.post("/predict", response_model=PredictionResponse)
async def predict(request: Request):
    data = await request.json()  # Parse the raw JSON body from the incoming request
    text = data["text"]  # Extract the text field from the request payload
    text_len = len(text.split())  # Count the number of whitespace-separated words in the input

    # Detect drift in input distribution
    z_score = abs(text_len - REFERENCE["text_length_mean"]) / REFERENCE["text_length_std"]  # Calculate how many standard deviations from the training mean
    drift_warning = None  # Initialize drift warning as None — will be set only if drift is detected
    if z_score > 3:  # A z-score > 3 means the input is more than 3 standard deviations from the mean (statistically significant anomaly)
        drift_warning = f"Input length ({text_len}) is {z_score:.1f} std from training mean"  # Human-readable drift alert

    # Run inference
    result = model.predict([text])[0]  # Run the ML model's prediction and take the first (only) result
    confidence = max(model.predict_proba([text])[0])  # Get the highest probability across all classes as the confidence score

    return PredictionResponse(
        prediction=result,  # The model's predicted class
        confidence=float(confidence),  # Confidence score cast to Python float for JSON serialization
        drift_warning=drift_warning,  # Drift warning (None if input was within the expected distribution)
    )

Drift doesn’t usually spike from 0.1% to 34% in two minutes unless something external changed—a new client integration, a scraper hitting your API with garbage data, or a bug in the upstream data pipeline.

4. Is There a Resource Exhaustion?

Most common cause of sudden error spikes: something ran out. GPU memory, CPU, connections, file descriptors, disk space.

# monitoring/resource_monitor.py
import psutil  # Cross-platform system/process monitoring library (CPU, memory, disk, network)
import torch  # PyTorch — used here to query CUDA GPU memory stats
from fastapi import FastAPI  # FastAPI framework for the debug endpoint

app = FastAPI()  # Initialize the FastAPI application

@app.get("/debug/resource-check")
async def resource_check():
    gpu_ok = True  # Default to healthy — will be set to False if GPU memory is nearly full
    gpu_detail = "no GPU"  # Default message for systems without a CUDA-capable GPU

    if torch.cuda.is_available():  # Check if a CUDA GPU is present
        free, total = torch.cuda.mem_get_info()  # Get free and total GPU memory in bytes
        used_percent = (total - free) / total * 100  # Calculate GPU memory utilization as a percentage
        gpu_ok = used_percent < 95  # Mark as unhealthy if GPU memory usage exceeds 95%
        gpu_detail = f"{used_percent:.1f}% used ({free/1e9:.1f}GB free / {total/1e9:.1f}GB total)"  # Human-readable GPU memory report

    cpu_percent = psutil.cpu_percent(interval=1)  # CPU utilization over a 1-second sampling interval
    memory = psutil.virtual_memory()  # System RAM stats (total, available, percent used, etc.)
    disk = psutil.disk_usage("/")  # Disk usage for the root filesystem (total, used, free, percent)

    return {
        "gpu": gpu_detail,  # GPU memory details or "no GPU"
        "gpu_ok": gpu_ok,  # Boolean: is GPU memory utilization below the 95% threshold?
        "cpu_percent": cpu_percent,  # CPU utilization percentage
        "memory_percent": memory.percent,  # System RAM utilization percentage
        "disk_percent": disk.percent,  # Disk utilization percentage
        "connections": len(psutil.net_connections()),  # Number of active network connections (indicator of connection leaks)
    }

If GPU memory is at 99%, model inference starts throwing CUDA out-of-memory errors. The model fails for every request until memory is freed or the pod restarts.

# inference_service/app.py — GPU memory guard
from contextlib import asynccontextmanager  # Decorator for creating async context managers (used for app lifespan)

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup: register a metric for GPU memory
    yield  # The application runs here — startup code before yield, shutdown code after
    # Shutdown: clean up CUDA cache
    if torch.cuda.is_available():  # Only attempt GPU cleanup if a CUDA device exists
        torch.cuda.empty_cache()  # Free all cached CUDA memory that PyTorch is holding onto

app = FastAPI(lifespan=lifespan)  # Create the FastAPI app with the lifespan handler for startup/shutdown hooks

@app.middleware("http")
async def check_gpu_memory(request: Request, call_next):
    if torch.cuda.is_available():  # Only check GPU if a CUDA device is present
        free, total = torch.cuda.mem_get_info()  # Query current free and total GPU memory
        if free < 500_000_000:  # Less than 500MB free — critical threshold for OOM risk
            torch.cuda.empty_cache()  # Force-release cached GPU memory allocations
            logger.warning("GPU memory critically low — cache emptied")  # Log the event for incident timeline

    response = await call_next(request)  # Pass the request to the next middleware or route handler
    return response  # Return the response back up the chain

If your inference server runs out of GPU memory, it’s because the previous requests didn’t free their tensors. Add explicit cleanup to your inference code:

# inference_service/inference.py
import torch  # PyTorch — used for model inference and GPU tensor management
import gc  # Python garbage collector — ensures Python-side objects are cleaned up after inference

def run_inference(text: str) -> dict:
    try:
        inputs = tokenizer(text, return_tensors="pt", truncation=True).to("cuda")  # Tokenize input, return PyTorch tensors, truncate to max length, move to GPU
        with torch.no_grad():  # Disable gradient computation — not needed for inference, saves memory and speeds up execution
            outputs = model(**inputs)  # Run the model forward pass with the tokenized inputs

        result = {
            "prediction": outputs.logits.argmax().item(),  # Get the class with the highest logit value (the model's predicted class)
            "confidence": outputs.logits.softmax(-1).max().item(),  # Apply softmax to convert logits to probabilities, then take the max as confidence
        }
        return result  # Return the prediction result to the caller
    finally:
        # Always clean up GPU tensors, even on error
        del inputs, outputs  # Delete tensor references explicitly so they can be garbage collected
        torch.cuda.empty_cache()  # Clear PyTorch's CUDA memory cache to free GPU memory
        gc.collect()  # Force Python garbage collection to release any remaining references

Minute 5-10: Correlate the Metrics

Check your observability dashboard for correlated spikes. If errors spiked at 14:47, what else spiked at 14:47?

# analytics/correlation_check.py
from datetime import datetime, timedelta  # For computing the time window around the incident
import httpx  # Async HTTP client for querying the Prometheus API

# Query Prometheus for correlated metrics
async def query_correlated_metrics(error_timestamp: datetime, window_minutes: int = 5):
    start = error_timestamp - timedelta(minutes=window_minutes)  # Start of the query window: 5 minutes before the error spike
    end = error_timestamp + timedelta(minutes=window_minutes)  # End of the query window: 5 minutes after the error spike

    queries = [
        'rate(http_requests_total{status=~"5.."}[1m])',  # Rate of HTTP 5xx errors per second (1m rate)
        'rate(node_cpu_seconds_total[1m])',  # CPU utilization rate — spiking CPU could indicate a thundering herd
        'node_memory_MemAvailable_bytes',  # Available system memory in bytes — low memory can cause OOM kills
        'rate(nvidia_smi_memory_used_bytes[1m])',  # GPU memory usage rate — GPU OOM is the most common AI incident cause
        'rate(container_oom_events_total[1m])',  # Container out-of-memory kill events — OOMKilled pods restart
    ]

    async with httpx.AsyncClient() as client:  # Create an async HTTP client for Prometheus queries
        results = {}
        for query in queries:  # Query each metric independently
            resp = await client.get(
                "http://prometheus:9090/api/v1/query_range",  # Prometheus range query endpoint
                params={
                    "query": query,  # The PromQL query string
                    "start": start.timestamp(),  # Start of the range as a Unix timestamp
                    "end": end.timestamp(),  # End of the range as a Unix timestamp
                    "step": "15",  # 15-second resolution for the time series data
                },
            )
            results[query] = resp.json()  # Parse the JSON response and store by query name
        return results  # Return all queried metrics for correlation analysis

Common patterns:

  • Error spike + GPU memory spike = OOM in model server
  • Error spike + CPU spike = thundering herd (too many concurrent requests)
  • Error spike + network latency spike = upstream degradation
  • Error spike alone = data drift or authentication issue

Minute 5-15: Real User Monitoring

Check if the errors affect all users or a specific subset:

# analytics/error_analysis.py
from fastapi import FastAPI, Request  # FastAPI framework components for the middleware
import logging  # Python's built-in logging module for structured error logging

app = FastAPI()  # Initialize the FastAPI application
error_logger = logging.getLogger("error_analysis")  # Create a named logger for error analysis — can be routed to a separate file or sink

@app.middleware("http")
async def capture_errors(request: Request, call_next):
    response = await call_next(request)  # Let the request proceed through all handlers and collect the response
    if response.status_code >= 500:  # Only log server-side errors (5xx), not client errors (4xx)
        error_logger.error("server_error", extra={  # Log at ERROR level with structured context in the "extra" dict
            "path": request.url.path,  # Which API endpoint produced the error (e.g., /v1/completions)
            "tenant": request.headers.get("X-Tenant-ID", "unknown"),  # Which tenant's request failed — helps identify noisy neighbors
            "user_agent": request.headers.get("User-Agent", "unknown"),  # Client identifier — could reveal a scraper or misconfigured client
            "method": request.method,  # HTTP method (GET, POST, etc.)
            "query_params": str(request.query_params),  # Full query string — useful for reproducing the failing request
            "status_code": response.status_code,  # The exact 5xx status code (500, 502, 503, etc.)
        })
    return response  # Return the response to the client unchanged

If the errors are from a single tenant, they might have a buggy integration or a misconfigured client. If they’re from a specific user-agent, it could be a scraper or automated tool. If they’re spread evenly, it’s an infrastructure issue.

Minute 10-30: The Investigation Loop

# incident_response/investigate.py
from datetime import datetime, timedelta  # For time arithmetic around the incident window
import httpx  # HTTP client for querying external APIs (GitHub, MLflow, etc.)
import json  # For serializing findings to a JSON string for output

class IncidentInvestigator:
    def __init__(self, incident_time: datetime):
        self.incident_time = incident_time  # Store the exact time the incident started (e.g., 14:47)
        self.findings = []  # Accumulate evidence as the investigation progresses

    def add_finding(self, source: str, evidence: str, confidence: float):
        self.findings.append({
            "source": source,  # Where the evidence came from (e.g., "GitHub API", "MLflow")
            "evidence": evidence,  # Human-readable description of what was found
            "confidence": confidence,  # How confident we are this is related to the incident (0.0 to 1.0)
            "timestamp": datetime.now().isoformat(),  # When this finding was recorded
        })

    def check_recent_deployments(self):
        """Check if there were any deployments around the incident time."""
        resp = httpx.get(
            "https://api.github.com/repos/org/platform/deployments",  # GitHub API endpoint for deployment history
            params={"since": (self.incident_time - timedelta(hours=2)).isoformat()},  # Look back 2 hours from the incident
        )
        deployments = resp.json()  # Parse the JSON response into a list of deployments
        if deployments:  # If there are any deployments in the window
            self.add_finding(
                "GitHub API",
                f"Found deployment at {deployments[0]['created_at']}",  # Record the most recent deployment time
                0.3,  # Low confidence — deployments don't always cause incidents
            )
        return len(deployments) == 0  # Return True if no deployments were found (eliminating this as a cause)

    def check_database_migrations(self):
        """Check if any migrations ran around the incident time."""
        # Query your migration tracking table
        pass  # Placeholder — implement by querying your migration history database

    def check_model_registry(self):
        """Check if model version was updated."""
        resp = httpx.get("http://mlflow:5000/api/2.0/mlflow/registered-models/alias")  # MLflow API to get the current model version for the production alias
        model = resp.json()  # Parse the response to get model metadata
        self.add_finding(
            "MLflow",
            f"Current model version: {model.get('version', 'unknown')}",  # Record which model version is currently deployed
            0.5,  # Medium confidence — a model change is a plausible root cause
        )
        return model  # Return the full model metadata for further analysis

    def summarize(self) -> str:
        return json.dumps(self.findings, indent=2)  # Pretty-print all findings as a JSON string for the incident log

# Usage
investigator = IncidentInvestigator(datetime(2026, 7, 22, 14, 47))  # Create investigator for the specific incident timestamp
investigator.check_recent_deployments()  # Check GitHub for recent deployments — returns False if any found
investigator.check_model_registry()  # Check MLflow for model version changes
print(investigator.summarize())  # Print all findings as a formatted JSON report

Minute 30-60: Mitigation

Once you’ve found the root cause, you need to fix it. Here are the common fixes:

Fix 1: GPU OOM — restart and add memory limits

# kubernetes/deployment.yaml — add resource limits
apiVersion: apps/v1  # Kubernetes API version for the Deployment resource
kind: Deployment  # Resource type: a Deployment manages a replicated set of pods
metadata:
  name: inference-service  # Name of the deployment, used for labels and service selection
spec:
  template:
    spec:
      containers:
        - name: model  # Container name within the pod
          image: inference-service:latest  # Docker image to run (the model server)
          resources:
            requests:
              memory: "8Gi"  # Guaranteed minimum memory reservation — Kubernetes uses this for scheduling
              nvidia.com/gpu: 1  # Request 1 GPU — pod will only schedule on nodes with a free GPU
            limits:
              memory: "12Gi"  # Hard memory limit — pod will be OOMKilled if it exceeds this
              nvidia.com/gpu: 1  # Maximum GPU count — prevents the container from using more than 1 GPU
          env:
            - name: MAX_BATCH_SIZE  # Environment variable controlling how many requests are processed concurrently
              value: "8"  # Limit concurrent requests to 8 to prevent GPU OOM from excessive parallelism
            - name: TORCH_CUDA_ALLOC_CONF  # PyTorch CUDA memory allocator configuration
              value: "max_split_size_mb:128"  # Prevents memory fragmentation by capping allocation split size at 128MB

Fix 2: Upstream degraded — failover

# inference_service/failover.py
class UpstreamRouter:
    def __init__(self):
        self.providers = [
            {"name": "primary", "url": "...", "priority": 1, "errors": 0},  # Primary provider: highest priority (1), zero errors initially
            {"name": "secondary", "url": "...", "priority": 2, "errors": 0},  # Secondary provider: lower priority (2), fallback
        ]
        self.error_threshold = 5  # Consecutive errors before a provider is deprioritized and considered degraded

    async def route(self, prompt: str) -> dict:
        for provider in sorted(self.providers, key=lambda p: p["priority"]):  # Try providers in priority order (lowest number first)
            try:
                result = await self._call(provider, prompt)  # Attempt to call the provider's API with the user's prompt
                provider["errors"] = 0  # Reset error count on success — provider is healthy again
                return result  # Return the successful result immediately
            except Exception:  # Any exception (timeout, connection error, 5xx response)
                provider["errors"] += 1  # Increment the consecutive error counter for this provider
                if provider["errors"] >= self.error_threshold:  # If error count exceeds the threshold (5)
                    provider["priority"] = 99  # Set priority to 99 (very low) — effectively demote this provider to last resort

        raise RuntimeError("All providers failed")  # Every provider has been exhausted — raise a fatal error

Fix 3: Data drift — input validation and rejection

# inference_service/input_validator.py
from pydantic import BaseModel, Field, field_validator  # Pydantic for request schema, field constraints, and custom validators
import re  # Regular expressions (imported for potential regex validation, though not used in this snippet)

class CompletionRequest(BaseModel):
    text: str = Field(..., min_length=1, max_length=8192)  # Required field: text must be between 1 and 8192 characters

    @field_validator("text")
    @classmethod
    def validate_text(cls, v: str):
        # Reject binary garbage
        if not v.isprintable():  # Check if every character in the string is printable (no null bytes, control chars, etc.)
            raise ValueError("Text contains non-printable characters")  # Reject with a clear validation error message

        # Reject extremely long words (likely garbage)
        for word in v.split():  # Split the text into whitespace-separated words
            if len(word) > 200:  # If any single word exceeds 200 characters, it's probably garbage data
                raise ValueError(f"Word too long: {len(word)} chars")  # Reject with the offending word length

        # Reject if mostly numbers (likely a scraper)
        digit_ratio = sum(c.isdigit() for c in v) / len(v)  # Calculate the proportion of digit characters in the text
        if digit_ratio > 0.8:  # If more than 80% of characters are digits, it's likely a scraper or automated tool
            raise ValueError("Text is mostly digits — possible scraper")  # Reject with a descriptive error

        return v  # Return the validated text (Pydantic will store it as-is after validation)

@app.post("/v1/completions")
async def completions(request: CompletionRequest):
    # If the request passes validation, run inference
    # If it fails, the client gets a 422 with a clear error
    result = run_inference(request.text)  # Pass the validated text to the inference function
    return result  # Return the inference result as the HTTP response

After the Incident: The Postmortem

# postmortem/template.py
from datetime import datetime  # For timestamping the postmortem document with the current date
import json  # For serializing the postmortem sections into a structured JSON output

class Postmortem:
    def __init__(self, incident_id: str):
        self.incident_id = incident_id  # Unique incident identifier (e.g., "INC-2026-07-22")
        self.sections = {
            "title": f"AI Platform Error Spike — {incident_id}",  # Document title with incident ID for traceability
            "date": datetime.now().isoformat(),  # ISO-formatted timestamp of when the postmortem was created
            "duration_minutes": None,  # Total incident duration — populated after the incident is resolved
            "impact": None,  # Description of user/business impact — populated during analysis
            "root_cause": None,  # The identified root cause — filled in after investigation concludes
            "timeline": [],  # Chronological list of key events during the incident
            "action_items": [],  # Remediation tasks assigned to teams after the incident
        }

    def add_timeline_entry(self, time: str, event: str, action: str = ""):
        self.sections["timeline"].append({
            "time": time,  # Timestamp of the event (e.g., "14:47")
            "event": event,  # Description of what happened
            "action": action,  # What action was taken in response
        })

    def add_action_item(self, description: str, owner: str, priority: str):
        self.sections["action_items"].append({
            "description": description,  # What needs to be done to prevent recurrence
            "owner": owner,  # Team responsible for implementing the fix
            "priority": priority,  # Priority level: P0 = critical, P1 = high, P2 = medium, etc.
        })

    def export(self) -> str:
        return json.dumps(self.sections, indent=2)  # Pretty-print the full postmortem as a JSON string

postmortem = Postmortem("INC-2026-07-22")  # Create a new postmortem for this specific incident
postmortem.add_timeline_entry("14:47", "Error rate spikes to 34%", "Page sent")  # Alert fired, on-call paged
postmortem.add_timeline_entry("14:49", "GPU memory exhaustion identified", "Restarted pod")  # Root cause found, immediate mitigation applied
postmortem.add_timeline_entry("14:52", "Error rate returns to 0.1%", "Monitoring")  # Error rate normalized after pod restart

postmortem.add_action_item(
    "Add GPU memory alert at 80% utilization",  # Proactive alerting to catch GPU pressure before it becomes critical
    "infra-team",  # Infrastructure team owns monitoring and alerting configuration
    "P0",  # P0 = must fix immediately to prevent the same incident
)
postmortem.add_action_item(
    "Add automatic GPU cache clearing middleware",  # Software-level defense against GPU memory leaks
    "ml-team",  # ML team owns the inference service code
    "P1",  # P1 = high priority but can be scheduled after the immediate fix
)
postmortem.add_action_item(
    "Update max_batch_size to prevent OOM",  # Configuration change to limit concurrent GPU memory usage
    "ml-team",  # ML team owns the model serving configuration
    "P1",  # P1 = important but not blocking production
)

print(postmortem.export())  # Print the full postmortem document as JSON for sharing and archiving

The Timeline of a Real Incident

14:47:28 — Error rate jumps 0.1% → 34%. Alert fires.
14:47:45 — On-call acknowledges. No deploy, no config change.
14:48:10 — Health check: model server responds, upstream is fine.
14:48:30 — Resource check: GPU memory 99.8% utilized. Found it.
14:49:00 — Restart inference pod. GPU memory frees.
14:49:15 — Error rate starts dropping.
14:50:30 — Error rate back to 0.1%. All clear.
14:52:00 — Root cause: a batch processing job ran inference in parallel, consuming all GPU memory. Previous requests failed with OOM until the pod restarted.
14:55:00 — Add GPU memory alert at 80%. Add max_concurrency limit. Document in postmortem.

Total time from alert to resolution: ~3 minutes.
Total time from resolution to prevention: ~5 minutes more.

That’s the difference between “everything is on fire” and “we found it, fixed it, and made sure it doesn’t happen again.”

Summary

When errors spike with no code change, check in order:

  1. Is the model server healthy? Maybe it crashed or OOM’d.
  2. Is the upstream API healthy? If you call OpenAI/Anthropic, they might be down.
  3. Is there resource exhaustion? GPU memory, CPU, disk — something ran out.
  4. Is there data drift? Input distribution shifted, model returns garbage.
  5. Is it a specific user segment? One tenant, one user-agent, one region?

Most of the time it’s #3 (GPU OOM) or #2 (upstream degraded). The fix for both is the same: detect fast, fail over gracefully, add protections so it can’t happen again.

The real skill isn’t fixing the problem — it’s knowing where to look first so you’re not still debugging 20 minutes later.

Comments

Leave a comment

What is 2 + 3?
Please solve the arithmetic question above to verify you're human.