CDN Failover Testing Playbook: Scripted Scenarios for Disaster Recovery Validation

At 2:17 AM on a Tuesday, our primary origin in us-east-1 went dark for 11 minutes. The CDN was supposed to swing traffic to the standby in eu-west-1 automatically. It didn’t. Half our edge PoPs kept hammering the dead origin until health checks finally timed out, and customers saw 30 seconds of 502s that should have been sub-second. That night taught me that a disaster recovery plan you haven’t scripted and fired for real isn’t a plan — it’s a hope.

Why Most Failover Tests Miss Real-World Failures

I've reviewed dozens of CDN DR validation efforts over the years, and the same pattern shows up again and again. Teams run a clean, manual test during business hours: they shut down an origin server gracefully, wait for the CDN to notice, and declare success when traffic shifts. It looks great in the demo, but it doesn't resemble a real outage. Actual failures are messy. They’re partial packet loss, TLS handshake hangs, slow 5xx trickles, or a BGP withdrawal that only affects certain edge regions. If your CDN Integration Testing Framework Design: Validating Edge Behavior Programmatically only covers the happy-path shutdown, you aren't testing failover at all.

A proper test playbook has to reproduce those dirty failure modes under controlled conditions. We've learned to start every engagement by mapping the actual failure domains: origin health, mid-tier shields, edge PoP isolation, DNS, and certificate validation. Each of those can fail independently, and each triggers a different decision path in the CDN's routing logic. For example, a 503 with a valid `Retry-After` header is often handled differently than a TCP timeout, which is also handled differently than an expired origin certificate. The HTTP Working Group Specifications define how status codes and cache directives should behave, but every CDN provider implements their own interpretation of stale-while-revalidate and stale-if-error, so you can't assume anything until you've measured it.

That means your scripts shouldn't just "turn off the server." They should inject specific, observable faults and then assert on the edge behavior you expect. Here's a simple bash harness we use to automate origin 5xx injection and validate that a secondary origin takes over within our SLO. It’s not fancy, but it catches 80% of misconfigurations before they hit production.

#!/bin/bash
# cdn_failover_smoke.sh - Validate origin failover on 5xx
set -euo pipefail

PRIMARY_ORIGIN="origin-primary.example.com"
FAILOVER_ORIGIN="origin-failover.example.com"
TEST_URL="https://www.example.com/api/health"
MAX_FAILOVER_SECS=5

echo "[*] Injecting 500s on primary origin..."
# Use toxiproxy or iptables to return 500 for 60s
curl -s -X POST http://toxiproxy:8474/proxies/primary/toxics \
  -d '{"name":"500_error","type":"slicer","attributes":{"average_size":0}}' > /dev/null

# Alternative: direct backend fault injection via admin endpoint
# curl -s -X POST "https://${PRIMARY_ORIGIN}/__admin/fault?code=500&duration=60"

START=$(date +%s)
HTTP_CODE=0

while true; do
  HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Cache-Control: no-cache" $TEST_URL)
  ELAPSED=$(( $(date +%s) - START ))
  
  if [[ "$HTTP_CODE" == "200" ]]; then
    echo "[OK] Failover succeeded in ${ELAPSED}s (served from failover)"
    # Verify via header which origin served request
    curl -s -I $TEST_URL | grep -i "x-origin-served:"
    break
  fi

  if [[ $ELAPSED -gt $MAX_FAILOVER_SECS ]]; then
    echo "[FAIL] Did not fail over within ${MAX_FAILOVER_SECS}s. Last code: $HTTP_CODE"
    exit 1
  fi
  sleep 0.5
done

# Cleanup fault injection
curl -s -X DELETE http://toxiproxy:8474/proxies/primary/toxics/500_error > /dev/null
echo "[*] Test complete. Check edge logs for stale-while-revalidate hits."

Scripted Scenarios That Actually Prove Disaster Recovery

Once you've got the basic harness, you need a library of scripted scenarios. Don't try to build every permutation at once — we've found three to four well-chosen faults cover most disaster recovery risks. I typically start with: hard origin down (TCP refuse), soft origin degradation (5xx at 20% for 2 minutes), and DNS failure for the primary. That last one still catches teams off guard. Your CDN might have the failover origin configured, but if your health checks rely on the same DNS that just broke, failover never triggers.

For each scenario, script both the fault and the assertions. The fault is the easy part; the assertions are where failover testing earns its keep. You’ll want to check status codes from multiple edge locations, time-to-first-successful-failover, cache behavior, and whether client-facing headers like `Age` or `X-Cache` indicate you’re serving stale content as designed. If you're building this into CI, treat it like any other integration test. Our End-to-End CDN Testing: Building Production-Like Test Environments approach spins up a mini-PoP topology with containerized Varnish and Nginx origins, so we can run these scripts on every pull request without touching production.

Here's a Python example we use for DNS-level failover. It blackholes the primary origin's DNS at the edge resolver and validates that requests routed through London, Singapore, and Ashburn all converge on the secondary without serving 5xx to the client. Tools like `dnstox` or just manipulating `/etc/hosts` on the test runner work well in a lab.

import time
import requests
import concurrent.futures
from datetime import datetime

EDGE_COLLECTORS = [
    "https://edge-test-lhr.example.com/check",
    "https://edge-test-sin.example.com/check",
    "https://edge-test-iad.example.com/check"
]
TEST_PATH = "https://www.example.com/static/app.js"
FAILOVER_HEADER = "x-origin-served"
TIMEOUT = 8  # seconds to consider failover successful

def inject_dns_failure():
    # In lab: update CoreDNS to return NXDOMAIN for primary
    print("[*] Injecting DNS NXDOMAIN for origin-primary.example.com")
    requests.post("http://coredns-admin:8080/blackhole",
                  json={"host": "origin-primary.example.com", "action": "nxdomain"})

def check_edge(url):
    try:
        r = requests.get(TEST_PATH, headers={"Host": "www.example.com",
                                             "Cache-Control": "no-cache",
                                             "x-edge-collector": url},
                         timeout=5)
        origin = r.headers.get(FAILOVER_HEADER, "unknown")
        return url, r.status_code, origin, r.elapsed.total_seconds()
    except Exception as e:
        return url, 0, str(e), 999

def run_test():
    inject_dns_failure()
    start = datetime.now()
    print(f"[*] Fault injected at {start.isoformat()}, polling edges...")

    with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
        for i in range(TIMEOUT * 2):
            futures = [executor.submit(check_edge, u) for u in EDGE_COLLECTORS]
            results = [f.result() for f in futures]
            
            all_ok = all(code == 200 and "failover" in origin for _, code, origin, _ in results)
            if all_ok:
                elapsed = (datetime.now() - start).total_seconds()
                print(f"[OK] DNS failover validated in {elapsed:.2f}s")
                for u, code, origin, lat in results:
                    print(f"  {u} -> {code} from {origin} in {lat:.3f}s")
                return True
            time.sleep(0.5)

    print("[FAIL] Not all edges failed over within timeout")
    return False

if __name__ == "__main__":
    success = run_test()
    # Always clean up DNS injection
    requests.post("http://coredns-admin:8080/blackhole",
                  json={"host": "origin-primary.example.com", "action": "clear"})
    exit(0 if success else 1)

Defining Pass/Fail: Metrics That Make CDN DR Validation Credible

You can’t call a failover test green just because you eventually got a 200. Without hard thresholds, every test becomes a judgment call. We define our pass/fail criteria before we run a single script, and we tie them directly to what users actually experience. That means looking beyond origin availability and focusing on edge-perceived latency, error volume during the transition window, and cache continuity. Guidance from web.dev Articles on Core Web Vitals is useful here — a failover that adds 2 seconds of Time to First Byte is technically successful but still damages your UX.

In our playbook, we log three telemetry streams for every run: synthetic checks from external catchpoints (like ThousandEyes or Catchpoint), CDN access logs with `origin_status` and `cache_status`, and Real User Monitoring for the test domain. The synthetics give us region-by-region failover time, the logs tell us whether we served stale or went to origin, and RUM confirms there wasn't a hidden spike for actual browsers. When we started correlating these after a failed drill two years ago, we found our US-West PoPs were 4x slower to fail over because health check intervals were tuned for capacity, not resiliency.

For teams building their own CDN Performance Testing Methodology: Load Generation, Measurement, and Analysis, these thresholds give you a solid baseline. Adjust them for your SLOs, but don't ship a DR validation that doesn't measure them. If you can't graph the failover window, you can't prove you met it.

Metric Pass Threshold (Intermediate) How We Collect It Why It Matters
Time to Failover (TTF) p95 < 3.0s, p99 < 5.0s Synthetic polling every 500ms from 5+ edge regions Directly impacts user-visible error duration
Error Rate During Transition < 0.5% of requests return 5xx CDN log aggregation (origin_status 5xx) + client checks Shows whether stale-while-revalidate masked the outage
Cache Hit Ratio Post-Failover > 85% for static assets in first 60s X-Cache / cache_status header sampling Prevents failover origin from being thunder-herded
Failover Origin Latency Delta < +150ms vs. primary baseline RUM TTFB comparison before/after fault injection Ensures secondary region isn't too cold or

Putting It Into Practice: Building Your Automated Failover Test Harness

Runbooks in a Google Doc don't cut it when your primary origin goes dark at 2 AM. We've seen teams with beautiful 20-page disaster recovery plans fail because no one had actually scripted the failover and wired it into CI. If your test requires three engineers to manually update DNS and watch Grafana, it's not a test — it's a fire drill you'll never run often enough.

The goal is a repeatable harness you can trigger on demand or on schedule. Ours lives in GitLab CI and runs weekly against staging plus monthly against prod (with safeguards). It orchestrates four things: baseline traffic generation, controlled fault injection, real-time validation from multiple vantage points, and automatic rollback. You'll want synthetic probes in at least 5-6 geographies — US East/West, EU Central, AP Southeast, and South America — because a failover that looks clean from Virginia often exposess 4-6 seconds of extra latency or SERVFAILs in Singapore. For realistic baseline load, don't just hit the cache with a single region; see Distributed Load Testing for CDN Edge: Generating Traffic from Multiple Geographies for patterns on generating geographically distributed traffic so your origin shield and secondary tier actually get exercised.

Keep your assertions brutally specific. Don't assert "site is up." Assert: p95 TTFB < 800ms for cached assets during failover, origin failover completes within 45 seconds of primary health check failure, stale content serve rate > 99.5% for cacheable objects with TTL > 60s, and zero 5xx for static assets over a 10-minute window. Here's a stripped-down bash harness we use to automate the core loop — it blocks the primary origin at the edge firewall, then validates from the outside in:

#!/bin/bash
set -euo pipefail

PRIMARY_ORIGIN="origin-primary.example.com"
FAILOVER_ORIGIN="origin-failover.example.com"
EDGE_HOST="www.example.com"
MAX_FAILOVER_SECS=45
TEST_URL="https://${EDGE_HOST}/static/app.js"

echo "[1/4] Establishing baseline - warming cache & checking health"
curl -s -o /dev/null -w "%{http_code}" $TEST_URL | grep -q 200
for POP in "us-east" "eu-west" "ap-southeast"; do
  curl -s -H "x-debug-pop: $POP" $TEST_URL --resolve "$EDGE_HOST:443:104.16.0.1" | grep -q "x-cache: HIT"
done

echo "[2/4] Injecting fault - blocking primary origin at edge ACL"
# Use your CDN's API - Cloudflare, Fastly, Akamai all support this
cdn_api block-origin --origin $PRIMARY_ORIGIN --ttl 600 &
BLOCK_PID=$!

echo "[3/4] Validating failover within ${MAX_FAILOVER_SECS}s"
START=$(date +%s)
until curl -sf -m 5 $TEST_URL -H "x-failover-test: 1" > /dev/null; do
  ELAPSED=$(( $(date +%s) - START ))
  if [ $ELAPSED -gt $MAX_FAILOVER_SECS ]; then
    echo "FAIL: Failover exceeded ${MAX_FAILOVER_SECS}s - check origin health checks (10s interval / 3 fails?)"
    kill $BLOCK_PID; exit 1
  fi
  sleep 2
done

echo "PASS: Failover in $(( $(date +%s) - START ))s - verifying cache behavior"
# Ensure we're serving stale/failover and not bypassing cache
curl -s -D - $TEST_URL -o /dev/null | grep -qi "x-cache:.*HIT\|x-serve-stale: 1"
curl -s -D - $TEST_URL | grep -q "x-origin: $FAILOVER_ORIGIN"

echo "[4/4] Rollback & post-check"
kill $BLOCK_PID
sleep 10
dig +short $EDGE_HOST | grep -q "104.16"
echo "Done - check your RUM for 5xx spike < 0.1% in next 5m"

Advanced Patterns and Troubleshooting When Failover Lies to You

Here's where most teams get burned: the failover "works" in your dashboard but serves the wrong thing. The classic is stale-while-revalidate gone rogue. Your CDN is correctly serving stale for 86,400 seconds while your origin is down, but your cache-key is wrong and you're serving stale English content to French users, or worse, serving a stale 500 error page with a 200 status. We've debugged a case where Akamai's SureRoute passed, but the secondary origin had a 6-hour-old purged asset — 99.8% availability on paper, 22% broken images for real users. You need to validate not just that you got a 200, but that the body, headers, and cache status are correct. Our post-failover assertions now hash the response body and compare content-length and etag against a known-good manifest. For a deeper checklist, use CDN Cache Validation Testing: Verifying Correct Caching Behavior Across Edges to verify you're not masking failures with poisoned cache.

Another nasty pattern is the origin shield collapse. When your primary region fails, every edge POP that was shielded through, say, Ashburn suddenly hammers your failover origin directly. We measured a 17x spike to the failover origin in the first 90 seconds of a test — no surprise it tipped over and we got cascading 502s. Your script needs to assert origin concurrency and shield hit ratio, not just edge hit ratio. If your shield hit ratio drops from 92% to 38% during failover, you don't have a failover architecture, you've got a thundering herd. Script a gradual drain: lower primary's weight to 10% before killing it, and watch failover CPU and connection counts for 3 minutes before full cutover.

When a test fails, don't just re-run it. Correlate three logs: CDN edge logs (look for origin_status vs edge_status divergence), DNS query logs (did your 30s TTL actually propagate in 30s or did ISP resolvers cache it for 300s?), and your synthetic trace waterfalls. The two most common root causes we see are health check misconfiguration — a 10-second interval with 3 consecutive failures means 30 seconds of 5xx before failover even starts — and Anycast vs. DNS failover confusion. DNS failover will always be slower and leakier than Anycast. If you're chasing sub-30s RTO, you need Anycast and you need to test it with real fault injection, not just a config toggle. That's where Chaos Testing at CDN Edge: Injecting Faults to Validate Resilience pays for itself — injecting 500s, latency, and TCP resets at the edge is the only way to prove your retry and circuit-breaker logic actually triggers.

Frequently Asked Questions

How often should we run automated CDN failover tests?

Weekly against a staging environment that mirrors prod routing, and monthly against production with a low-risk, short-duration (5-10 minute) test. If you only test quarterly, config drift will get you — we've seen a single WAF rule change silently break failover health checks for 11 weeks. Automate it so frequency isn't dependent on someone's calendar.

Should we test DNS failover and Anycast failover differently?

Absolutely. DNS failover tests must measure TTL propagation — expect 30-120s extra delay beyond your health check interval due to resolver caching, even with a 30s TTL. Use dig from multiple public resolvers (8.8.8.8, 1.1.1.1, 9.9.9.9) to verify. Anycast tests should measure BGP withdrawal and edge connection draining, which is typically 10-25 seconds but can blackhole 0.5-1% of users for up to 60 seconds. Don't use the same pass/fail threshold for both.

How can we test failover without impacting real users?

Three safeguards: 1) Use a canary hostname (e.g., failover-test.example.com) that shares the same origins and shield config as production, 2) use CDN edge ACLs or Workers to fault-inject for traffic with a specific header/cookie only, and 3) schedule prod tests in low-traffic windows and keep blast radius to 5 minutes. Never block your primary origin globally in prod without a one-click rollback and an on-call engineer watching origin concurrency.

Why are we still serving 5xx errors even though the CDN says failover succeeded?

Usually it's cache bypass or health check lag. Check if your test URLs are cacheable — dynamic, uncacheable paths will proxy straight to the dead origin until health checks fail (that's 30-45s of hard 5xx). Also verify your stale-if-error and stale-while-revalidate directives; without them, the CDN correctly fails over but has nothing stale to serve and must return the origin's 5xx. Your edge logs should show x-cache: MISS and origin_status: 0 in that case.

Related Articles