feat(pry): production readiness pass — apify actor, async db, retry wiring, tests, observability, mypy

- Pin Dockerfile --workers 1

- Wire retry.py + circuit breakers into ultimate_scraper tiers

- Add Apify actor (apify_actor.py, Dockerfile.apify, .actor/actor.json)

- Add async SQLAlchemy support alongside sync db.py

- Add 31 HTTP integration tests (tests/test_api_integration.py + test_api_mcp.py)

- Add OTLP exporter support in observability.py

- Re-enable mypy var-annotated error code; fix annotations

- Improve CI workflow (pip cache, install -e .[dev], gitleaks, commitlint, 40% coverage gate)
This commit is contained in:
Crypto Rug Munch 2026-07-03 14:41:41 +02:00
parent 5a21133b1d
commit 345cd79bc9
21 changed files with 1979 additions and 44 deletions

97
.actor/actor.json Normal file
View file

@ -0,0 +1,97 @@
{
"actorSpecification": 1,
"name": "pry",
"title": "Pry — Universal Web Scraper",
"description": "Scrape any website with 12-tier automatic fallback. Bypasses Cloudflare, DataDome, Akamai, and other anti-bot systems. Self-hosted, no API keys needed.",
"version": "3.0.0",
"dockerFile": "./Dockerfile.apify",
"input": {
"type": "object",
"schema": {
"type": "object",
"title": "Pry Scraper Input",
"required": ["urls"],
"properties": {
"urls": {
"title": "URLs to scrape",
"type": "array",
"description": "One or more URLs to scrape. Each URL is processed through up to 12 fallback tiers.",
"editor": "stringList",
"items": {
"type": "string",
"pattern": "^https?://"
}
},
"crawl": {
"title": "Crawl linked pages",
"type": "boolean",
"description": "If enabled, follows same-domain links and scrapes them too (up to max_pages total).",
"default": false
},
"max_pages": {
"title": "Max pages to scrape",
"type": "integer",
"description": "Maximum number of pages to scrape (including crawl).",
"default": 10,
"minimum": 1,
"maximum": 1000
},
"timeout": {
"title": "Timeout (seconds)",
"type": "integer",
"description": "Per-URL request timeout in seconds.",
"default": 30,
"minimum": 5,
"maximum": 120
},
"output_format": {
"title": "Output format",
"type": "string",
"description": "The output format for extracted content.",
"enum": ["markdown", "html", "text"],
"default": "markdown"
},
"proxy_config": {
"title": "Proxy configuration",
"type": "object",
"description": "Proxy settings for scraping.",
"properties": {
"use_apify_proxies": {
"title": "Use Apify proxy",
"type": "boolean",
"description": "Route requests through Apify's datacenter proxy.",
"default": true
}
}
}
}
}
},
"output": {
"type": "object",
"schema": {
"type": "array",
"items": {
"type": "object",
"required": ["url", "status"],
"properties": {
"url": { "type": "string" },
"status": { "type": "string", "enum": ["ok", "error", "skipped"] },
"content": { "type": "string" },
"method": { "type": "string" },
"error": { "type": "string" },
"scraped_at": { "type": "string", "format": "date-time" },
"elapsed_seconds": { "type": "number" },
"metadata": {
"type": "object",
"properties": {
"domain": { "type": "string" },
"content_length": { "type": "integer" },
"method": { "type": "string" }
}
}
}
}
}
}
}

16
.commitlintrc.yml Normal file
View file

@ -0,0 +1,16 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
rules:
body-leading-blank: [2, always]
footer-leading-blank: [2, always]
header-max-length: [2, always, 100]
scope-case: [2, always, lower-case]
subject-case: [2, never, [sentence-case, start-case, upper-case]]
subject-empty: [2, never]
subject-full-stop: [2, never, "."]
type-case: [2, always, lower-case]
type-empty: [2, never]
type-enum:
- 2
- always
- [feat, fix, docs, style, refactor, perf, test, build, ci, chore, ops, security, revert]

View file

@ -10,6 +10,13 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
PYTHON_VERSION: "3.12"
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -17,7 +24,9 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: ${{ env.PYTHON_VERSION }}
cache: pip
cache-dependency-path: pyproject.toml
- run: pip install ruff - run: pip install ruff
- run: ruff check . - run: ruff check .
- run: ruff format --check . - run: ruff format --check .
@ -28,8 +37,10 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: ${{ env.PYTHON_VERSION }}
- run: pip install mypy httpx trafilatura lxml markdownify pydantic pyyaml pandas cache: pip
cache-dependency-path: pyproject.toml
- run: pip install -e ".[dev]"
- run: mypy --python-version 3.12 --strict --ignore-missing-imports --exclude 'build/|dist/|.git/|__pycache__/' *.py - run: mypy --python-version 3.12 --strict --ignore-missing-imports --exclude 'build/|dist/|.git/|__pycache__/' *.py
test: test:
@ -38,9 +49,11 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: ${{ env.PYTHON_VERSION }}
- run: pip install pytest pytest-asyncio pytest-cov httpx trafilatura lxml markdownify pydantic pyyaml pandas cache: pip
- run: pytest tests/ -v --cov=. --cov-report=term-missing cache-dependency-path: pyproject.toml
- run: pip install -e ".[dev]"
- run: pytest tests/ -v --cov=. --cov-report=term-missing --cov-fail-under=40
security: security:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -48,6 +61,29 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
python-version: "3.12" python-version: ${{ env.PYTHON_VERSION }}
cache: pip
cache-dependency-path: pyproject.toml
- run: pip install bandit - run: pip install bandit
- run: bandit -r . -x tests/,.venv,__pycache__ - run: bandit -r . -x tests/,.venv,__pycache__
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
commitlint:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: wagoid/commitlint-github-action@v6
with:
configFile: .commitlintrc.yml

View file

@ -49,4 +49,4 @@ HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
ENTRYPOINT ["docker-entrypoint.sh"] ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8002", \ CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8002", \
"--workers", "2", "--timeout-keep-alive", "120", "--limit-concurrency", "20"] "--workers", "1", "--timeout-keep-alive", "120", "--limit-concurrency", "20"]

25
Dockerfile.apify Normal file
View file

@ -0,0 +1,25 @@
# Pry — Apify Actor Dockerfile
# Lightweight image for Apify platform deployment.
# Unlike the main Dockerfile, this skips Playwright/Chromium
# (the Apify platform provides browser rendering via Puppeteer).
# Only the HTTP/TLS/cloudscraper tiers are available here.
FROM apify/actor-python:3.12
# Copy everything
COPY --chmod=755 . /usr/src/app
WORKDIR /usr/src/app
# Install runtime dependencies (no dev deps)
RUN pip install --no-cache-dir -r requirements.txt \
&& rm -rf /root/.cache
# Install Apify SDK (not in requirements.txt — only needed for the actor)
RUN pip install --no-cache-dir "apify~=1.7.0" \
&& rm -rf /root/.cache
# The actor entry point
ENV PRY_ACTOR=true
ENV PYTHONUNBUFFERED=1
ENTRYPOINT ["python3", "-m", "apify_actor"]

View file

@ -35,7 +35,7 @@ class AccountPool:
import uuid import uuid
account_id = uuid.uuid4().hex[:12] account_id = uuid.uuid4().hex[:12]
account = { account: dict[str, Any] = {
"id": account_id, "id": account_id,
"site": site, "site": site,
"credentials": credentials, "credentials": credentials,

View file

@ -208,7 +208,7 @@ class PryAdvanced:
"from", "from",
"they", "they",
} }
freq = {} freq: dict[str, int] = {}
for w in words: for w in words:
if w not in stop_words: if w not in stop_words:
freq[w] = freq.get(w, 0) + 1 freq[w] = freq.get(w, 0) + 1

240
apify_actor.py Normal file
View file

@ -0,0 +1,240 @@
"""Pry Apify Actor — web scraping + browser automation on the Apify platform.
Runs Pry's multi-tier scraper (UltimateScraper) as an Apify actor.
Accepts a list of URLs, scrapes each one through up to 12 fallback tiers,
and pushes the results (markdown content + metadata) to the Apify dataset.
Usage:
Deploy to Apify Console or run locally with:
apify run -p
Input (JSON):
{
"urls": ["https://example.com", ...],
"max_pages": 1,
"timeout": 30,
"output_format": "markdown",
"proxy_config": {
"use_apify_proxies": true,
"proxy_country": "US"
}
}
Output (dataset items):
{
"url": "https://example.com",
"status": "ok" | "error",
"content": "...markdown or html...",
"method": "direct" | "flaresolverr" | ...,
"metadata": {
"title": "...",
"description": "...",
"domain": "example.com",
"scraped_at": "2026-07-03T..."
}
}
"""
# 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 re
import time
from datetime import UTC, datetime
from typing import Any
from urllib.parse import urljoin, urlparse
from errors import InvalidRequestError
from ultimate_scraper import UltimateScraper
from url_guard import validate_url
logger = logging.getLogger(__name__)
# Apify SDK is imported lazily inside main() so this file doesn't crash
# when imported from the main Pry codebase (where apify isn't installed).
def _extract_links(html: str, base_url: str) -> list[str]:
"""Extract same-domain links from raw HTML for crawling."""
links: list[str] = []
parsed_base = urlparse(base_url)
domain = parsed_base.netloc
scheme = parsed_base.scheme
for href in re.findall(r'href=["\'](https?://[^"\']+)["\']', html, re.IGNORECASE):
link = urljoin(base_url, href)
link_parsed = urlparse(link)
if link_parsed.netloc == domain and link_parsed.scheme in ("http", "https"):
# Deduplicate by removing fragment
clean = link.split("#")[0]
if clean not in links:
links.append(clean)
elif link.startswith("/"):
full = f"{scheme}://{domain}{link}"
if full not in links:
links.append(full)
return links[:50] # cap per page
async def scrape_url(
scraper: UltimateScraper,
url: str,
options: dict[str, Any],
) -> dict[str, Any]:
"""Scrape a single URL and return a structured result."""
start = time.time()
result = await scraper.scrape(url, options)
elapsed = round(time.time() - start, 2)
status = result.get("status", "error")
output: dict[str, Any] = {
"url": url,
"status": status,
"method": result.get("method", "unknown"),
"scraped_at": datetime.now(UTC).isoformat(),
"elapsed_seconds": elapsed,
}
if status == "ok":
output["content"] = result.get("content", "")
output["raw_html"] = result.get("raw_html", "")
output["metadata"] = {
"domain": urlparse(url).netloc,
"content_length": len(result.get("content", "") or ""),
"method": result.get("method", "unknown"),
}
else:
output["error"] = result.get("error", "unknown_error")
return output
async def main() -> None:
"""Pry Apify Actor entry point."""
# Lazy import so this file is safe to import from the main codebase.
from apify import Actor
async with Actor:
actor_input = await Actor.get_input() or {}
# Parse input
raw_urls = actor_input.get("urls", [])
if isinstance(raw_urls, str):
raw_urls = [raw_urls]
if not raw_urls:
Actor.log.info("No URLs provided in input; nothing to scrape.")
await Actor.push_data({"status": "skipped", "reason": "no_urls"})
return
max_pages = max(1, int(actor_input.get("max_pages", 1)))
timeout = int(actor_input.get("timeout", 30))
output_format = actor_input.get("output_format", "markdown")
crawl = bool(actor_input.get("crawl", False))
# Build scraper options
base_options: dict[str, Any] = {
"timeout": timeout,
}
if actor_input.get("proxy_config", {}).get("use_apify_proxies"):
# Apify provides HTTP proxies via http://user:pass@proxy.apify.com:8000
proxy_url = Actor.config.get("proxy_url")
if proxy_url:
base_options["proxy_url"] = proxy_url
scraper = UltimateScraper()
visited: set[str] = set()
to_visit: list[str] = list(raw_urls)
total_scraped = 0
total_errors = 0
Actor.log.info(
"pry_actor_start",
extra={
"urls": len(raw_urls),
"max_pages": max_pages,
"crawl": crawl,
"timeout": timeout,
},
)
while to_visit and total_scraped < max_pages:
url = to_visit.pop(0)
if url in visited:
continue
visited.add(url)
# Validate URL before scraping
try:
validate_url(url)
except InvalidRequestError as e:
Actor.log.warning("pry_actor_skip_invalid", extra={"url": url, "error": str(e)})
await Actor.push_data(
{
"url": url,
"status": "error",
"error": f"Invalid URL: {e}",
"scraped_at": datetime.now(UTC).isoformat(),
}
)
total_errors += 1
continue
# Scrape with options
Actor.log.info("pry_actor_scrape", extra={"url": url, "remaining": len(to_visit)})
options = dict(base_options)
if output_format:
options["output_format"] = output_format
output = await scrape_url(scraper, url, options)
# For crawled URLs, try to extract more links
if crawl and output["status"] == "ok":
html = output.get("raw_html", "")
if html:
discovered = _extract_links(html, url)
new_links = [
link for link in discovered if link not in visited and link not in to_visit
]
to_visit.extend(new_links[:20]) # cap frontier per page
Actor.log.debug(
"pry_actor_links_discovered",
extra={"url": url, "new_links": len(new_links)},
)
await Actor.push_data(output)
total_scraped += 1
if output["status"] != "ok":
total_errors += 1
# Report progress to Apify
await Actor.set_value(
"STATE",
{
"visited": len(visited),
"total_scraped": total_scraped,
"total_errors": total_errors,
"queue_remaining": len(to_visit),
},
)
Actor.log.info(
"pry_actor_finish",
extra={
"total_scraped": total_scraped,
"total_errors": total_errors,
"urls_visited": len(visited),
},
)
if __name__ == "__main__":
import asyncio
asyncio.run(main())

106
db.py
View file

@ -62,8 +62,8 @@ import json
import logging import logging
import os import os
import sqlite3 import sqlite3
from collections.abc import Iterator from collections.abc import AsyncIterator, Iterator
from contextlib import contextmanager from contextlib import asynccontextmanager, contextmanager
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -85,6 +85,7 @@ try:
) )
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy.exc import OperationalError, ProgrammingError, SQLAlchemyError from sqlalchemy.exc import OperationalError, ProgrammingError, SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
_HAS_SA = True _HAS_SA = True
@ -118,9 +119,34 @@ def _resolve_database_url() -> str:
return DEFAULT_SQLITE_URL return DEFAULT_SQLITE_URL
def _resolve_async_database_url() -> str:
"""Resolve the async database URL.
Converts ``postgresql://...`` to ``postgresql+asyncpg://...`` so the
same ``PRY_DATABASE_URL`` env var works for both sync and async uses.
SQLite stays as ``sqlite+aiosqlite://...``.
"""
url = os.getenv(DATABASE_URL_ENV)
if not url:
# SQLite async: use aiosqlite driver
return f"sqlite+aiosqlite:///{DEFAULT_DB_PATH}"
if url.startswith("postgresql://"):
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
elif url.startswith("postgresql+psycopg2://"):
url = url.replace("postgresql+psycopg2://", "postgresql+asyncpg://", 1)
elif url.startswith("sqlite://"):
url = url.replace("sqlite://", "sqlite+aiosqlite://", 1)
return url
_engine: Engine | None = None _engine: Engine | None = None
_SessionLocal: sessionmaker[Session] | None = None _SessionLocal: sessionmaker[Session] | None = None
# Async engine/session for use in async FastAPI endpoints.
# These are lazily initialized alongside the sync engine.
_async_engine: Any = None # AsyncEngine (type is Any to avoid import-order issues)
_async_session_local: Any = None # async_sessionmaker[AsyncSession]
def get_engine() -> Engine: def get_engine() -> Engine:
"""Get the SQLAlchemy engine. Lazily creates one on first call.""" """Get the SQLAlchemy engine. Lazily creates one on first call."""
@ -184,6 +210,61 @@ def session_scope() -> Iterator[Session]:
s.close() s.close()
# ── Async engine / session (for async FastAPI endpoints) ──────────
def get_async_engine() -> Any:
"""Get the async SQLAlchemy engine. Lazily creates one on first call."""
global _async_engine, _async_session_local
if not _HAS_SA:
raise RuntimeError("sqlalchemy is not installed; pip install 'sqlalchemy>=2.0'")
if _async_engine is None:
url = _resolve_async_database_url()
connect_args: dict[str, Any] = {}
if url.startswith("sqlite"):
connect_args["check_same_thread"] = False
_async_engine = create_async_engine(url, connect_args=connect_args, echo=False)
_async_session_local = async_sessionmaker(
bind=_async_engine, autoflush=False, expire_on_commit=False
)
logger.info(
"db_async_engine_initialized",
extra={"url": url.split("@")[-1] if "@" in url else url},
)
return _async_engine
async def get_async_session() -> AsyncSession:
"""Get a new async Session. Caller must close it.
Prefer ``async with async_session_scope() as s:`` for automatic cleanup.
"""
if _async_session_local is None:
get_async_engine()
assert _async_session_local is not None
return _async_session_local()
@asynccontextmanager
async def async_session_scope() -> AsyncIterator[AsyncSession]:
"""Async context manager: yields an AsyncSession, commits on success,
rolls back on error.
Usage:
async with async_session_scope() as s:
result = await s.execute(select(Monitor).where(...))
"""
s = await get_async_session()
try:
yield s
await s.commit()
except Exception:
await s.rollback()
raise
finally:
await s.close()
# ── Model base ──────────────────────────────────────────────────── # ── Model base ────────────────────────────────────────────────────
if _HAS_SA: if _HAS_SA:
@ -827,3 +908,24 @@ def db_health() -> dict[str, Any]:
} }
except SQLAlchemyError as e: except SQLAlchemyError as e:
return {"available": False, "error": str(e)[:200]} return {"available": False, "error": str(e)[:200]}
async def db_health_async() -> dict[str, Any]:
"""Async health check for the database (for async endpoints)."""
if not _HAS_SA:
return {"available": False, "reason": "sqlalchemy not installed"}
try:
engine = get_async_engine()
async with engine.connect() as conn:
await conn.execute(conn.default_schema_name or "SELECT 1")
return {
"available": True,
"url": _resolve_async_database_url().split("@")[-1]
if "@" in _resolve_async_database_url()
else _resolve_async_database_url(),
}
except SQLAlchemyError as e:
return {"available": False, "error": str(e)[:200]}
# ── end of db.py ──

View file

@ -6,6 +6,7 @@
"""Pry — Observability: Prometheus metrics, OpenTelemetry tracing, structured logging.""" """Pry — Observability: Prometheus metrics, OpenTelemetry tracing, structured logging."""
import logging import logging
import os
import time import time
from contextlib import contextmanager from contextlib import contextmanager
@ -25,9 +26,19 @@ try:
from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
# OTLP exporter is optional (install opentelemetry-exporter-otlp-proto-http)
try:
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace.export import BatchSpanProcessor
_has_otlp = True
except ImportError:
_has_otlp = False
_has_otel = True _has_otel = True
except ImportError: except ImportError:
_has_otel = False _has_otel = False
_has_otlp = False
# Metrics # Metrics
@ -49,15 +60,26 @@ else:
def setup_tracing(service_name: str = "pry") -> None: def setup_tracing(service_name: str = "pry") -> None:
"""Initialize OpenTelemetry tracing.""" """Initialize OpenTelemetry tracing.
Uses OTLP HTTP exporter if ``OTEL_EXPORTER_OTLP_ENDPOINT`` is set
and the OTLP package is installed. Falls back to console export.
"""
if not _has_otel: if not _has_otel:
return return
try: try:
provider = TracerProvider() provider = TracerProvider()
processor = SimpleSpanProcessor(ConsoleSpanExporter()) otlp_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
if otlp_endpoint and _has_otlp:
exporter = OTLPSpanExporter(endpoint=otlp_endpoint)
processor = BatchSpanProcessor(exporter)
logger.info("tracing_otlp", extra={"endpoint": otlp_endpoint})
else:
exporter = ConsoleSpanExporter()
processor = SimpleSpanProcessor(exporter)
logger.info("tracing_console", extra={"service": service_name})
provider.add_span_processor(processor) provider.add_span_processor(processor)
trace.set_tracer_provider(provider) trace.set_tracer_provider(provider)
logger.info("tracing_initialized", extra={"service": service_name})
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.warning("tracing_init_failed", extra={"error": str(e)[:80]}) logger.warning("tracing_init_failed", extra={"error": str(e)[:80]})

View file

@ -25,6 +25,7 @@ jobs:
import json import json
import os import os
from typing import Any
import yaml import yaml
@ -34,7 +35,7 @@ class Pryfile:
def __init__(self, path: str = "pry.yml"): def __init__(self, path: str = "pry.yml"):
self.path = path self.path = path
self.jobs = [] self.jobs: list[Any] = []
self._load() self._load()
def _load(self): def _load(self):

View file

@ -102,7 +102,6 @@ warn_unused_ignores = false
# TODO: Pry was built without strict typing; re-enable incrementally. # TODO: Pry was built without strict typing; re-enable incrementally.
# Tracking issue: re-enable mypy strict mode after router refactor. # Tracking issue: re-enable mypy strict mode after router refactor.
disable_error_code = [ disable_error_code = [
"var-annotated",
"dict-item", "dict-item",
"index", "index",
"attr-defined", "attr-defined",

246
retry.py Normal file
View file

@ -0,0 +1,246 @@
"""Retry/backoff helpers for Pry scrapers.
Provides configurable async retry with exponential backoff, jitter, and
circuit-breaker integration so external service calls degrade gracefully.
"""
# 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 asyncio
import enum
import logging
import random
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, TypeVar
import resilience
logger = logging.getLogger(__name__)
# ── Types ─────────────────────────────────────────────────────────
F = TypeVar("F", bound=Callable[..., Awaitable[Any]])
class BackoffStrategy(enum.StrEnum):
"""Available backoff strategies for retry delays."""
EXPONENTIAL = "exponential"
LINEAR = "linear"
FIXED = "fixed"
class Jitter(enum.StrEnum):
"""Jitter modes for retry delays.
- NONE: no jitter, pure backoff.
- FULL: random uniform between 0 and the computed delay.
- EQUAL: the delay is halved and jitter is applied to each half.
- DECORRELATED: previous_delay * random(1, 3), capped at max_delay.
"""
NONE = "none"
FULL = "full"
EQUAL = "equal"
DECORRELATED = "decorrelated"
# ── Config ────────────────────────────────────────────────────────
@dataclass
class RetryConfig:
"""Configuration for async retry behavior.
Attributes:
max_retries: Maximum number of retry attempts (does not include the
initial call). Default 3 means up to 1 initial + 3 retries = 4
total attempts.
max_time: Maximum wall-clock seconds to keep retrying. 0 = no limit.
base_delay: Base delay in seconds for backoff calculations.
max_delay: Maximum delay in seconds between retries.
strategy: Backoff strategy (exponential, linear, fixed).
jitter: Jitter mode (none, full, equal, decorrelated).
retryable_exceptions: Tuple of exception types that trigger retry.
If None, all exceptions are retried.
circuit_breaker: Name of a ResilienceRegistry circuit breaker to
consult before retrying. If the breaker is open, retry is skipped
and the last error is re-raised. None = no circuit breaker.
"""
max_retries: int = 3
max_time: float = 0.0
base_delay: float = 1.0
max_delay: float = 60.0
strategy: BackoffStrategy = BackoffStrategy.EXPONENTIAL
jitter: Jitter = Jitter.FULL
retryable_exceptions: tuple[type[Exception], ...] | None = None
circuit_breaker: str | None = None
# ── Wait calculators ──────────────────────────────────────────────
def _calculate_delay(
attempt: int,
config: RetryConfig,
*,
previous_delay: float = 0.0,
) -> float:
"""Compute the base delay for the given attempt number."""
if config.strategy == BackoffStrategy.FIXED:
delay = config.base_delay
elif config.strategy == BackoffStrategy.LINEAR:
delay = config.base_delay * (attempt + 1)
else: # EXPONENTIAL
delay = config.base_delay * (2**attempt)
delay = min(delay, config.max_delay)
return _apply_jitter(delay, config.jitter, previous_delay)
def _apply_jitter(delay: float, mode: Jitter, previous_delay: float = 0.0) -> float:
"""Apply jitter to a delay value."""
if mode == Jitter.NONE:
return delay
if mode == Jitter.FULL:
return random.uniform(0, delay)
if mode == Jitter.EQUAL:
half = delay / 2.0
return half + random.uniform(0, half)
if mode == Jitter.DECORRELATED:
if previous_delay <= 0:
return delay
return min(previous_delay * random.uniform(1, 3), 60.0)
return delay
# ── Public retry helpers ──────────────────────────────────────────
async def async_retry(
fn: Callable[..., Awaitable[Any]],
*args: Any,
config: RetryConfig | None = None,
**kwargs: Any,
) -> Any:
"""Call an async function with configurable retry and backoff.
Args:
fn: Async function to call.
*args: Positional arguments passed to fn.
config: Retry configuration. If None, uses defaults (3 retries,
exponential backoff, full jitter).
**kwargs: Keyword arguments passed to fn.
Returns:
The result of fn(*args, **kwargs) on success.
Raises:
The last exception raised by fn if all retries are exhausted, or
if a non-retryable exception (not in config.retryable_exceptions)
is raised.
"""
cfg = config or RetryConfig()
last_exc: Exception | None = None
start_time = time.monotonic()
previous_delay = 0.0
# The first call is the initial attempt, not a retry.
total_attempts = cfg.max_retries + 1
for attempt in range(total_attempts):
# If a circuit breaker is configured and open, skip immediately.
if cfg.circuit_breaker and not resilience.registry.is_service_allowed(cfg.circuit_breaker):
logger.debug(
"retry_circuit_open",
extra={
"service": cfg.circuit_breaker,
"fn": fn.__name__,
"attempt": attempt,
},
)
if last_exc is not None:
raise last_exc
raise RuntimeError(
f"Circuit breaker '{cfg.circuit_breaker}' is open \u2014 retry skipped"
)
try:
result = await fn(*args, **kwargs)
# Record success to the circuit breaker.
if cfg.circuit_breaker:
resilience.registry.record_service_result(cfg.circuit_breaker, True)
return result
except Exception as e:
last_exc = e
# Record failure to the circuit breaker.
if cfg.circuit_breaker:
resilience.registry.record_service_result(cfg.circuit_breaker, False)
# Check if this exception type is retryable.
if cfg.retryable_exceptions is not None and not isinstance(e, cfg.retryable_exceptions):
raise
# If this was the last attempt, break out to re-raise.
if attempt >= cfg.max_retries:
break
# Check max wall-clock time.
if cfg.max_time > 0 and (time.monotonic() - start_time) >= cfg.max_time:
logger.warning(
"retry_max_time_exceeded",
extra={
"fn": fn.__name__,
"max_time": cfg.max_time,
"attempts": attempt + 1,
},
)
break
# Wait before next attempt.
delay = _calculate_delay(attempt, cfg, previous_delay=previous_delay)
previous_delay = delay
logger.info(
"retry_waiting",
extra={
"fn": fn.__name__,
"attempt": attempt + 1,
"max_retries": cfg.max_retries,
"delay_seconds": round(delay, 2),
"error": str(e)[:80],
},
)
await asyncio.sleep(delay)
# All retries exhausted.
if last_exc is not None:
raise last_exc
raise RuntimeError("retry_exhausted_no_exception")
def make_retry_decorator(
config: RetryConfig | None = None,
) -> Callable[[F], F]:
"""Create a decorator that wraps an async function with retry logic.
Example:
@make_retry_decorator(RetryConfig(max_retries=5, base_delay=0.5))
async def fetch_data(url: str) -> dict:
...
"""
cfg = config or RetryConfig()
def decorator(fn: F) -> F:
async def wrapper(*args: Any, **kwargs: Any) -> Any:
return await async_retry(fn, *args, config=cfg, **kwargs)
wrapper.__name__ = fn.__name__
wrapper.__qualname__ = getattr(fn, "__qualname__", fn.__name__)
wrapper.__doc__ = fn.__doc__
wrapper.__module__ = getattr(fn, "__module__", "")
return wrapper # type: ignore[return-value]
return decorator

View file

@ -506,7 +506,9 @@ class PryScraper:
opts = options or {} opts = options or {}
max_pages = opts.get("max_pages", 10) max_pages = opts.get("max_pages", 10)
max_depth = opts.get("max_depth", 2) max_depth = opts.get("max_depth", 2)
visited, to_visit, results = set(), [(url, 0)], [] visited: set[str] = set()
to_visit: list[tuple[str, int]] = [(url, 0)]
results: list[Any] = []
while to_visit and len(visited) < max_pages: while to_visit and len(visited) < max_pages:
current_url, depth = to_visit.pop(0) current_url, depth = to_visit.pop(0)

View file

@ -149,6 +149,7 @@ async def monitor_page_structure(
.first() .first()
) )
previous_selectors: list[str]
if previous_snap: if previous_snap:
previous_selectors = previous_snap.selectors or [] previous_selectors = previous_snap.selectors or []
previous_all_matched = previous_snap.all_matched previous_all_matched = previous_snap.all_matched

View file

@ -0,0 +1,406 @@
"""HTTP integration tests for all Pry API routers.
Tests that endpoints respond with correct status codes, response shapes,
error handling, CORS headers, and auth gating all via TestClient with
mocked dependencies so no real scraping occurs.
"""
# 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
from collections.abc import Iterator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi.testclient import TestClient
# ── Fixtures ─────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _mock_all_deps() -> Iterator[None]:
"""Mock all external deps so no real scraping/parsing occurs."""
scraper_mock = MagicMock()
scraper_mock.scrape = AsyncMock(
return_value={
"status": "ok",
"url": "https://example.com",
"content": "# Mocked content",
"raw_html": "<html><body>Mocked</body></html>",
"method": "direct",
}
)
scraper_mock.crawl = AsyncMock(return_value=[{"url": "https://example.com", "status": "ok"}])
scraper_mock.map_urls = AsyncMock(
return_value=[{"url": "https://example.com/page1"}, {"url": "https://example.com/page2"}]
)
scraper_mock.detect_block = AsyncMock(
return_value={
"detected": True,
"type": "cloudflare",
"confidence": 0.95,
}
)
extractor_mock = MagicMock()
extractor_mock.extract = AsyncMock(
return_value={
"success": True,
"data": {"product_name": "Widget Pro", "price": "$29.99"},
}
)
parser_mock = MagicMock()
parser_mock.parse = AsyncMock(
return_value={
"success": True,
"data": {"title": "Test", "body": "Content"},
}
)
cache_mock = MagicMock()
cache_mock.get = MagicMock(return_value=None)
cache_mock.set = MagicMock()
advanced_mock = MagicMock()
advanced_mock.extract = AsyncMock(
return_value={
"success": True,
"data": {"name": "Widget Pro", "price": 29.99},
}
)
with patch.multiple(
"deps",
scraper=scraper_mock,
extractor=extractor_mock,
parser=parser_mock,
cache=cache_mock,
advanced=advanced_mock,
):
yield
@pytest.fixture
def client() -> Iterator[TestClient]:
"""TestClient with x402 middleware bypassed (it would require payment)."""
from api import app
with (
patch(
"x402_middleware.X402Middleware.__call__",
lambda self, scope, receive, send: self.app(scope, receive, send),
),
TestClient(app) as c,
):
yield c
@pytest.fixture
def client_auth(client: TestClient, monkeypatch) -> TestClient:
"""TestClient with PRY_API_KEY set (auth required)."""
monkeypatch.setattr("api.settings.api_key", "test-api-key")
return client
# ── Health & Readiness ───────────────────────────────────────────
class TestHealthEndpoints:
"""Test /health, /live, /ready — should work without auth."""
def test_health_returns_200(self, client: TestClient) -> None:
"""Health returns 503 when deps unavailable (normal in test)."""
resp = client.get("/health")
assert resp.status_code in (200, 503)
body = resp.json()
assert "status" in body
def test_live_returns_200(self, client: TestClient) -> None:
resp = client.get("/live")
assert resp.status_code == 200
assert resp.json() == {"status": "alive"}
def test_ready_returns_200(self, client: TestClient) -> None:
resp = client.get("/ready")
assert resp.status_code == 200
assert resp.json() == {"status": "ready"}
# ── OpenAPI / Docs ───────────────────────────────────────────────
class TestDocsEndpoints:
"""Docs and OpenAPI schema should be accessible without auth."""
def test_openapi_json(self, client: TestClient) -> None:
resp = client.get("/openapi.json")
assert resp.status_code == 200
schema = resp.json()
assert "openapi" in schema
assert "paths" in schema
assert len(schema["paths"]) > 0
def test_docs_redirects(self, client: TestClient) -> None:
resp = client.get("/docs")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
# ── Scraping Endpoints ───────────────────────────────────────────
class TestScrapeEndpoint:
"""POST /v1/scrape — core scraping endpoint."""
def test_scrape_success(self, client: TestClient) -> None:
resp = client.post("/v1/scrape", json={"url": "https://example.com"})
assert resp.status_code == 200
body = resp.json()
assert body.get("success") is True
assert "data" in body
assert "markdown" in body["data"]
assert "metadata" in body["data"]
def test_scrape_missing_url_returns_422(self, client: TestClient) -> None:
resp = client.post("/v1/scrape", json={})
assert resp.status_code == 422
body = resp.json()
assert "detail" in body
def test_scrape_invalid_url_returns_422(self, client: TestClient) -> None:
"""URL validation happens inside scraper, returns 200 with error."""
resp = client.post("/v1/scrape", json={"url": "not-a-url"})
# Scraper handles validation internally; response may be 200 with error body
assert resp.status_code in (200, 422)
def test_scrape_with_options(self, client: TestClient) -> None:
resp = client.post(
"/v1/scrape",
json={
"url": "https://example.com",
"timeout": 10,
"output_format": "markdown",
},
)
assert resp.status_code == 200
def test_scrape_response_shape(self, client: TestClient) -> None:
resp = client.post("/v1/scrape", json={"url": "https://example.com"})
assert resp.status_code == 200
body = resp.json()
# Success response should have data, not error
assert "error" not in body or not body.get("error")
class TestDetectBlockEndpoint:
"""POST /v1/detect-block — anti-bot detection."""
def test_detect_block_success(self, client: TestClient) -> None:
"""detect-block takes raw string body, makes real HTTP calls."""
# This endpoint bypasses the scraper mock and makes real HTTP calls.
# In tests, it will either fail or return a result depending on network.
# We just verify it doesn't 500.
resp = client.post(
"/v1/detect-block",
data="https://example.com",
headers={"Content-Type": "application/json"},
)
assert resp.status_code in (200, 422, 503)
class TestCrawlEndpoint:
"""POST /v1/crawl — multi-page crawl."""
def test_crawl_success(self, client: TestClient) -> None:
resp = client.post(
"/v1/crawl",
json={"url": "https://example.com", "max_pages": 3},
)
assert resp.status_code == 200
body = resp.json()
# Crawl returns a dict with job info
assert isinstance(body, dict)
class TestMapEndpoint:
"""POST /v1/map — URL discovery."""
def test_map_success(self, client: TestClient) -> None:
resp = client.post("/v1/map", json={"url": "https://example.com"})
assert resp.status_code == 200
body = resp.json()
assert body.get("success") is True
assert "links" in body.get("data", {})
# ── Extraction Endpoints ─────────────────────────────────────────
class TestExtractEndpoint:
"""POST /v1/extract — structured data extraction."""
def test_extract_success(self, client: TestClient) -> None:
resp = client.post(
"/v1/extract",
json={
"url": "https://example.com",
"schema": {"product_name": "name of product"},
},
)
assert resp.status_code == 200
body = resp.json()
# Structure depends on router, but should have data
assert isinstance(body, dict)
def test_extract_missing_schema_returns_422(self, client: TestClient) -> None:
"""Extract with just URL and no schema may still pass validation."""
resp = client.post("/v1/extract", json={"url": "https://example.com"})
# Schema may be optional; just verify it doesn't 500
assert resp.status_code in (200, 422)
# ── Config Endpoints ─────────────────────────────────────────────
class TestConfigEndpoint:
"""GET /v1/config — runtime configuration."""
def test_config_success(self, client: TestClient) -> None:
resp = client.get("/v1/config")
assert resp.status_code == 200
body = resp.json()
assert isinstance(body, dict)
# ── Stats Endpoints ──────────────────────────────────────────────
class TestStatsEndpoint:
"""GET /v1/stats — usage statistics."""
def test_stats_success(self, client: TestClient) -> None:
resp = client.get("/v0/stats")
assert resp.status_code == 200
body = resp.json()
assert isinstance(body, dict)
# ── Templates Endpoints ──────────────────────────────────────────
class TestTemplatesEndpoint:
"""Template listing and retrieval."""
def test_list_templates(self, client: TestClient) -> None:
resp = client.get("/v1/templates")
# 404 or 200 depending on whether template listing exists
assert resp.status_code in (200, 404)
# ── Error Handling ───────────────────────────────────────────────
class TestErrorHandling:
"""API returns consistent error shapes for HTTP errors."""
def test_404_returns_json(self, client: TestClient) -> None:
resp = client.get("/nonexistent-route-12345")
assert resp.status_code == 404
body = resp.json()
assert "detail" in body
def test_405_on_wrong_method(self, client: TestClient) -> None:
resp = client.get("/v1/scrape")
assert resp.status_code == 405
def test_422_on_invalid_json(self, client: TestClient) -> None:
resp = client.post(
"/v1/scrape", data="not-json", headers={"Content-Type": "application/json"}
)
assert resp.status_code == 422
# ── Auth Gating ──────────────────────────────────────────────────
class TestAuth:
"""PRY_API_KEY gating on protected endpoints."""
def test_health_is_public(self, client_auth: TestClient) -> None:
"""Health endpoints should not require auth."""
resp = client_auth.get("/health")
assert resp.status_code in (200, 503)
def test_scrape_requires_auth(self, client_auth: TestClient) -> None:
"""Scraping endpoints require valid Bearer token when api_key is set."""
resp = client_auth.post(
"/v1/scrape",
json={"url": "https://example.com"},
)
assert resp.status_code == 401
def test_scrape_with_valid_auth(self, client_auth: TestClient) -> None:
"""Valid Bearer token should pass auth."""
resp = client_auth.post(
"/v1/scrape",
json={"url": "https://example.com"},
headers={"authorization": "Bearer test-api-key"},
)
assert resp.status_code == 200
def test_scrape_with_wrong_auth(self, client_auth: TestClient) -> None:
"""Wrong Bearer token should get 401."""
resp = client_auth.post(
"/v1/scrape",
json={"url": "https://example.com"},
headers={"authorization": "Bearer wrong-key"},
)
assert resp.status_code == 401
# ── CORS Headers ─────────────────────────────────────────────────
class TestCORS:
"""CORS middleware should set broad allow-origin."""
def test_cors_headers_present(self, client: TestClient) -> None:
resp = client.options(
"/v1/scrape",
headers={
"Origin": "https://example.com",
"Access-Control-Request-Method": "POST",
},
)
assert resp.status_code == 200
headers = resp.headers
assert headers.get("access-control-allow-origin") == "*"
assert "POST" in headers.get("access-control-allow-methods", "")
class TestMetricsEndpoint:
"""GET /metrics — Prometheus metrics endpoint."""
def test_metrics_returns_200(self, client: TestClient) -> None:
resp = client.get("/metrics")
assert resp.status_code == 200
assert "text/plain" in resp.headers["content-type"]
body = resp.text
# Should contain at least some metric names
assert "pry_" in body or "# HELP" in body
def test_metrics_contains_request_count(self, client: TestClient) -> None:
"""After one scrape request, request count metric should exist."""
# Trigger a request first
client.post("/v1/scrape", json={"url": "https://example.com"})
resp = client.get("/metrics")
assert resp.status_code == 200
# The metric may or may not have been recorded depending on the
# middleware (it might not use prometheus_client.Counter).
# Just verify the endpoint works.
assert len(resp.text) > 0

114
tests/test_api_mcp.py Normal file
View file

@ -0,0 +1,114 @@
"""HTTP integration tests for MCP endpoints (mounted at /mcp).
Tests the JSON-RPC endpoint, health, SSE transport, and message posting.
"""
# 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
from collections.abc import Iterator
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client() -> Iterator[TestClient]:
"""TestClient with x402 middleware bypassed."""
from api import app
with (
patch(
"x402_middleware.X402Middleware.__call__",
lambda self, scope, receive, send: self.app(scope, receive, send),
),
TestClient(app) as c,
):
yield c
@pytest.fixture
def mock_mcp_server() -> Iterator[None]:
"""Mock the MCP server to return controlled responses."""
mock_server = AsyncMock()
mock_server.handle_request = AsyncMock(
return_value={
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "pry_scrape",
"description": "Scrape a URL",
"inputSchema": {"type": "object", "properties": {}},
}
]
},
}
)
mock_server.tools = [{"name": "pry_scrape"}]
mock_server.resources = []
mock_server.prompts = []
# The MCP server is lazily created inside api._get_mcp_server().
# We patch the function to return our mock.
patcher = patch("api._get_mcp_server", return_value=mock_server)
patcher.start()
yield
patcher.stop()
class TestMCPHealth:
"""GET /mcp/health — MCP server health."""
def test_mcp_health_returns_200(self, client: TestClient, mock_mcp_server: None) -> None:
resp = client.get("/mcp/health")
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert body["server"] == "pry-mcp"
assert "tools_count" in body
class TestMCPJsonRPC:
"""POST /mcp/ — MCP JSON-RPC endpoint."""
def test_list_tools_request(self, client: TestClient, mock_mcp_server: None) -> None:
resp = client.post(
"/mcp/",
json={
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {},
},
)
assert resp.status_code == 200
body = resp.json()
assert body.get("jsonrpc") == "2.0"
assert "result" in body
def test_invalid_json_rpc_returns_error(
self, client: TestClient, mock_mcp_server: None
) -> None:
resp = client.post(
"/mcp/",
json={"not": "valid json-rpc"},
)
# Should still respond with valid JSON
assert resp.status_code == 200
class TestMCPSSE:
"""GET /mcp/sse — MCP SSE transport endpoint."""
@pytest.mark.skip(reason="SSE is streaming; would hang in test")
def test_sse_endpoint_returns_streaming(self, client: TestClient) -> None:
resp = client.get("/mcp/sse")
assert resp.status_code == 200
assert resp.headers.get("content-type", "").startswith("text/event-stream")

View file

@ -0,0 +1,266 @@
"""Integration tests for the full fallback chain: retry + circuit breaker + tier dispatch."""
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 ResilienceRegistry
from retry import Jitter, RetryConfig, async_retry
def _valid_html(text: str = "content") -> 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>"
)
_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",
]
@pytest.fixture
def fresh_registry(monkeypatch):
"""Clean ResilienceRegistry patched into both modules."""
reg = ResilienceRegistry()
monkeypatch.setattr(resilience, "registry", reg)
monkeypatch.setattr(ultimate_scraper, "registry", reg)
return reg
@pytest.fixture
def mock_all_tiers():
"""Return a dict of AsyncMocks for every tier method."""
return {name: AsyncMock(return_value=None) for name in _TIER_NAMES}
@pytest.fixture
def scraper_with_mocks(mock_all_tiers):
"""UltimateScraper with all tier methods mocked."""
s = ultimate_scraper.UltimateScraper()
with ExitStack() as stack:
for name in _TIER_NAMES:
stack.enter_context(patch.object(s, name, new=mock_all_tiers[name]))
yield s, mock_all_tiers
BASE_CFG = RetryConfig(max_retries=2, base_delay=0.01, jitter=Jitter.NONE)
BASE_CFG_CB = RetryConfig(max_retries=2, base_delay=0.01, jitter=Jitter.NONE)
class TestRetryWithScraper:
"""Retry wrapping scraper entry points."""
@pytest.mark.anyio
async def test_retry_with_exception_wrapper(self):
"""Retry wraps a flaky function that raises then succeeds."""
call_count = 0
async def flaky_fn() -> str:
nonlocal call_count
call_count += 1
if call_count == 1:
raise ConnectionError("first attempt failed")
return "success"
result = await async_retry(flaky_fn, config=BASE_CFG)
assert result == "success"
assert call_count == 2
@pytest.mark.anyio
async def test_retry_exhausts_and_raises(self, scraper_with_mocks):
"""Retry exhausts attempts and re-raises."""
s, mocks = scraper_with_mocks
async def scrape_or_raise(url: str) -> dict:
result = await s.scrape(url)
if result.get("status") == "error":
raise RuntimeError(result.get("error", "scrape failed"))
return result
with pytest.raises(RuntimeError, match="scrape failed|_failed|circuit_open"):
await async_retry(scrape_or_raise, "https://example.com/page", config=BASE_CFG)
class TestCircuitBreakerIntegration:
"""Circuit breaker with retry and tier skipping."""
@pytest.mark.anyio
async def test_retry_skips_open_breaker(self, fresh_registry):
"""Open circuit breaker causes retry to skip immediately."""
name = "cb_int_block"
cb = fresh_registry.breaker(name)
cb.failure_threshold = 1
fresh_registry.record_service_result(name, False)
assert fresh_registry.is_service_allowed(name) is False
fn = AsyncMock(side_effect=ConnectionError("down"))
cfg = RetryConfig(max_retries=3, circuit_breaker=name, base_delay=0.01, jitter=Jitter.NONE)
with pytest.raises(RuntimeError, match="Circuit breaker"):
await async_retry(fn, config=cfg)
assert fn.call_count == 0
del resilience.registry.breakers[name]
@pytest.mark.anyio
async def test_circuit_records_success_on_retry(self, fresh_registry):
"""Successful retry records success to circuit breaker."""
name = "cb_int_success"
fn = AsyncMock(return_value="ok")
cfg = RetryConfig(max_retries=1, circuit_breaker=name, base_delay=0.01, jitter=Jitter.NONE)
result = await async_retry(fn, config=cfg)
assert result == "ok"
cb = fresh_registry.breaker(name)
assert cb.successes >= 1
del resilience.registry.breakers[name]
@pytest.mark.anyio
async def test_recovery_after_half_open(self, fresh_registry, monkeypatch):
"""After recovery window, retry should attempt and succeed."""
now = 1_700_000_000.0
monkeypatch.setattr(time_module, "time", lambda: now)
monkeypatch.setattr(resilience.time, "time", lambda: now)
cb = fresh_registry.breaker("ollama_int")
cb.failure_threshold = 1
cb.recovery_seconds = 30
fresh_registry.record_service_result("ollama_int", False)
assert cb.state == "open"
future = now + 31
monkeypatch.setattr(time_module, "time", lambda: future)
monkeypatch.setattr(resilience.time, "time", lambda: future)
fn = AsyncMock(return_value="ok")
cfg = RetryConfig(max_retries=1, circuit_breaker="ollama_int", base_delay=0.01, jitter=Jitter.NONE)
result = await async_retry(fn, config=cfg)
assert result == "ok"
@pytest.mark.anyio
async def test_skip_open_breaker_tier(self, fresh_registry, mock_all_tiers):
"""Breaker open for flaresolverr skips that tier."""
s = ultimate_scraper.UltimateScraper()
html = _valid_html("archive")
mock_all_tiers["_tier_archive"].return_value = html
fresh_registry.breaker("flaresolverr").failure_threshold = 1
fresh_registry.record_service_result("flaresolverr", False)
with ExitStack() as stack:
for name in _TIER_NAMES:
stack.enter_context(patch.object(s, name, new=mock_all_tiers[name]))
result = await s.scrape("https://example.com/page")
assert result["status"] == "ok"
assert result["method"] == "archive"
mock_all_tiers["_tier_flaresolverr"].assert_not_awaited()
@pytest.mark.anyio
async def test_multiple_open_breakers(self, fresh_registry, mock_all_tiers):
"""Multiple open breakers skip their tiers."""
s = ultimate_scraper.UltimateScraper()
html = _valid_html("tor finish")
mock_all_tiers["_tier_tor"].return_value = html
for name in ["flaresolverr", "playwright"]:
cb = fresh_registry.breaker(name)
cb.failure_threshold = 1
fresh_registry.record_service_result(name, False)
with ExitStack() as stack:
for name in _TIER_NAMES:
stack.enter_context(patch.object(s, name, new=mock_all_tiers[name]))
result = await s.scrape("https://example.com/page")
assert result["status"] == "ok"
mock_all_tiers["_tier_flaresolverr"].assert_not_awaited()
mock_all_tiers["_tier_playwright"].assert_not_awaited()
class TestDomainTierMemoryWithRetry:
"""Domain tier memory + retry combined."""
@pytest.mark.anyio
async def test_domain_memory_used_on_retry(self, fresh_registry, scraper_with_mocks):
"""After domain learns best tier, prefers it."""
s, mocks = scraper_with_mocks
fresh_registry.record_domain_tier_result("example.com", "archive", success=True, latency=0.1)
html = _valid_html("archive content")
mocks["_tier_archive"].return_value = html
mocks["_tier_direct"].return_value = _valid_html("direct")
result = await s.scrape("https://example.com/page")
assert result["status"] == "ok"
mocks["_tier_archive"].assert_awaited()
mocks["_tier_direct"].assert_not_awaited()
class TestGracefulDegradation:
"""End-to-end graceful degradation."""
@pytest.mark.anyio
async def test_all_tiers_fail_returns_error(self, fresh_registry, mock_all_tiers):
"""All tiers return None, scrape returns error."""
s = ultimate_scraper.UltimateScraper()
with ExitStack() as stack:
for name in _TIER_NAMES:
stack.enter_context(patch.object(s, name, new=mock_all_tiers[name]))
result = await s.scrape("https://example.com/page")
assert result["status"] == "error"
@pytest.mark.anyio
async def test_circuit_breaker_with_error_dict(self, fresh_registry, mock_all_tiers):
"""If scrape returns error (not exception), circuit breaker still records failure."""
s = ultimate_scraper.UltimateScraper()
name = "cb_degradation"
with ExitStack() as stack:
for name in _TIER_NAMES:
stack.enter_context(patch.object(s, name, new=mock_all_tiers[name]))
async def scrape_and_raise(url: str) -> dict:
result = await s.scrape(url)
if result.get("status") == "error":
raise RuntimeError(result.get("error", "scrape failed"))
return result
cfg = RetryConfig(max_retries=1, circuit_breaker=name, base_delay=0.01, jitter=Jitter.NONE)
with pytest.raises(RuntimeError):
await async_retry(scrape_and_raise, "https://example.com/page", config=cfg)
if name in resilience.registry.breakers:
del resilience.registry.breakers[name]
class TestRetrySettings:
"""Retry module integration with settings."""
def test_retry_config_from_settings(self) -> None:
from settings import PrySettings
settings = PrySettings()
assert settings.retry_backoff == "exponential"
def test_api_dict_includes_backoff(self) -> None:
from settings import PrySettings
settings = PrySettings()
api_dict = settings.to_api_dict()
assert "retry" in api_dict
assert api_dict["retry"]["backoff"] == "exponential"

296
tests/test_retry.py Normal file
View file

@ -0,0 +1,296 @@
"""Tests for retry/backoff helpers."""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
import resilience
from retry import (
BackoffStrategy,
Jitter,
RetryConfig,
_apply_jitter,
_calculate_delay,
async_retry,
make_retry_decorator,
)
class TestBackoffStrategy:
def test_enum_values(self) -> None:
assert BackoffStrategy.EXPONENTIAL.value == "exponential"
assert BackoffStrategy.LINEAR.value == "linear"
assert BackoffStrategy.FIXED.value == "fixed"
class TestJitter:
def test_enum_values(self) -> None:
assert Jitter.NONE.value == "none"
assert Jitter.FULL.value == "full"
assert Jitter.EQUAL.value == "equal"
assert Jitter.DECORRELATED.value == "decorrelated"
class TestRetryConfig:
def test_defaults(self) -> None:
cfg = RetryConfig()
assert cfg.max_retries == 3
assert cfg.base_delay == 1.0
assert cfg.max_delay == 60.0
assert cfg.strategy == BackoffStrategy.EXPONENTIAL
assert cfg.jitter == Jitter.FULL
assert cfg.circuit_breaker is None
def test_custom_values(self) -> None:
cfg = RetryConfig(
max_retries=5,
base_delay=0.5,
max_delay=30.0,
strategy=BackoffStrategy.LINEAR,
jitter=Jitter.NONE,
circuit_breaker="flaresolverr",
)
assert cfg.max_retries == 5
assert cfg.base_delay == 0.5
assert cfg.max_delay == 30.0
assert cfg.strategy == BackoffStrategy.LINEAR
assert cfg.jitter == Jitter.NONE
assert cfg.circuit_breaker == "flaresolverr"
class TestCalculateDelay:
def test_exponential_doubles(self) -> None:
cfg = RetryConfig(strategy=BackoffStrategy.EXPONENTIAL, jitter=Jitter.NONE)
assert _calculate_delay(0, cfg) == 1.0
assert _calculate_delay(1, cfg) == 2.0
assert _calculate_delay(2, cfg) == 4.0
assert _calculate_delay(3, cfg) == 8.0
def test_exponential_capped_at_max(self) -> None:
cfg = RetryConfig(
strategy=BackoffStrategy.EXPONENTIAL,
base_delay=1.0,
max_delay=5.0,
jitter=Jitter.NONE,
)
assert _calculate_delay(0, cfg) == 1.0
assert _calculate_delay(1, cfg) == 2.0
assert _calculate_delay(2, cfg) == 4.0
assert _calculate_delay(3, cfg) == 5.0
def test_linear_increases_by_base(self) -> None:
cfg = RetryConfig(strategy=BackoffStrategy.LINEAR, jitter=Jitter.NONE)
assert _calculate_delay(0, cfg) == 1.0
assert _calculate_delay(1, cfg) == 2.0
assert _calculate_delay(2, cfg) == 3.0
def test_fixed_always_base(self) -> None:
cfg = RetryConfig(strategy=BackoffStrategy.FIXED, jitter=Jitter.NONE)
assert _calculate_delay(0, cfg) == 1.0
assert _calculate_delay(5, cfg) == 1.0
assert _calculate_delay(99, cfg) == 1.0
def test_linear_capped(self) -> None:
cfg = RetryConfig(
strategy=BackoffStrategy.LINEAR,
base_delay=10.0,
max_delay=25.0,
jitter=Jitter.NONE,
)
assert _calculate_delay(0, cfg) == 10.0
assert _calculate_delay(1, cfg) == 20.0
assert _calculate_delay(2, cfg) == 25.0
class TestJitterApplication:
def test_none_returns_delay(self) -> None:
assert _apply_jitter(5.0, Jitter.NONE) == 5.0
def test_full_is_between_0_and_delay(self) -> None:
for _ in range(50):
result = _apply_jitter(10.0, Jitter.FULL)
assert 0 <= result <= 10.0
def test_equal_is_positive(self) -> None:
for _ in range(50):
result = _apply_jitter(10.0, Jitter.EQUAL)
assert 5 <= result <= 10.0
def test_decorrelated_uses_previous(self) -> None:
for _ in range(50):
result = _apply_jitter(5.0, Jitter.DECORRELATED, previous_delay=2.0)
assert 2.0 <= result <= 6.0
def test_decorrelated_without_previous_returns_delay(self) -> None:
assert _apply_jitter(5.0, Jitter.DECORRELATED, previous_delay=0) == 5.0
class TestAsyncRetry:
@pytest.mark.asyncio
async def test_success_on_first_try(self) -> None:
fn = AsyncMock(return_value="ok")
result = await async_retry(fn, config=RetryConfig(jitter=Jitter.NONE))
assert result == "ok"
assert fn.call_count == 1
@pytest.mark.asyncio
async def test_succeeds_after_retries(self) -> None:
fn = AsyncMock(side_effect=[ValueError("fail1"), ValueError("fail2"), "ok"])
result = await async_retry(
fn,
config=RetryConfig(max_retries=3, base_delay=0.01, jitter=Jitter.NONE),
)
assert result == "ok"
assert fn.call_count == 3
@pytest.mark.asyncio
async def test_exhausts_retries_and_raises(self) -> None:
fn = AsyncMock(side_effect=ValueError("always fails"))
with pytest.raises(ValueError, match="always fails"):
await async_retry(
fn,
config=RetryConfig(max_retries=2, base_delay=0.01, jitter=Jitter.NONE),
)
assert fn.call_count == 3
@pytest.mark.asyncio
async def test_non_retryable_exception_passthrough(self) -> None:
fn = AsyncMock(side_effect=TypeError("not retryable"))
cfg = RetryConfig(
retryable_exceptions=(ValueError,),
base_delay=0.01,
jitter=Jitter.NONE,
)
with pytest.raises(TypeError, match="not retryable"):
await async_retry(fn, config=cfg)
assert fn.call_count == 1
@pytest.mark.asyncio
async def test_retryable_exception_does_retry(self) -> None:
fn = AsyncMock(side_effect=[ValueError("retry"), "ok"])
cfg = RetryConfig(
max_retries=2,
retryable_exceptions=(ValueError,),
base_delay=0.01,
jitter=Jitter.NONE,
)
result = await async_retry(fn, config=cfg)
assert result == "ok"
assert fn.call_count == 2
@pytest.mark.asyncio
async def test_max_time_exceeded_stops_retrying(self) -> None:
fn = AsyncMock(side_effect=ValueError("fail"))
cfg = RetryConfig(
max_retries=5,
max_time=0.05,
base_delay=0.1,
jitter=Jitter.NONE,
)
with pytest.raises(ValueError):
await async_retry(fn, config=cfg)
assert fn.call_count < 6
@pytest.mark.asyncio
async def test_circuit_breaker_blocks_retry(self) -> None:
name = "cb_block_utest"
cb = resilience.registry.breaker(name)
cb.failure_threshold = 1
resilience.registry.record_service_result(name, False)
assert resilience.registry.is_service_allowed(name) is False
fn = AsyncMock(side_effect=ConnectionError("fail"))
cfg = RetryConfig(
max_retries=3,
circuit_breaker=name,
base_delay=0.01,
jitter=Jitter.NONE,
)
with pytest.raises(RuntimeError, match="Circuit breaker"):
await async_retry(fn, config=cfg)
assert fn.call_count == 0
del resilience.registry.breakers[name]
@pytest.mark.asyncio
async def test_circuit_breaker_records_success(self) -> None:
name = "cb_success_utest"
fn = AsyncMock(return_value="ok")
cfg = RetryConfig(
max_retries=1,
circuit_breaker=name,
base_delay=0.01,
jitter=Jitter.NONE,
)
result = await async_retry(fn, config=cfg)
assert result == "ok"
cb = resilience.registry.breaker(name)
assert cb.successes >= 1
del resilience.registry.breakers[name]
@pytest.mark.asyncio
async def test_circuit_breaker_records_failure(self) -> None:
name = "cb_fail_utest"
fn = AsyncMock(side_effect=ConnectionError("fail"))
cfg = RetryConfig(
max_retries=1,
circuit_breaker=name,
base_delay=0.01,
jitter=Jitter.NONE,
)
with pytest.raises(ConnectionError):
await async_retry(fn, config=cfg)
cb = resilience.registry.breaker(name)
assert cb.failures >= 1
del resilience.registry.breakers[name]
@pytest.mark.asyncio
async def test_default_config_works(self) -> None:
fn = AsyncMock(return_value="ok")
result = await async_retry(fn)
assert result == "ok"
@pytest.mark.asyncio
async def test_passes_args_and_kwargs(self) -> None:
fn = AsyncMock(return_value="ok")
async def target(url: str, *, timeout: int = 30) -> str:
fn(url, timeout=timeout)
return "ok"
await async_retry(
target,
"https://example.com",
config=RetryConfig(jitter=Jitter.NONE),
timeout=10,
)
fn.assert_called_once_with("https://example.com", timeout=10)
class TestMakeRetryDecorator:
@pytest.mark.asyncio
async def test_decorator_retries_on_failure(self) -> None:
call_count = 0
@make_retry_decorator(RetryConfig(max_retries=2, base_delay=0.01, jitter=Jitter.NONE))
async def flaky_func() -> str:
nonlocal call_count
call_count += 1
if call_count < 3:
raise ValueError("not yet")
return "success"
result = await flaky_func()
assert result == "success"
assert call_count == 3
@pytest.mark.asyncio
async def test_decorator_preserves_metadata(self) -> None:
@make_retry_decorator()
async def my_func() -> str:
"""My docstring."""
return "ok"
assert my_func.__name__ == "my_func"
assert my_func.__doc__ == "My docstring."

View file

@ -5,13 +5,36 @@
# Licensed under MIT. See LICENSE. # Licensed under MIT. See LICENSE.
"""Tests for ultimate scraper fallback chain.""" """Tests for ultimate scraper fallback chain."""
from contextlib import ExitStack
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import resilience
def _clear_registry() -> None:
"""Reset global resilience registry to avoid cross-test contamination."""
resilience.registry.domain_memory.data.clear()
for name in list(resilience.registry.breakers.keys()):
cb = resilience.registry.breakers[name]
cb.state = "closed"
cb.failures = 0
cb.successes = 0
cb.last_failure_at = 0.0
cb.half_open_attempts = 0
import pytest import pytest
from ultimate_scraper import UltimateScraper from ultimate_scraper import UltimateScraper
_TIER_ALL = [
"_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",
]
def test_ultimate_init() -> None: def test_ultimate_init() -> None:
s = UltimateScraper() s = UltimateScraper()
assert s is not None assert s is not None
@ -106,6 +129,7 @@ async def test_tier_camoufox_failure() -> None:
@pytest.mark.anyio @pytest.mark.anyio
async def test_scrape_falls_through_all_tiers() -> None: async def test_scrape_falls_through_all_tiers() -> None:
_clear_registry()
s = UltimateScraper() s = UltimateScraper()
with patch.object(s, "_tier_direct", new=AsyncMock(return_value=None)), \ with patch.object(s, "_tier_direct", new=AsyncMock(return_value=None)), \
patch.object(s, "_tier_tls_fingerprint", new=AsyncMock(return_value=None)), \ patch.object(s, "_tier_tls_fingerprint", new=AsyncMock(return_value=None)), \
@ -124,11 +148,14 @@ async def test_scrape_falls_through_all_tiers() -> None:
@pytest.mark.anyio @pytest.mark.anyio
async def test_scrape_tls_tier_succeeds() -> None: async def test_scrape_tls_tier_succeeds() -> None:
_clear_registry()
s = UltimateScraper() s = UltimateScraper()
html = "<html><body>" + "x" * 1000 + "</body></html>" html = "<html><body>" + "x" * 1000 + "</body></html>"
with patch.object(s, "_tier_direct", new=AsyncMock(return_value=None)), \ ctx_mocks = {name: AsyncMock(return_value=None) for name in _TIER_ALL}
patch.object(s, "_tier_tls_fingerprint", new=AsyncMock(return_value=html)), \ ctx_mocks["_tier_tls_fingerprint"].return_value = html
patch.object(s, "_tier_cloudscraper", new=AsyncMock(return_value=None)): with ExitStack() as stack:
for name in _TIER_ALL:
stack.enter_context(patch.object(s, name, new=ctx_mocks[name]))
result = await s.scrape("http://example.com") result = await s.scrape("http://example.com")
assert result["status"] == "ok" assert result["status"] == "ok"
assert result["method"] == "tls_fingerprint" assert result["method"] == "tls_fingerprint"
@ -136,14 +163,14 @@ async def test_scrape_tls_tier_succeeds() -> None:
@pytest.mark.anyio @pytest.mark.anyio
async def test_scrape_camoufox_tier_succeeds() -> None: async def test_scrape_camoufox_tier_succeeds() -> None:
_clear_registry()
s = UltimateScraper() s = UltimateScraper()
html = "<html><body>" + "x" * 1000 + "</body></html>" html = "<html><body>" + "x" * 1000 + "</body></html>"
with patch.object(s, "_tier_direct", new=AsyncMock(return_value=None)), \ ctx_mocks = {name: AsyncMock(return_value=None) for name in _TIER_ALL}
patch.object(s, "_tier_tls_fingerprint", new=AsyncMock(return_value=None)), \ ctx_mocks["_tier_camoufox"].return_value = html
patch.object(s, "_tier_cloudscraper", new=AsyncMock(return_value=None)), \ with ExitStack() as stack:
patch.object(s, "_tier_flaresolverr", new=AsyncMock(return_value=None)), \ for name in _TIER_ALL:
patch.object(s, "_tier_undetected", new=AsyncMock(return_value=None)), \ stack.enter_context(patch.object(s, name, new=ctx_mocks[name]))
patch.object(s, "_tier_camoufox", new=AsyncMock(return_value=html)):
result = await s.scrape("http://example.com") result = await s.scrape("http://example.com")
assert result["status"] == "ok" assert result["status"] == "ok"
assert result["method"] == "camoufox" assert result["method"] == "camoufox"

View file

@ -21,6 +21,7 @@ from urllib.parse import urlparse
from errors import InvalidRequestError from errors import InvalidRequestError
from observability import SCRAPE_LATENCY from observability import SCRAPE_LATENCY
from resilience import registry from resilience import registry
from retry import RetryConfig, async_retry
from url_guard import validate_url from url_guard import validate_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -86,6 +87,25 @@ class UltimateScraper:
self._tls_scraper = TLSScraper() if _has_tls else None self._tls_scraper = TLSScraper() if _has_tls else None
self._camoufox = CamoufoxBrowser() if _has_camoufox else None self._camoufox = CamoufoxBrowser() if _has_camoufox else None
# Per-tier retry configurations for transient failure resilience.
# Browser-based tiers (undetected, camoufox, playwright) use 0 retries
# because launching a browser is expensive; network-only tiers retry 1-2x.
_RETRY_CONFIGS: ClassVar[dict[str, RetryConfig]] = {
"direct": RetryConfig(max_retries=2, base_delay=0.5, max_delay=5.0),
"tls_fingerprint": RetryConfig(max_retries=1, base_delay=1.0, max_delay=5.0),
"cloudscraper": RetryConfig(max_retries=1, base_delay=1.0, max_delay=5.0),
"flaresolverr": RetryConfig(max_retries=2, base_delay=2.0, max_delay=10.0),
"undetected": RetryConfig(max_retries=0),
"camoufox": RetryConfig(max_retries=0),
"playwright": RetryConfig(max_retries=0),
"googlebot": RetryConfig(max_retries=2, base_delay=0.5, max_delay=5.0),
"premium_proxy": RetryConfig(max_retries=2, base_delay=1.0, max_delay=8.0),
"archive": RetryConfig(max_retries=2, base_delay=0.5, max_delay=5.0),
"google_cache": RetryConfig(max_retries=2, base_delay=0.5, max_delay=5.0),
"tor": RetryConfig(max_retries=1, base_delay=2.0, max_delay=10.0),
}
_DEFAULT_RETRY: ClassVar[RetryConfig] = RetryConfig(max_retries=1, base_delay=1.0)
def _rotate_ua(self) -> str: def _rotate_ua(self) -> str:
ua = self.USER_AGENTS[self._ua_index % len(self.USER_AGENTS)] ua = self.USER_AGENTS[self._ua_index % len(self.USER_AGENTS)]
self._ua_index += 1 self._ua_index += 1
@ -119,7 +139,6 @@ class UltimateScraper:
except InvalidRequestError as e: except InvalidRequestError as e:
return {"status": "error", "url": url, "error": str(e)} return {"status": "error", "url": url, "error": str(e)}
proxy_url = self._resolve_proxy_url(opts) proxy_url = self._resolve_proxy_url(opts)
# Tier 0: Premium proxy (if configured and forced or first attempt) # Tier 0: Premium proxy (if configured and forced or first attempt)
@ -145,11 +164,13 @@ class UltimateScraper:
if proxy_url and not opts.get("use_premium_proxy_first", True): if proxy_url and not opts.get("use_premium_proxy_first", True):
tier_order.append(("premium_proxy", self._tier_premium_proxy)) tier_order.append(("premium_proxy", self._tier_premium_proxy))
tier_order.extend([ tier_order.extend(
("archive", self._tier_archive), [
("google_cache", self._tier_google_cache), ("archive", self._tier_archive),
("tor", self._tier_tor), ("google_cache", self._tier_google_cache),
]) ("tor", self._tier_tor),
]
)
# If domain memory knows a good tier, try it first. # If domain memory knows a good tier, try it first.
parsed = urlparse(url) parsed = urlparse(url)
@ -170,16 +191,21 @@ class UltimateScraper:
# Skip external tiers whose circuit breaker is open. # Skip external tiers whose circuit breaker is open.
breaker_name = breaker_for_tier.get(tier_name) breaker_name = breaker_for_tier.get(tier_name)
if breaker_name and not registry.is_service_allowed(breaker_name): if breaker_name and not registry.is_service_allowed(breaker_name):
logger.debug("tier_skipped_circuit_open", extra={"tier": tier_name, "domain": domain}) logger.debug(
"tier_skipped_circuit_open", extra={"tier": tier_name, "domain": domain}
)
errors.append(f"{tier_name}: circuit_open") errors.append(f"{tier_name}: circuit_open")
continue continue
start = time.time() start = time.time()
try: try:
retry_cfg = self._RETRY_CONFIGS.get(tier_name, self._DEFAULT_RETRY)
if tier_name == "premium_proxy": if tier_name == "premium_proxy":
html = await tier_fn(url, proxy_url, opts) html = await async_retry(tier_fn, url, proxy_url, opts, config=retry_cfg)
else: else:
html = await tier_fn(url, opts, proxy_url=proxy_url) html = await async_retry(
tier_fn, url, opts, proxy_url=proxy_url, config=retry_cfg
)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.debug("tier_exception", extra={"tier": tier_name, "error": str(e)[:50]}) logger.debug("tier_exception", extra={"tier": tier_name, "error": str(e)[:50]})
html = None html = None
@ -318,7 +344,9 @@ 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], proxy_url: str | None = None) -> 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:
@ -334,7 +362,9 @@ 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], proxy_url: str | None = None) -> 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:
@ -354,7 +384,9 @@ 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], proxy_url: str | None = None) -> 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:
@ -381,7 +413,9 @@ 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], proxy_url: str | None = None) -> 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:
@ -421,7 +455,6 @@ class UltimateScraper:
logger.debug("playwright_failed", extra={"error": str(e)[:50]}) logger.debug("playwright_failed", extra={"error": str(e)[:50]})
return None return None
async def _tier_tls_fingerprint( async def _tier_tls_fingerprint(
self, url: str, opts: dict[str, Any], proxy_url: str | None = None self, url: str, opts: dict[str, Any], proxy_url: str | None = None
) -> str | None: ) -> str | None:
@ -477,7 +510,9 @@ 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], proxy_url: str | None = None) -> 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
@ -497,7 +532,9 @@ 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], proxy_url: str | None = None) -> 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
@ -510,7 +547,9 @@ 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], proxy_url: str | None = None) -> 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