Routing Smoke Tests Through a Python Health Gateway

This page implements one concrete task inside smoke-test routing logic: an asynchronous Python health gateway that fronts a freshly promoted restore sandbox, runs a battery of read-only smoke checks concurrently over aiohttp, and collapses them into a single readiness verdict that gates whether traffic is routed at all. The gateway sits between the sandbox produced by sandbox provisioning and the point where a fallback chain configuration would promote a tier, so a sandbox that is up but not actually correct never receives real requests. Every probe is tagged as synthetic so it is excluded from production observability, each failing check is mapped into a status consumed by downstream error categorization frameworks, and the aggregated verdict — with its per-check latencies — is emitted for validation telemetry and metrics so a readiness decision is auditable after the drill closes.

Architecture and Execution Model

Health-gateway readiness flow for a promoted DR sandbox A vertical flow of five stages: receive the readiness probe, run read-only smoke checks concurrently, aggregate the check results, compute a single readiness verdict, and gate routing by returning a POSIX exit code. Receive readiness probe Run read-only smoke checks Aggregate check results Compute readiness verdict Gate routing / exit code

Figure. The gateway receives a readiness probe, fans out concurrent read-only checks, aggregates them, computes one verdict, and returns the exit code the drill runner uses to open or hold routing.

The gateway is deliberately a gate, not a proxy: it does not carry production traffic, it decides whether production traffic should be carried. It fans out four independent checks with asyncio.gather so the total wall-clock cost is bounded by the slowest probe rather than their sum, then applies a strict all-must-pass rule. A single failed check is enough to withhold routing, because a promoted sandbox that answers on the network but has a truncated table, a lagging replica, or a query that has regressed past its latency budget is worse than one that is plainly down — it can pass a shallow liveness check and then serve wrong answers.

Prerequisites

  • Python 3.8+ (the dataclasses, IntEnum, and typing usage is stable from 3.8; asyncio.run requires 3.7+).

  • The async HTTP client, installed into the automation environment:

    bash
    pip install aiohttp
    
  • A promoted sandbox exposing a read-only data-access API — the endpoints the checks call (/schema, /metrics/row-counts, /query/critical, /replication/status) must be served by the restored service or a thin read-only sidecar in front of it. None of them mutate state.

  • Network reachability from the gateway to the sandbox only. Run the gateway from inside the drill’s isolation boundary so its probes cannot leak onto a production data plane, and so the sandbox admits it through the single ingress the provisioning stage opened.

  • An observability pipeline that honors the synthetic markers. The gateway sets X-Synthetic-Probe and a drill-traffic header on every request; production dashboards and SLO windows must be configured to drop requests carrying them so a drill never distorts real error-rate or latency metrics.

Production Implementation

The gateway derives its configuration from the command line, opens one shared aiohttp session, and runs the four checks concurrently. Each check is a self-contained coroutine that returns a CheckResult rather than raising, so one probe’s transport error cannot mask another’s verdict; the aggregation step then reduces the four results to a single exit code. Every request carries the synthetic headers so it is filtered out of production telemetry.

python
#!/usr/bin/env python3
"""Async readiness gateway for a freshly promoted DR sandbox.

Runs a battery of read-only smoke checks against the sandbox over aiohttp,
tagging every request as synthetic so it never contaminates production
observability, aggregates the checks into a single readiness verdict, and exits
with the code the drill runner branches on to gate traffic routing.

Checks: schema reachable, row-count sanity, critical-query latency, replication
caught up. Any failing check holds routing and quarantines the sandbox.

Exit codes:
    0  READY  -> all checks passed; route real traffic to the sandbox
    1  HOLD   -> at least one check failed; hold routing and quarantine
    2  ABORT  -> usage / configuration / transport error, no verdict formed
"""
import argparse
import asyncio
import sys
import time
from dataclasses import dataclass
from enum import IntEnum
from typing import List

import aiohttp

# Every probe carries these headers so downstream observability drops it from
# production SLO windows instead of counting synthetic load as real traffic.
SYNTHETIC_HEADERS = {
    "X-Synthetic-Probe": "true",
    "X-Drill-Traffic": "readiness-gateway",
    "User-Agent": "dr-health-gateway/1.0 (synthetic; read-only)",
}


class Verdict(IntEnum):
    READY = 0
    HOLD = 1
    ABORT = 2


@dataclass
class CheckResult:
    name: str
    ok: bool
    detail: str
    latency_ms: float


@dataclass
class GatewayConfig:
    base_url: str
    expected_tables: List[str]
    min_rows: int
    max_query_ms: float
    max_replication_lag_s: float
    request_timeout_s: float


def _ms(start: float) -> float:
    return (time.perf_counter() - start) * 1000.0


async def _get_json(session: aiohttp.ClientSession, url: str, timeout: float) -> dict:
    async with session.get(
        url, headers=SYNTHETIC_HEADERS, timeout=aiohttp.ClientTimeout(total=timeout)
    ) as resp:
        resp.raise_for_status()
        return await resp.json()


async def check_schema(session: aiohttp.ClientSession, cfg: GatewayConfig) -> CheckResult:
    start = time.perf_counter()
    try:
        data = await _get_json(session, f"{cfg.base_url}/schema", cfg.request_timeout_s)
    except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
        return CheckResult("schema_reachable", False, f"schema endpoint error: {exc}", _ms(start))
    present = set(data.get("tables", []))
    missing = [t for t in cfg.expected_tables if t not in present]
    if missing:
        return CheckResult("schema_reachable", False, f"missing tables: {missing}", _ms(start))
    return CheckResult("schema_reachable", True, f"{len(present)} tables present", _ms(start))


async def check_row_counts(session: aiohttp.ClientSession, cfg: GatewayConfig) -> CheckResult:
    start = time.perf_counter()
    try:
        data = await _get_json(session, f"{cfg.base_url}/metrics/row-counts", cfg.request_timeout_s)
    except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
        return CheckResult("row_count_sanity", False, f"row-count endpoint error: {exc}", _ms(start))
    counts = data.get("counts", {})
    thin = {t: c for t, c in counts.items() if int(c) < cfg.min_rows}
    if thin:
        return CheckResult("row_count_sanity", False, f"tables below floor {cfg.min_rows}: {thin}", _ms(start))
    return CheckResult("row_count_sanity", True, f"{len(counts)} tables above floor", _ms(start))


async def check_query_latency(session: aiohttp.ClientSession, cfg: GatewayConfig) -> CheckResult:
    start = time.perf_counter()
    payload = {"name": "critical_path", "mode": "read-only"}
    try:
        async with session.post(
            f"{cfg.base_url}/query/critical",
            json=payload,
            headers=SYNTHETIC_HEADERS,
            timeout=aiohttp.ClientTimeout(total=cfg.request_timeout_s),
        ) as resp:
            resp.raise_for_status()
            body = await resp.json()
    except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
        return CheckResult("critical_query_latency", False, f"query endpoint error: {exc}", _ms(start))
    server_ms = float(body.get("elapsed_ms", _ms(start)))
    if server_ms > cfg.max_query_ms:
        return CheckResult(
            "critical_query_latency", False,
            f"{server_ms:.1f}ms exceeds {cfg.max_query_ms:.1f}ms budget", server_ms,
        )
    return CheckResult("critical_query_latency", True, f"{server_ms:.1f}ms within budget", server_ms)


async def check_replication(session: aiohttp.ClientSession, cfg: GatewayConfig) -> CheckResult:
    start = time.perf_counter()
    try:
        data = await _get_json(session, f"{cfg.base_url}/replication/status", cfg.request_timeout_s)
    except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
        return CheckResult("replication_caught_up", False, f"replication endpoint error: {exc}", _ms(start))
    lag = float(data.get("lag_seconds", 1e9))
    if lag > cfg.max_replication_lag_s:
        return CheckResult(
            "replication_caught_up", False,
            f"lag {lag:.1f}s exceeds {cfg.max_replication_lag_s:.1f}s tolerance", _ms(start),
        )
    return CheckResult("replication_caught_up", True, f"lag {lag:.1f}s within tolerance", _ms(start))


async def run_checks(cfg: GatewayConfig) -> List[CheckResult]:
    checks = [check_schema, check_row_counts, check_query_latency, check_replication]
    async with aiohttp.ClientSession() as session:
        results = await asyncio.gather(*(check(session, cfg) for check in checks))
    return list(results)


def build_config(args: argparse.Namespace) -> GatewayConfig:
    return GatewayConfig(
        base_url=args.base_url.rstrip("/"),
        expected_tables=[t.strip() for t in args.expected_tables.split(",") if t.strip()],
        min_rows=args.min_rows,
        max_query_ms=args.max_query_ms,
        max_replication_lag_s=args.max_lag,
        request_timeout_s=args.timeout,
    )


def main(argv=None) -> int:
    parser = argparse.ArgumentParser(description="Readiness gateway for a promoted DR sandbox")
    parser.add_argument("base_url", help="base URL of the sandbox data-access API")
    parser.add_argument("--expected-tables", dest="expected_tables", required=True,
                        help="comma-separated tables that must be present")
    parser.add_argument("--min-rows", type=int, default=1)
    parser.add_argument("--max-query-ms", type=float, default=250.0)
    parser.add_argument("--max-lag", type=float, default=5.0)
    parser.add_argument("--timeout", type=float, default=10.0)
    args = parser.parse_args(argv)

    if not args.base_url.startswith(("http://", "https://")):
        print("base_url must be an http(s) URL", file=sys.stderr)
        return int(Verdict.ABORT)
    cfg = build_config(args)
    if not cfg.expected_tables:
        print("--expected-tables must list at least one table", file=sys.stderr)
        return int(Verdict.ABORT)

    try:
        results = asyncio.run(run_checks(cfg))
    except Exception as exc:  # setup/transport failure short-circuits before any verdict
        print(f"gateway failed to execute checks: {exc}", file=sys.stderr)
        return int(Verdict.ABORT)

    for r in results:
        print(f"[{'PASS' if r.ok else 'FAIL'}] {r.name} ({r.latency_ms:.1f}ms): {r.detail}")

    if all(r.ok for r in results):
        print("verdict READY: gating open, routing real traffic to the sandbox")
        return int(Verdict.READY)
    failed = [r.name for r in results if not r.ok]
    print(f"verdict HOLD: gating closed, quarantine sandbox; failed: {failed}", file=sys.stderr)
    return int(Verdict.HOLD)


if __name__ == "__main__":
    sys.exit(main())

The exit-code contract is what makes the gateway composable. READY (0) means the drill runner may open routing to the sandbox; HOLD (1) means every failed check name is on record and the sandbox must be quarantined rather than promoted; ABORT (2) is reserved for a malformed invocation or a setup failure that prevented any check from running, so a runner can distinguish “the sandbox failed” from “the gateway never got to ask.” Because a check returns a CheckResult on transport failure instead of raising, an unreachable /replication/status degrades that one check to a fail without hiding a passing schema and row-count result — the operator sees exactly which dimension was not satisfied.

Step-by-Step Execution Walkthrough

  1. Confirm the sandbox is promoted and reachable. The provisioning stage must have exported the sandbox endpoint and opened ingress from the gateway before this runs; the gateway assumes the read-only API is already listening.

  2. Choose the thresholds for this dataset. --min-rows, --max-query-ms, and --max-lag encode what “correct enough to serve” means for the restored data; derive them from the production baselines the drill is validating against, not from defaults.

  3. Run the gateway against the sandbox base URL and the tables that must exist:

    bash
    python3 dr_health_gateway.py http://sandbox.dr-drill.svc.cluster.local:8080 \
      --expected-tables accounts,ledger_entries,audit_log \
      --min-rows 1 --max-query-ms 250 --max-lag 5; echo "exit=$?"
    
  4. Read the per-check lines. Each check prints PASS/FAIL, its latency, and a detail string, so a failure names the exact dimension — a missing table, a thin row count, a blown latency budget, or replication lag.

  5. Branch on the exit code. 0 opens routing and lets the drill advance, 1 holds routing and quarantines the sandbox for investigation, and 2 aborts because the invocation or setup was wrong and no verdict was formed. Route the exit code straight into the drill runner; do not re-derive readiness from the log text.

Verification and Expected Output

A ready sandbox passes all four checks, prints the verdict, and exits 0:

text
[PASS] schema_reachable (18.4ms): 3 tables present
[PASS] row_count_sanity (22.1ms): 3 tables above floor
[PASS] critical_query_latency (86.7ms): 86.7ms within budget
[PASS] replication_caught_up (15.9ms): lag 1.2s within tolerance
verdict READY: gating open, routing real traffic to the sandbox

A sandbox that is up but lagging fails the replication check, holds routing, and exits 1:

text
[PASS] schema_reachable (17.9ms): 3 tables present
[PASS] row_count_sanity (24.0ms): 3 tables above floor
[PASS] critical_query_latency (91.3ms): 91.3ms within budget
[FAIL] replication_caught_up (16.2ms): lag 42.0s exceeds 5.0s tolerance
verdict HOLD: gating closed, quarantine sandbox; failed: ['replication_caught_up']

Success means all four checks returned PASS and the process exited 0. The exit code is the contract: a non-zero exit is treated exactly like a failed validation gate, and the HOLD list names which checks to investigate before the sandbox is reconsidered for routing. Because the checks run concurrently, total wall-clock time tracks the slowest probe — here roughly 90ms — not the sum of all four.

Failure Modes and Troubleshooting

Symptom Cause Remediation
Every check fails with a transport error Gateway cannot reach the sandbox base URL Run the gateway inside the drill isolation boundary; confirm the provisioning stage opened ingress from it
schema_reachable fails with missing tables Restore incomplete, or --expected-tables names a table the schema renamed Confirm the restore finished; reconcile the expected-tables list with the current schema
critical_query_latency fails on a cold sandbox First-query planning and cold caches inflate latency Issue a warm-up query before the gateway, or set --max-query-ms to the cold-path budget
replication_caught_up fails right after promotion Replica has not yet drained its backlog Delay the gateway until initial catch-up completes, or raise --max-lag to the drill’s real RPO tolerance
Drill traffic shows up in production dashboards Observability not filtering the synthetic headers Drop requests carrying X-Synthetic-Probe/X-Drill-Traffic from SLO windows before the next drill
Exit 2 with base_url must be an http(s) URL Endpoint passed without a scheme Pass a full http:///https:// URL; the gateway aborts rather than guessing a scheme
Gateway hangs near the timeout One endpoint is slow but not erroring Lower --timeout; each request is capped by aiohttp.ClientTimeout so a hung probe fails its own check instead of the whole run

Integration Notes

The gateway is built for headless execution and returns strict POSIX codes, so any scheduler can gate on it:

  • Airflow — invoke it from a BashOperator (or a PythonOperator that calls main) and inspect the return code; a non-zero exit fails the task and short-circuits the downstream routing task, and the DAG run history becomes the readiness audit trail.
  • Celery — wrap the call in a task that raises on non-zero so event-driven drills, triggered when a sandbox is promoted, get low-latency readiness gating and the broker records every HOLD.
  • cron / systemd — schedule the gateway directly; because it exits 0/1/2 cleanly, an OnFailure handler can route a quarantine alert with no extra glue.

Map each failed check name into an orchestrator action through your error categorization frameworks so a replication-lag HOLD and a missing-table HOLD escalate differently, and forward the per-check latencies and the verdict into your telemetry store so readiness decisions are queryable long after the drill closes. For the concurrency and timeout primitives the gateway relies on, consult the Python asyncio documentation and the aiohttp client reference for ClientSession and ClientTimeout semantics.

Frequently Asked Questions

Why require every check to pass instead of a majority?

The four checks measure orthogonal failure modes — reachability, data completeness, query performance, and replication freshness — and a sandbox can fail exactly one while looking healthy on the other three. A majority rule would promote a sandbox with a truncated table or a stale replica, which is precisely the silent-corruption case a drill exists to catch. Requiring all four means routing opens only when the restore is correct on every dimension the drill measures.

How does synthetic-traffic tagging keep the drill out of production metrics?

Every probe carries X-Synthetic-Probe and a drill-traffic header, and the observability pipeline is configured to drop any request bearing them from SLO windows, error-rate counters, and latency histograms. Without that, a burst of readiness checks would inflate request volume and skew the very dashboards on-call engineers watch. The tag makes drill load observable to the drill runner while invisible to production monitoring.

Why run the checks with asyncio.gather rather than sequentially?

The four probes are independent read-only calls, so running them concurrently bounds total wall-clock time by the slowest single check instead of the sum of all four. Each check still returns its own CheckResult and its own latency, so concurrency does not blur which dimension failed. On a promoted sandbox where the readiness window is tight, that difference keeps the gate well inside the drill's time budget.

What is the difference between exit code 1 and exit code 2?

Exit 1 is a verdict: the checks ran and at least one failed, so the sandbox is held and quarantined and the failing check names are on record. Exit 2 is an abort: a missing argument, a URL without a scheme, or a setup error meant no check ever ran and no readiness judgment was formed. Runners treat 2 as "fix the invocation" and 1 as "hold routing and investigate the sandbox."

This script is one component of the broader Smoke-Test Routing Logic workflow, itself part of Restore Drill Orchestration & Environment Isolation.