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:
parent
5a21133b1d
commit
345cd79bc9
21 changed files with 1979 additions and 44 deletions
97
.actor/actor.json
Normal file
97
.actor/actor.json
Normal 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
16
.commitlintrc.yml
Normal 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]
|
||||
50
.github/workflows/ci.yml
vendored
50
.github/workflows/ci.yml
vendored
|
|
@ -10,6 +10,13 @@ on:
|
|||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: "3.12"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -17,7 +24,9 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- run: pip install ruff
|
||||
- run: ruff check .
|
||||
- run: ruff format --check .
|
||||
|
|
@ -28,8 +37,10 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install mypy httpx trafilatura lxml markdownify pydantic pyyaml pandas
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
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
|
||||
|
||||
test:
|
||||
|
|
@ -38,9 +49,11 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install pytest pytest-asyncio pytest-cov httpx trafilatura lxml markdownify pydantic pyyaml pandas
|
||||
- run: pytest tests/ -v --cov=. --cov-report=term-missing
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- run: pip install -e ".[dev]"
|
||||
- run: pytest tests/ -v --cov=. --cov-report=term-missing --cov-fail-under=40
|
||||
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -48,6 +61,29 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- run: pip install bandit
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -49,4 +49,4 @@ HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
|||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
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
25
Dockerfile.apify
Normal 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"]
|
||||
|
|
@ -35,7 +35,7 @@ class AccountPool:
|
|||
import uuid
|
||||
|
||||
account_id = uuid.uuid4().hex[:12]
|
||||
account = {
|
||||
account: dict[str, Any] = {
|
||||
"id": account_id,
|
||||
"site": site,
|
||||
"credentials": credentials,
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ class PryAdvanced:
|
|||
"from",
|
||||
"they",
|
||||
}
|
||||
freq = {}
|
||||
freq: dict[str, int] = {}
|
||||
for w in words:
|
||||
if w not in stop_words:
|
||||
freq[w] = freq.get(w, 0) + 1
|
||||
|
|
|
|||
240
apify_actor.py
Normal file
240
apify_actor.py
Normal 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
106
db.py
|
|
@ -62,8 +62,8 @@ import json
|
|||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
|
@ -85,6 +85,7 @@ try:
|
|||
)
|
||||
from sqlalchemy.engine import Engine
|
||||
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
|
||||
|
||||
_HAS_SA = True
|
||||
|
|
@ -118,9 +119,34 @@ def _resolve_database_url() -> str:
|
|||
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
|
||||
_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:
|
||||
"""Get the SQLAlchemy engine. Lazily creates one on first call."""
|
||||
|
|
@ -184,6 +210,61 @@ def session_scope() -> Iterator[Session]:
|
|||
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 ────────────────────────────────────────────────────
|
||||
|
||||
if _HAS_SA:
|
||||
|
|
@ -827,3 +908,24 @@ def db_health() -> dict[str, Any]:
|
|||
}
|
||||
except SQLAlchemyError as e:
|
||||
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 ──
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"""Pry — Observability: Prometheus metrics, OpenTelemetry tracing, structured logging."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
|
||||
|
|
@ -25,9 +26,19 @@ try:
|
|||
from opentelemetry.sdk.trace import TracerProvider
|
||||
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
|
||||
except ImportError:
|
||||
_has_otel = False
|
||||
_has_otlp = False
|
||||
|
||||
|
||||
# Metrics
|
||||
|
|
@ -49,15 +60,26 @@ else:
|
|||
|
||||
|
||||
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:
|
||||
return
|
||||
try:
|
||||
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)
|
||||
trace.set_tracer_provider(provider)
|
||||
logger.info("tracing_initialized", extra={"service": service_name})
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("tracing_init_failed", extra={"error": str(e)[:80]})
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ jobs:
|
|||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
|
@ -34,7 +35,7 @@ class Pryfile:
|
|||
|
||||
def __init__(self, path: str = "pry.yml"):
|
||||
self.path = path
|
||||
self.jobs = []
|
||||
self.jobs: list[Any] = []
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
|
|
|
|||
|
|
@ -102,7 +102,6 @@ warn_unused_ignores = false
|
|||
# TODO: Pry was built without strict typing; re-enable incrementally.
|
||||
# Tracking issue: re-enable mypy strict mode after router refactor.
|
||||
disable_error_code = [
|
||||
"var-annotated",
|
||||
"dict-item",
|
||||
"index",
|
||||
"attr-defined",
|
||||
|
|
|
|||
246
retry.py
Normal file
246
retry.py
Normal 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
|
||||
|
|
@ -506,7 +506,9 @@ class PryScraper:
|
|||
opts = options or {}
|
||||
max_pages = opts.get("max_pages", 10)
|
||||
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:
|
||||
current_url, depth = to_visit.pop(0)
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ async def monitor_page_structure(
|
|||
.first()
|
||||
)
|
||||
|
||||
previous_selectors: list[str]
|
||||
if previous_snap:
|
||||
previous_selectors = previous_snap.selectors or []
|
||||
previous_all_matched = previous_snap.all_matched
|
||||
|
|
|
|||
406
tests/test_api_integration.py
Normal file
406
tests/test_api_integration.py
Normal 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
114
tests/test_api_mcp.py
Normal 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")
|
||||
266
tests/test_fallback_integration.py
Normal file
266
tests/test_fallback_integration.py
Normal 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
296
tests/test_retry.py
Normal 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."
|
||||
|
|
@ -5,13 +5,36 @@
|
|||
# Licensed under MIT. See LICENSE.
|
||||
"""Tests for ultimate scraper fallback chain."""
|
||||
|
||||
from contextlib import ExitStack
|
||||
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
|
||||
|
||||
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:
|
||||
s = UltimateScraper()
|
||||
assert s is not None
|
||||
|
|
@ -106,6 +129,7 @@ async def test_tier_camoufox_failure() -> None:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_scrape_falls_through_all_tiers() -> None:
|
||||
_clear_registry()
|
||||
s = UltimateScraper()
|
||||
with patch.object(s, "_tier_direct", 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
|
||||
async def test_scrape_tls_tier_succeeds() -> None:
|
||||
_clear_registry()
|
||||
s = UltimateScraper()
|
||||
html = "<html><body>" + "x" * 1000 + "</body></html>"
|
||||
with patch.object(s, "_tier_direct", new=AsyncMock(return_value=None)), \
|
||||
patch.object(s, "_tier_tls_fingerprint", new=AsyncMock(return_value=html)), \
|
||||
patch.object(s, "_tier_cloudscraper", new=AsyncMock(return_value=None)):
|
||||
ctx_mocks = {name: AsyncMock(return_value=None) for name in _TIER_ALL}
|
||||
ctx_mocks["_tier_tls_fingerprint"].return_value = html
|
||||
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")
|
||||
assert result["status"] == "ok"
|
||||
assert result["method"] == "tls_fingerprint"
|
||||
|
|
@ -136,14 +163,14 @@ async def test_scrape_tls_tier_succeeds() -> None:
|
|||
|
||||
@pytest.mark.anyio
|
||||
async def test_scrape_camoufox_tier_succeeds() -> None:
|
||||
_clear_registry()
|
||||
s = UltimateScraper()
|
||||
html = "<html><body>" + "x" * 1000 + "</body></html>"
|
||||
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_cloudscraper", new=AsyncMock(return_value=None)), \
|
||||
patch.object(s, "_tier_flaresolverr", new=AsyncMock(return_value=None)), \
|
||||
patch.object(s, "_tier_undetected", new=AsyncMock(return_value=None)), \
|
||||
patch.object(s, "_tier_camoufox", new=AsyncMock(return_value=html)):
|
||||
ctx_mocks = {name: AsyncMock(return_value=None) for name in _TIER_ALL}
|
||||
ctx_mocks["_tier_camoufox"].return_value = html
|
||||
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")
|
||||
assert result["status"] == "ok"
|
||||
assert result["method"] == "camoufox"
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from urllib.parse import urlparse
|
|||
from errors import InvalidRequestError
|
||||
from observability import SCRAPE_LATENCY
|
||||
from resilience import registry
|
||||
from retry import RetryConfig, async_retry
|
||||
from url_guard import validate_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -86,6 +87,25 @@ class UltimateScraper:
|
|||
self._tls_scraper = TLSScraper() if _has_tls 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:
|
||||
ua = self.USER_AGENTS[self._ua_index % len(self.USER_AGENTS)]
|
||||
self._ua_index += 1
|
||||
|
|
@ -119,7 +139,6 @@ class UltimateScraper:
|
|||
except InvalidRequestError as e:
|
||||
return {"status": "error", "url": url, "error": str(e)}
|
||||
|
||||
|
||||
proxy_url = self._resolve_proxy_url(opts)
|
||||
|
||||
# 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):
|
||||
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),
|
||||
("tor", self._tier_tor),
|
||||
])
|
||||
]
|
||||
)
|
||||
|
||||
# If domain memory knows a good tier, try it first.
|
||||
parsed = urlparse(url)
|
||||
|
|
@ -170,16 +191,21 @@ class UltimateScraper:
|
|||
# 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})
|
||||
logger.debug(
|
||||
"tier_skipped_circuit_open", extra={"tier": tier_name, "domain": domain}
|
||||
)
|
||||
errors.append(f"{tier_name}: circuit_open")
|
||||
continue
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
retry_cfg = self._RETRY_CONFIGS.get(tier_name, self._DEFAULT_RETRY)
|
||||
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:
|
||||
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
|
||||
logger.debug("tier_exception", extra={"tier": tier_name, "error": str(e)[:50]})
|
||||
html = None
|
||||
|
|
@ -318,7 +344,9 @@ class UltimateScraper:
|
|||
logger.debug("premium_proxy_failed", extra={"error": str(e)[:50]})
|
||||
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:
|
||||
return None
|
||||
try:
|
||||
|
|
@ -334,7 +362,9 @@ class UltimateScraper:
|
|||
logger.debug("cloudscraper_failed", extra={"error": str(e)[:50]})
|
||||
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
|
||||
|
||||
if not settings.flaresolverr_url:
|
||||
|
|
@ -354,7 +384,9 @@ class UltimateScraper:
|
|||
logger.debug("flaresolverr_failed", extra={"error": str(e)[:50]})
|
||||
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:
|
||||
return None
|
||||
try:
|
||||
|
|
@ -381,7 +413,9 @@ class UltimateScraper:
|
|||
logger.debug("undetected_failed", extra={"error": str(e)[:50]})
|
||||
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:
|
||||
return None
|
||||
try:
|
||||
|
|
@ -421,7 +455,6 @@ class UltimateScraper:
|
|||
logger.debug("playwright_failed", extra={"error": str(e)[:50]})
|
||||
return None
|
||||
|
||||
|
||||
async def _tier_tls_fingerprint(
|
||||
self, url: str, opts: dict[str, Any], proxy_url: str | None = None
|
||||
) -> str | None:
|
||||
|
|
@ -477,7 +510,9 @@ class UltimateScraper:
|
|||
logger.debug("googlebot_failed", extra={"error": str(e)[:50]})
|
||||
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."""
|
||||
try:
|
||||
import httpx
|
||||
|
|
@ -497,7 +532,9 @@ class UltimateScraper:
|
|||
logger.debug("archive_failed", extra={"error": str(e)[:50]})
|
||||
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:
|
||||
import httpx
|
||||
|
||||
|
|
@ -510,7 +547,9 @@ class UltimateScraper:
|
|||
logger.debug("google_cache_failed", extra={"error": str(e)[:50]})
|
||||
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."""
|
||||
if not _has_tor:
|
||||
return None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue