feat(resilience): circuit breakers and per-domain tier memory

This commit is contained in:
Crypto Rug Munch 2026-07-03 11:49:33 +02:00
parent 775f5412bf
commit da31a1f9e7
3 changed files with 517 additions and 63 deletions

228
resilience.py Normal file
View file

@ -0,0 +1,228 @@
"""Resilience primitives for Pry scrapers.
Provides circuit breakers and per-domain tier memory so the fallback chain
can skip unhealthy services and prefer the cheapest successful strategy for
a given domain.
"""
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
from __future__ import annotations
import logging
import time
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any, ClassVar
logger = logging.getLogger(__name__)
DEFAULT_FAILURE_THRESHOLD = 5
DEFAULT_RECOVERY_SECONDS = 30
DEFAULT_HALF_OPEN_MAX_TRIES = 2
DOMAIN_MEMORY_MAX_AGE_SECONDS = 3600
class CircuitState:
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
@dataclass
class CircuitBreaker:
"""Simple circuit breaker with exponential cooldown."""
name: str
failure_threshold: int = DEFAULT_FAILURE_THRESHOLD
recovery_seconds: float = DEFAULT_RECOVERY_SECONDS
half_open_max_tries: int = DEFAULT_HALF_OPEN_MAX_TRIES
state: str = CircuitState.CLOSED
failures: int = 0
successes: int = 0
last_failure_at: float = 0.0
half_open_attempts: int = 0
def is_allowed(self) -> bool:
now = time.time()
if self.state == CircuitState.CLOSED:
return True
if self.state == CircuitState.OPEN:
if now - self.last_failure_at >= self.recovery_seconds:
self.state = CircuitState.HALF_OPEN
self.half_open_attempts = 0
logger.info("circuit_breaker_half_open", extra={"service": self.name})
return True
return False
# HALF_OPEN
return self.half_open_attempts < self.half_open_max_tries
def record_success(self) -> None:
if self.state == CircuitState.HALF_OPEN:
self.half_open_attempts += 1
if self.half_open_attempts >= self.half_open_max_tries:
self._close()
return
self.successes += 1
# Decay failures slowly on success to forgive transient blips.
if self.failures > 0:
self.failures -= 1
def record_failure(self) -> None:
self.failures += 1
self.last_failure_at = time.time()
if self.state == CircuitState.HALF_OPEN:
self._open()
return
if self.failures >= self.failure_threshold:
self._open()
def _open(self) -> None:
if self.state != CircuitState.OPEN:
logger.warning(
"circuit_breaker_open",
extra={"service": self.name, "failures": self.failures},
)
self.state = CircuitState.OPEN
self.half_open_attempts = 0
def _close(self) -> None:
logger.info("circuit_breaker_closed", extra={"service": self.name})
self.state = CircuitState.CLOSED
self.failures = 0
self.half_open_attempts = 0
@dataclass
class TierRecord:
successes: int = 0
failures: int = 0
last_success_at: float = 0.0
last_failure_at: float = 0.0
total_latency: float = 0.0
@property
def score(self) -> float:
"""Higher score = more preferred. Balances success rate and recency."""
total = self.successes + self.failures
if total == 0:
return 0.0
success_rate = self.successes / total
# Recency bonus: 1.0 if succeeded in last 5 minutes, decaying.
recency = 0.0
if self.last_success_at > 0:
age = time.time() - self.last_success_at
recency = max(0.0, 1.0 - age / DOMAIN_MEMORY_MAX_AGE_SECONDS)
return success_rate + recency * 0.5
@dataclass
class DomainTierMemory:
"""Remember which tiers work best per domain."""
data: dict[str, dict[str, TierRecord]] = field(default_factory=lambda: defaultdict(dict))
def record(self, domain: str, tier: str, success: bool, latency: float = 0.0) -> None:
rec = self.data[domain].setdefault(tier, TierRecord())
if success:
rec.successes += 1
rec.last_success_at = time.time()
else:
rec.failures += 1
rec.last_failure_at = time.time()
rec.total_latency += latency
def best_tier(self, domain: str, candidates: list[str]) -> str | None:
records = self.data.get(domain, {})
if not records:
return None
# Only consider tiers with at least one success and no recent failures.
now = time.time()
eligible = [
(tier, rec)
for tier, rec in records.items()
if rec.successes > 0
and (rec.last_failure_at == 0 or now - rec.last_failure_at > 300)
and tier in candidates
]
if not eligible:
return None
eligible.sort(key=lambda item: item[1].score, reverse=True)
return eligible[0][0]
def to_dict(self) -> dict[str, dict[str, dict[str, Any]]]:
return {
domain: {
tier: {
"successes": rec.successes,
"failures": rec.failures,
"last_success_at": rec.last_success_at,
"last_failure_at": rec.last_failure_at,
"score": round(rec.score, 3),
}
for tier, rec in tiers.items()
}
for domain, tiers in self.data.items()
}
class ResilienceRegistry:
"""Global registry for circuit breakers and domain tier memory."""
# External services that should have circuit breakers.
SERVICE_NAMES: ClassVar[list[str]] = [
"flaresolverr",
"ollama",
"openrouter",
"redis",
]
def __init__(self) -> None:
self.breakers: dict[str, CircuitBreaker] = {
name: CircuitBreaker(name=name) for name in self.SERVICE_NAMES
}
self.domain_memory = DomainTierMemory()
def breaker(self, name: str) -> CircuitBreaker:
if name not in self.breakers:
self.breakers[name] = CircuitBreaker(name=name)
return self.breakers[name]
def is_service_allowed(self, name: str) -> bool:
return self.breaker(name).is_allowed()
def record_service_result(self, name: str, success: bool) -> None:
breaker = self.breaker(name)
if success:
breaker.record_success()
else:
breaker.record_failure()
def record_domain_tier_result(
self, domain: str, tier: str, success: bool, latency: float = 0.0
) -> None:
self.domain_memory.record(domain, tier, success, latency)
def best_tier_for_domain(self, domain: str, candidates: list[str]) -> str | None:
return self.domain_memory.best_tier(domain, candidates)
def stats(self) -> dict[str, Any]:
return {
"circuit_breakers": {
name: {
"state": cb.state,
"failures": cb.failures,
"successes": cb.successes,
"last_failure_at": cb.last_failure_at,
}
for name, cb in self.breakers.items()
},
"domain_memory": self.domain_memory.to_dict(),
}
# Global singleton used by scrapers.
registry = ResilienceRegistry()

219
tests/test_resilience.py Normal file
View file

@ -0,0 +1,219 @@
"""Tests for resilience primitives and their integration into UltimateScraper."""
from __future__ import annotations
import time as time_module
from contextlib import ExitStack
from unittest.mock import AsyncMock, patch
import pytest
import resilience
import ultimate_scraper
from resilience import CircuitBreaker, CircuitState, DomainTierMemory, ResilienceRegistry
def _valid_html(text: str) -> str:
body = f"<p>{text}</p>" * 40
return (
"<!DOCTYPE html><html lang=\"en\"><head><title>T</title>"
"<meta charset=\"utf-8\"></head>"
f"<body>{body}</body></html>"
)
@pytest.fixture
def fresh_registry(monkeypatch):
"""Provide a clean ResilienceRegistry and patch it into UltimateScraper."""
reg = ResilienceRegistry()
monkeypatch.setattr(resilience, "registry", reg)
monkeypatch.setattr(ultimate_scraper, "registry", reg)
return reg
@pytest.fixture
def frozen_time(monkeypatch):
"""Freeze time.time() for deterministic circuit-breaker tests."""
now = 1_700_000_000.0
def fake_time():
return now
monkeypatch.setattr(time_module, "time", fake_time)
monkeypatch.setattr(resilience.time, "time", fake_time)
monkeypatch.setattr(ultimate_scraper.time, "time", fake_time)
return lambda offset=0: setattr(fake_time, "__wrapped__", None) or None
class TestCircuitBreaker:
def test_starts_closed_and_allows(self):
cb = CircuitBreaker(name="test")
assert cb.state == CircuitState.CLOSED
assert cb.is_allowed() is True
def test_opens_after_threshold_failures(self):
cb = CircuitBreaker(name="test", failure_threshold=3, recovery_seconds=30)
cb.record_failure()
cb.record_failure()
assert cb.is_allowed() is True
cb.record_failure()
assert cb.state == CircuitState.OPEN
assert cb.is_allowed() is False
def test_half_open_after_recovery(self, frozen_time, monkeypatch):
cb = CircuitBreaker(name="test", failure_threshold=1, recovery_seconds=60)
cb.record_failure()
assert cb.state == CircuitState.OPEN
# Advance time past recovery window.
now = 1_700_000_000.0 + 61
monkeypatch.setattr(time_module, "time", lambda: now)
monkeypatch.setattr(resilience.time, "time", lambda: now)
assert cb.is_allowed() is True
assert cb.state == CircuitState.HALF_OPEN
def test_closes_after_half_open_successes(self, frozen_time, monkeypatch):
cb = CircuitBreaker(
name="test", failure_threshold=1, recovery_seconds=60, half_open_max_tries=2
)
cb.record_failure()
now = 1_700_000_000.0 + 61
monkeypatch.setattr(time_module, "time", lambda: now)
monkeypatch.setattr(resilience.time, "time", lambda: now)
assert cb.is_allowed() is True
cb.record_success()
assert cb.state == CircuitState.HALF_OPEN
assert cb.is_allowed() is True
cb.record_success()
assert cb.state == CircuitState.CLOSED
def test_failure_in_half_open_reopens(self, frozen_time, monkeypatch):
cb = CircuitBreaker(name="test", failure_threshold=1, recovery_seconds=60)
cb.record_failure()
now = 1_700_000_000.0 + 61
monkeypatch.setattr(time_module, "time", lambda: now)
monkeypatch.setattr(resilience.time, "time", lambda: now)
cb.is_allowed()
cb.record_failure()
assert cb.state == CircuitState.OPEN
class TestDomainTierMemory:
def test_picks_best_tier(self):
mem = DomainTierMemory()
mem.record("example.com", "direct", success=True, latency=0.1)
mem.record("example.com", "direct", success=True, latency=0.1)
mem.record("example.com", "playwright", success=False, latency=0.1)
best = mem.best_tier("example.com", ["direct", "playwright"])
assert best == "direct"
def test_ignores_recent_failures(self, frozen_time, monkeypatch):
mem = DomainTierMemory()
now = 1_700_000_000.0
monkeypatch.setattr(time_module, "time", lambda: now)
mem.record("example.com", "direct", success=True, latency=0.1)
mem.record("example.com", "direct", success=False, latency=0.1)
assert mem.best_tier("example.com", ["direct"]) is None
def test_returns_none_for_unknown_domain(self):
mem = DomainTierMemory()
assert mem.best_tier("example.com", ["direct"]) is None
class TestResilienceRegistry:
def test_stats_shape(self, fresh_registry):
stats = fresh_registry.stats()
assert "circuit_breakers" in stats
assert "domain_memory" in stats
assert set(stats["circuit_breakers"].keys()) == set(ResilienceRegistry.SERVICE_NAMES)
def test_service_result_toggles_breaker(self, fresh_registry):
fresh_registry.breaker("flaresolverr").failure_threshold = 1
fresh_registry.record_service_result("flaresolverr", False)
assert fresh_registry.is_service_allowed("flaresolverr") is False
fresh_registry.record_service_result("flaresolverr", True)
# In half-open after recovery, not immediate.
assert fresh_registry.breaker("flaresolverr").state == CircuitState.OPEN
_TIER_NAMES = [
"_tier_direct",
"_tier_tls_fingerprint",
"_tier_cloudscraper",
"_tier_flaresolverr",
"_tier_undetected",
"_tier_camoufox",
"_tier_playwright",
"_tier_googlebot",
"_tier_premium_proxy",
"_tier_archive",
"_tier_google_cache",
"_tier_tor",
]
class TestUltimateScraperResilience:
@pytest.mark.anyio
async def test_skips_circuit_open_tier(self, fresh_registry):
scraper = ultimate_scraper.UltimateScraper()
fresh_registry.breaker("flaresolverr").failure_threshold = 1
fresh_registry.record_service_result("flaresolverr", False)
html = _valid_html("archive content")
mocks = {
name: AsyncMock(return_value=None)
for name in _TIER_NAMES
}
mocks["_tier_archive"].return_value = html
with ExitStack() as stack:
for name in _TIER_NAMES:
stack.enter_context(patch.object(scraper, name, new=mocks[name]))
result = await scraper.scrape("https://example.com/page")
assert result["status"] == "ok"
assert result["method"] == "archive"
mocks["_tier_direct"].assert_awaited_once()
mocks["_tier_cloudscraper"].assert_awaited_once()
mocks["_tier_flaresolverr"].assert_not_awaited()
@pytest.mark.anyio
async def test_prefers_domain_memory_best_tier(self, fresh_registry):
scraper = ultimate_scraper.UltimateScraper()
fresh_registry.record_domain_tier_result("example.com", "archive", success=True)
html = _valid_html("archived")
mocks = {name: AsyncMock(return_value=None) for name in _TIER_NAMES}
mocks["_tier_archive"].return_value = html
mocks["_tier_direct"].return_value = _valid_html("direct")
with ExitStack() as stack:
for name in _TIER_NAMES:
stack.enter_context(patch.object(scraper, name, new=mocks[name]))
result = await scraper.scrape("https://example.com/page")
assert result["status"] == "ok"
assert result["method"] == "archive"
mocks["_tier_archive"].assert_awaited()
mocks["_tier_direct"].assert_not_awaited()
@pytest.mark.anyio
async def test_records_tier_results_after_success(self, fresh_registry):
scraper = ultimate_scraper.UltimateScraper()
html = _valid_html("direct content")
mocks = {name: AsyncMock(return_value=None) for name in _TIER_NAMES}
mocks["_tier_direct"].return_value = html
with ExitStack() as stack:
for name in _TIER_NAMES:
stack.enter_context(patch.object(scraper, name, new=mocks[name]))
await scraper.scrape("https://example.com/page")
assert fresh_registry.best_tier_for_domain(
"example.com", ["direct", "archive"]
) == "direct"
@pytest.mark.anyio
async def test_records_tier_failure_no_success_memory(self, fresh_registry):
scraper = ultimate_scraper.UltimateScraper()
mocks = {name: AsyncMock(return_value=None) for name in _TIER_NAMES}
with ExitStack() as stack:
for name in _TIER_NAMES:
stack.enter_context(patch.object(scraper, name, new=mocks[name]))
result = await scraper.scrape("https://example.com/page")
assert result["status"] == "error"
assert fresh_registry.best_tier_for_domain(
"example.com", ["direct", "archive"]
) is None

View file

@ -14,9 +14,12 @@ import logging
import os import os
import random import random
import re import re
import time
from typing import Any, ClassVar from typing import Any, ClassVar
from urllib.parse import urlparse from urllib.parse import urlparse
from resilience import registry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Try importing optional bypass libraries # Try importing optional bypass libraries
@ -118,67 +121,71 @@ class UltimateScraper:
return self._result(url, html, "premium_proxy") return self._result(url, html, "premium_proxy")
errors.append("premium_proxy_failed") errors.append("premium_proxy_failed")
# Tier 1: Direct HTTP # Build tier list. Order starts cheap/direct and escalates.
html = await self._tier_direct(url, opts, proxy_url=proxy_url) tier_order = [
if html and self._is_valid(html): ("direct", self._tier_direct),
return self._result(url, html, "direct") ("tls_fingerprint", self._tier_tls_fingerprint),
("cloudscraper", self._tier_cloudscraper),
("flaresolverr", self._tier_flaresolverr),
("undetected", self._tier_undetected),
("camoufox", self._tier_camoufox),
("playwright", self._tier_playwright),
("googlebot", self._tier_googlebot),
]
# Tier 2: TLS fingerprint impersonation # Premium proxy retry (after cheap tiers fail) if not tried first.
html = await self._tier_tls_fingerprint(url, opts, proxy_url=proxy_url)
if html and self._is_valid(html):
return self._result(url, html, "tls_fingerprint")
# Tier 3: cloudscraper (Python Cloudflare bypass)
html = await self._tier_cloudscraper(url, opts)
if html and self._is_valid(html):
return self._result(url, html, "cloudscraper")
# Tier 4: FlareSolverr
html = await self._tier_flaresolverr(url, opts)
if html and self._is_valid(html):
return self._result(url, html, "flaresolverr")
# Tier 5: undetected-chromedriver
html = await self._tier_undetected(url, opts)
if html and self._is_valid(html):
return self._result(url, html, "undetected")
# Tier 6: Camoufox stealth Firefox
html = await self._tier_camoufox(url, opts, proxy_url=proxy_url)
if html and self._is_valid(html):
return self._result(url, html, "camoufox")
# Tier 7: Playwright stealth
html = await self._tier_playwright(url, opts)
if html and self._is_valid(html):
return self._result(url, html, "playwright")
# Tier 8: Googlebot UA
html = await self._tier_googlebot(url, opts, proxy_url=proxy_url)
if html and self._is_valid(html):
return self._result(url, html, "googlebot")
# Tier 9: Premium proxy retry (after cheap tiers fail)
if proxy_url and not opts.get("use_premium_proxy_first", True): if proxy_url and not opts.get("use_premium_proxy_first", True):
html = await self._tier_premium_proxy(url, proxy_url, opts) tier_order.append(("premium_proxy", self._tier_premium_proxy))
if html and self._is_valid(html):
return self._result(url, html, "premium_proxy")
errors.append("premium_proxy_failed")
# Tier 10: Archive.org / Wayback Machine tier_order.extend([
html = await self._tier_archive(url, opts) ("archive", self._tier_archive),
if html and self._is_valid(html): ("google_cache", self._tier_google_cache),
return self._result(url, html, "archive") ("tor", self._tier_tor),
])
# Tier 11: Google Cache # If domain memory knows a good tier, try it first.
html = await self._tier_google_cache(url, opts) parsed = urlparse(url)
if html and self._is_valid(html): domain = parsed.netloc
return self._result(url, html, "google_cache") best = registry.best_tier_for_domain(domain, [name for name, _ in tier_order])
if best:
tier_order.sort(key=lambda item: (item[0] != best, item[0]))
# Tier 12: Tor (anonymous routing) # External service tiers protected by circuit breakers.
html = await self._tier_tor(url, opts) breaker_for_tier = {
if html and self._is_valid(html): "flaresolverr": "flaresolverr",
return self._result(url, html, "tor") "playwright": "playwright",
"camoufox": "camoufox",
"tls_fingerprint": "tls_fingerprint",
}
for tier_name, tier_fn in tier_order:
# Skip external tiers whose circuit breaker is open.
breaker_name = breaker_for_tier.get(tier_name)
if breaker_name and not registry.is_service_allowed(breaker_name):
logger.debug("tier_skipped_circuit_open", extra={"tier": tier_name, "domain": domain})
errors.append(f"{tier_name}: circuit_open")
continue
start = time.time()
try:
if tier_name == "premium_proxy":
html = await tier_fn(url, proxy_url, opts)
else:
html = await tier_fn(url, opts, proxy_url=proxy_url)
except Exception as e: # noqa: BLE001
logger.debug("tier_exception", extra={"tier": tier_name, "error": str(e)[:50]})
html = None
latency = time.time() - start
success = bool(html and self._is_valid(html))
registry.record_domain_tier_result(domain, tier_name, success, latency)
if breaker_name:
registry.record_service_result(breaker_name, success)
if success:
method = "premium_proxy" if tier_name == "premium_proxy" else tier_name
return self._result(url, html, method)
errors.append(f"{tier_name}_failed")
last_err = errors[-1] if errors else "all_tiers_failed" last_err = errors[-1] if errors else "all_tiers_failed"
return { return {
@ -301,7 +308,7 @@ class UltimateScraper:
logger.debug("premium_proxy_failed", extra={"error": str(e)[:50]}) logger.debug("premium_proxy_failed", extra={"error": str(e)[:50]})
return None return None
async def _tier_cloudscraper(self, url: str, opts: dict[str, Any]) -> str | None: async def _tier_cloudscraper(self, url: str, opts: dict[str, Any], proxy_url: str | None = None) -> str | None:
if not _has_cloudscraper: if not _has_cloudscraper:
return None return None
try: try:
@ -317,7 +324,7 @@ class UltimateScraper:
logger.debug("cloudscraper_failed", extra={"error": str(e)[:50]}) logger.debug("cloudscraper_failed", extra={"error": str(e)[:50]})
return None return None
async def _tier_flaresolverr(self, url: str, opts: dict[str, Any]) -> str | None: async def _tier_flaresolverr(self, url: str, opts: dict[str, Any], proxy_url: str | None = None) -> str | None:
from settings import settings from settings import settings
if not settings.flaresolverr_url: if not settings.flaresolverr_url:
@ -337,7 +344,7 @@ class UltimateScraper:
logger.debug("flaresolverr_failed", extra={"error": str(e)[:50]}) logger.debug("flaresolverr_failed", extra={"error": str(e)[:50]})
return None return None
async def _tier_undetected(self, url: str, opts: dict[str, Any]) -> str | None: async def _tier_undetected(self, url: str, opts: dict[str, Any], proxy_url: str | None = None) -> str | None:
if not _has_undetected: if not _has_undetected:
return None return None
try: try:
@ -364,7 +371,7 @@ class UltimateScraper:
logger.debug("undetected_failed", extra={"error": str(e)[:50]}) logger.debug("undetected_failed", extra={"error": str(e)[:50]})
return None return None
async def _tier_playwright(self, url: str, opts: dict[str, Any]) -> str | None: async def _tier_playwright(self, url: str, opts: dict[str, Any], proxy_url: str | None = None) -> str | None:
if not _has_playwright: if not _has_playwright:
return None return None
try: try:
@ -460,7 +467,7 @@ class UltimateScraper:
logger.debug("googlebot_failed", extra={"error": str(e)[:50]}) logger.debug("googlebot_failed", extra={"error": str(e)[:50]})
return None return None
async def _tier_archive(self, url: str, opts: dict[str, Any]) -> str | None: async def _tier_archive(self, url: str, opts: dict[str, Any], proxy_url: str | None = None) -> str | None:
"""Fetch from Wayback Machine CDX API for latest snapshot.""" """Fetch from Wayback Machine CDX API for latest snapshot."""
try: try:
import httpx import httpx
@ -480,7 +487,7 @@ class UltimateScraper:
logger.debug("archive_failed", extra={"error": str(e)[:50]}) logger.debug("archive_failed", extra={"error": str(e)[:50]})
return None return None
async def _tier_google_cache(self, url: str, opts: dict[str, Any]) -> str | None: async def _tier_google_cache(self, url: str, opts: dict[str, Any], proxy_url: str | None = None) -> str | None:
try: try:
import httpx import httpx
@ -493,7 +500,7 @@ class UltimateScraper:
logger.debug("google_cache_failed", extra={"error": str(e)[:50]}) logger.debug("google_cache_failed", extra={"error": str(e)[:50]})
return None return None
async def _tier_tor(self, url: str, opts: dict[str, Any]) -> str | None: async def _tier_tor(self, url: str, opts: dict[str, Any], proxy_url: str | None = None) -> str | None:
"""Fetch via Tor SOCKS5 proxy.""" """Fetch via Tor SOCKS5 proxy."""
if not _has_tor: if not _has_tor:
return None return None