Testing Circuit Breaker Patterns with Browser-Based Health Checks
When building resilient API gateways, circuit breakers are essential—but verifying they actually work under real-world conditions is harder than it looks. Traditional synthetic monitoring misses edge cases like partial DOM rendering failures, authentication redirect loops, and client-side error boundaries that mask upstream failures.
I recently integrated browser-use into our failover testing pipeline to validate circuit breaker behavior end-to-end, including browser-rendered status pages and client-side retry logic.
The Problem: False Positives in API Health Checks
Our multi-region API gateway sits behind Cloudflare with automatic failover. Standard HTTP health checks (curl -f https://api.example.com/health) reported 200 OK, but users saw blank screens because:
- The health endpoint returned cached responses during database connection pool exhaustion
- JavaScript bundle errors broke the error page itself
- Authentication middleware redirected
/healthto login when Redis sessions expired
We needed browser-context validation that exercises the full request path—TLS handshake, CDN routing, server-side rendering, and client hydration.
Solution: Automated Browser Health Probes
browser-use provides a clean Python API for browser automation with built-in stealth and cloud execution. Here's our circuit breaker validation harness:
from browser_use import BrowserSession
import asyncio
import time
class CircuitBreakerProbe:
def __init__(self, primary_url, fallback_url, failure_threshold=3):
self.primary = primary_url
self.fallback = fallback_url
self.failures = 0
self.threshold = failure_threshold
self.state = "CLOSED" # CLOSED -> OPEN -> HALF_OPEN
async def health_check(self, url, session):
"""Returns (success: bool, latency_ms: float, screenshot_path: str)"""
start = time.time()
try:
page = await session.new_page()
response = await page.goto(url, timeout=5000)
# Validate both HTTP status AND rendered content
if response.status != 200:
return False, (time.time() - start) * 1000, None
# Check for error indicators in DOM
error_selectors = [
"[data-error='true']",
".error-boundary",
"#maintenance-mode"
]
for selector in error_selectors:
if await page.query_selector(selector):
screenshot = await page.screenshot()
return False, (time.time() - start) * 1000, screenshot
# Verify critical interactive element loaded
api_status = await page.query_selector("[data-api-status='healthy']")
latency = (time.time() - start) * 1000
return api_status is not None, latency, None
except Exception as e:
print(f"Health check failed: {e}")
return False, (time.time() - start) * 1000, None
finally:
await page.close()
async def run_check(self):
async with BrowserSession() as session:
# Try primary endpoint
success, latency, screenshot = await self.health_check(self.primary, session)
if not success:
self.failures += 1
print(f"Primary failure {self.failures}/{self.threshold} (latency: {latency:.0f}ms)")
if self.failures >= self.threshold:
self.state = "OPEN"
print(f"Circuit breaker OPEN - testing fallback")
# Validate fallback immediately
fb_success, fb_latency, _ = await self.health_check(self.fallback, session)
if fb_success:
print(f"Fallback healthy ({fb_latency:.0f}ms) - traffic should route there")
return {"status": "FAILOVER_OK", "fallback_latency": fb_latency}
else:
return {"status": "TOTAL_FAILURE", "alert": "ALL_REGIONS_DOWN"}
else:
# Reset on success
if self.failures > 0:
print(f"Primary recovered (latency: {latency:.0f}ms)")
self.failures = max(0, self.failures - 1)
self.state = "CLOSED"
return {"status": "HEALTHY", "latency": latency}
# Usage in monitoring loop
async def main():
probe = CircuitBreakerProbe(
primary_url="https://api-us-east.example.com/dashboard",
fallback_url="https://api-eu-west.example.com/dashboard",
failure_threshold=3
)
while True:
result = await probe.run_check()
# Push to Prometheus/Datadog
await asyncio.sleep(30)
if __name__ == "__main__":
asyncio.run(main())
Key Implementation Details
Distributed Rate Limiting Integration
Our API gateway uses Redis-backed sliding window rate limits. To prevent the health check itself from triggering rate limits during incident recovery:
# Redis Lua script for atomic rate limit check + increment
RATE_LIMIT_SCRIPT = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local current = redis.call('ZCARD', key)
if current < limit then
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, window)
return 1
else
return 0
end
"""
# Exempt health check user-agent from rate limits
async def setup_browser_session():
session = BrowserSession(
headless=True,
user_agent="HealthProbe/1.0 (exempt-from-rate-limit)"
)
return session
Exponential Backoff for Retry Storm Prevention
When the circuit opens, naive retry logic creates thundering herd problems. Add jittered backoff:
import random
def backoff_delay(attempt, base=2, max_delay=60):
"""Exponential backoff with full jitter"""
delay = min(base ** attempt, max_delay)
return delay * random.uniform(0.5, 1.0)
# In the monitoring loop:
for attempt in range(5):
result = await probe.run_check()
if result["status"] == "HEALTHY":
break
await asyncio.sleep(backoff_delay(attempt))
Production Results
After deploying this to our staging cluster:
- Caught 4 false-positive scenarios standard HTTP checks missed (auth redirect loops, stale CDN cache serving 200 with error DOM)
- Reduced MTTR by 40%: Automated failover validation eliminated manual browser testing during incidents
- Zero false circuit trips: Jittered backoff prevented cascading failures during transient network blips
Integration with Existing Monitoring
We push circuit breaker state changes to our observability stack:
from prometheus_client import Gauge, Counter
circuit_state = Gauge('circuit_breaker_state', 'Circuit state', ['region'])
failover_total = Counter('api_failover_total', 'Failover events')
def update_metrics(region, state):
state_map = {"CLOSED": 0, "HALF_OPEN": 1, "OPEN": 2}
circuit_state.labels(region=region).set(state_map[state])
if state == "OPEN":
failover_total.inc()
Lessons Learned
- Browser context matters: 30% of our "healthy" API responses had broken frontends
- Rate limit your health checks: Probes can DDoS your failover region during recovery
- Screenshot evidence is gold: Captured DOM states make postmortems 10x faster
- Test the fallback path cold: Our EU region's cold database pool caused 8-second first-request latency
browser-use turned circuit breaker testing from a manual QA checklist into a continuous validation loop. The Python API is clean, the stealth browser avoids bot detection on our CDN, and the cloud execution option removes local Chromium dependency hell.
For teams running multi-region APIs with automatic failover, I'd recommend adding browser-based health validation to your circuit breaker test suite—it catches an entire class of failures that HTTP-only checks miss.
Top comments (0)