Turning Incidents into Guardrails
Incidents without guardrails repeat. Guardrails are alerts, tests, lint rules, and checklists that make the same failure harder or visible earlier. Python teams encode them in pytest, Ruff, Alembic review, and SLO burn alerts.
Recipe
Quick-reference recipe card - copy-paste ready.
# pytest regression from post-mortem INC-8842
def test_migration_uses_concurrently_for_large_tables():
sql = Path("alembic/versions/20260612_add_invoice_idx.py").read_text()
assert "CONCURRENTLY" in sql or "op.create_index" not in sql# Alert: Celery queue depth leading indicator
- alert: BillingQueueDepthHigh
expr: celery_queue_length{queue="billing"} > 10000
for: 5mWhen to reach for this:
- Post-mortem action items need concrete delivery
- Same incident class occurred twice in a quarter
- Audit asks for preventive controls
- On-call fatigue from noisy but preventable pages
Working Example
"""guardrails_example.py - incident-driven tests and runtime checks."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Guardrail:
incident_id: str
kind: str # test | alert | lint | runbook
description: str
verify: str
GUARDRAILS: list[Guardrail] = [
Guardrail(
incident_id="INC-8842",
kind="test",
description="Large-table indexes use CONCURRENTLY",
verify="pytest tests/test_migrations.py -k concurrently",
),
Guardrail(
incident_id="INC-9011",
kind="alert",
description="Checkout p95 burn rate",
verify="Grafana: CheckoutSLOBurn",
),
Guardrail(
incident_id="INC-9011",
kind="lint",
description="No sync sleep in async routes",
verify="scripts/check_async_blocking.py in CI",
),
]
def audit_open_guardrails(registry: list[Guardrail]) -> None:
for g in registry:
print(f"[{g.incident_id}] {g.kind}: {g.description} -> {g.verify}")
if __name__ == "__main__":
audit_open_guardrails(GUARDRAILS)# tests/test_incident_9011_loop_block.py
import ast
from pathlib import Path
def test_no_time_sleep_in_async_functions():
src = Path("src/api/routes/checkout.py").read_text()
tree = ast.parse(src)
for node in ast.walk(tree):
if isinstance(node, ast.AsyncFunctionDef):
for child in ast.walk(node):
if isinstance(child, ast.Call):
func = child.func
if isinstance(func, ast.Attribute) and func.attr == "sleep":
raise AssertionError(f"time.sleep in async {node.name}")What this demonstrates:
- Guardrails link to incident IDs for traceability
- Regression tests encode specific failure mechanics
- CI scripts catch async blocking patterns
- Ops review audits verification commands, not just ticket closure
Deep Dive
How It Works
- Leading indicators - Queue depth and pool wait predict 5xx before customers notice.
- Regression tests - Cheap insurance; run on every PR touching related paths.
- Lint/CI policy - Encode review checklist items machines enforce.
- Runbook updates - Human guardrails when automation is impractical.
- Metrics on guardrails - Track mean time to implement action items after SEV1.
Guardrail Types
| Type | Example | Verifies |
|---|---|---|
| Unit/integration test | Replay bad migration SQL | CI green |
| Contract test | API/worker pydantic schema | Breaking change blocked |
| Alert | SLO burn 2x | Pager fires in staging test |
| Ruff custom rule | Ban lru_cache(maxsize=None) | PR fails |
| Game day | Rollback under 10m | RTO measured |
Python Notes
# pyproject.toml - pip-audit in CI
[tool.uv]
dev-dependencies = ["pip-audit>=2.7"]
# scripts/ci.sh
# uv run pip-audit -r requirements.lockGotchas
- Action items that say "be more careful" - Not guardrails. Fix: Require test, alert, or automated check per item.
- Alerts without runbook link - On-call ignores noise. Fix: Annotation points to mitigation steps.
- Tests that mock away the bug - Regression never fails. Fix: Test at integration boundary that failed in prod.
- Guardrails in one service only - Sister team repeats incident. Fix: Platform promotes pattern to template repo.
- Closing tickets without verification - Drift returns. Fix: Monthly ops review runs
verifycommand from registry.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Manual checklist | Low-frequency human process | Repeatable technical failure |
| Architecture change | Fundamental design flaw | Single missing alert |
| Remove feature | Risk exceeds value | Core revenue path |
| Chaos engineering | Validate guardrail under stress | Before basic monitoring exists |
FAQs
How many guardrails per incident?
Prefer 1-3 high-leverage items. Too many dilutes ownership.
Who owns guardrail delivery?
Service owner implements; platform owns shared templates and CI hooks.
Should guardrails block deploy?
Yes for migrations and security. Warnings-only for first iteration, then promote to hard fail.
How to guardrail async issues?
Static AST check plus pytest timing test under concurrent load in CI.
What about third-party outages?
Circuit breaker tests and fallback behavior regression; status-page comms runbook.
Do we need a guardrail registry?
Helpful at scale - spreadsheet or YAML listing incident ID, verify command, owner.
How prevent alert fatigue?
Tune thresholds from incident data; page on SLO burn, not single blips.
Can LLM apps have guardrails?
Yes - eval suites for prompt regressions, token budget alerts, refusal policy tests.
When to delete a guardrail?
When code path removed or superseded by platform control. Document in changelog.
How measure success?
Repeat incident rate, MTTR trend, and action item age < 30 days.
Related
- Blameless Post-Mortems & RCA - source action items
- On-Call Health - sustainable alerting
- Common Production Failure Modes - what to prevent
- pytest Testing - regression test home
- Linting & Formatting - CI policy
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.