mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-08-02 13:00:14 -04:00
feat: Improve scraper resiliency and add both proxy and FlareSolverr support (#7953)
This commit is contained in:
0
tests/unit_tests/pkgs/safehttp/__init__.py
Normal file
0
tests/unit_tests/pkgs/safehttp/__init__.py
Normal file
418
tests/unit_tests/pkgs/safehttp/test_fetch.py
Normal file
418
tests/unit_tests/pkgs/safehttp/test_fetch.py
Normal file
@@ -0,0 +1,418 @@
|
||||
from collections.abc import AsyncIterator
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from mealie.pkgs.safehttp import fetch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detection helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "expected"),
|
||||
[
|
||||
(200, False),
|
||||
(301, False),
|
||||
(400, True),
|
||||
(401, True),
|
||||
(402, True),
|
||||
(403, True),
|
||||
(406, True),
|
||||
(429, True),
|
||||
(451, True),
|
||||
(404, False), # resource genuinely missing -> not worth rotating
|
||||
(410, False),
|
||||
(500, False), # server error a new fingerprint won't fix
|
||||
(502, False),
|
||||
(503, True), # WAFs use 503 during interstitials
|
||||
(504, False),
|
||||
],
|
||||
)
|
||||
def test_is_challenge_status(status_code: int, expected: bool):
|
||||
assert fetch.is_challenge_status(status_code) is expected
|
||||
|
||||
|
||||
def test_headers_indicate_challenge():
|
||||
assert fetch.headers_indicate_challenge(httpx.Headers({"cf-mitigated": "challenge"})) is True
|
||||
# header lookup is case-insensitive
|
||||
assert fetch.headers_indicate_challenge(httpx.Headers({"CF-Mitigated": "challenge"})) is True
|
||||
assert fetch.headers_indicate_challenge(httpx.Headers({"server": "cloudflare"})) is False
|
||||
assert fetch.headers_indicate_challenge(httpx.Headers({})) is False
|
||||
|
||||
|
||||
def test_body_indicates_challenge():
|
||||
assert fetch.body_indicates_challenge(b"<html>...cf-browser-verification...</html>") is True
|
||||
# marker matching is case-insensitive
|
||||
assert fetch.body_indicates_challenge(b"<script src='/cdn-cgi/challenge-platform/x'></script>") is True
|
||||
assert fetch.body_indicates_challenge(b"<html><body>A normal recipe for cookies</body></html>") is False
|
||||
|
||||
|
||||
def test_body_indicates_challenge_only_samples_head():
|
||||
# A marker past the sampled window should not be detected.
|
||||
body = b"x" * (fetch._CHALLENGE_BODY_SAMPLE + 100) + b"datadome"
|
||||
assert fetch.body_indicates_challenge(body) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FetchResult.text
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_fetch_result_text_decoding():
|
||||
assert fetch.FetchResult(b"h\xc3\xa9llo", 200, "http://x", httpx.Headers(), "utf-8").text == "héllo"
|
||||
# None encoding falls back to a blind decode
|
||||
assert fetch.FetchResult(b"abc", 200, "http://x", httpx.Headers(), None).text == "abc"
|
||||
# An unknown encoding name also falls back rather than raising
|
||||
assert fetch.FetchResult(b"abc", 200, "http://x", httpx.Headers(), "not-real").text == "abc"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fakes for the fetch loop
|
||||
# ---------------------------------------------------------------------------
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code: int, *, headers: dict | None = None, body: bytes = b"", url: str = "https://x/r"):
|
||||
self.status_code = status_code
|
||||
self.headers = httpx.Headers(headers or {})
|
||||
self.encoding = "utf-8"
|
||||
self.url = url
|
||||
self._body = body
|
||||
|
||||
async def aiter_bytes(self, chunk_size: int = 1024) -> AsyncIterator[bytes]:
|
||||
for i in range(0, len(self._body), chunk_size):
|
||||
yield self._body[i : i + chunk_size]
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, response: _FakeResponse):
|
||||
self._response = response
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
def stream(self, method: str, url: str, **kwargs):
|
||||
return self._response
|
||||
|
||||
|
||||
def _patch_responses(
|
||||
monkeypatch,
|
||||
responses: list[_FakeResponse],
|
||||
*,
|
||||
proxy_url: str | None = None,
|
||||
proxy_mode=fetch.ScraperProxyMode.always,
|
||||
flaresolverr_url: str | None = None,
|
||||
flaresolverr_timeout: int = 60,
|
||||
) -> dict:
|
||||
"""Scripts consecutive attempts to return the given responses.
|
||||
|
||||
Records the number of attempts and the proxy passed to each, and isolates the fetch from real
|
||||
app settings by injecting the given scraper configuration.
|
||||
"""
|
||||
state = {"queue": list(responses), "attempts": 0, "proxies": []}
|
||||
|
||||
def make_client(*args, **kwargs):
|
||||
state["attempts"] += 1
|
||||
return _FakeClient(state["queue"].pop(0))
|
||||
|
||||
def fake_build_transport(impersonate: str, proxy: str | None = None):
|
||||
state["proxies"].append(proxy)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(fetch, "AsyncClient", make_client)
|
||||
monkeypatch.setattr(fetch, "_build_transport", fake_build_transport)
|
||||
monkeypatch.setattr(
|
||||
fetch,
|
||||
"get_app_settings",
|
||||
lambda: SimpleNamespace(
|
||||
SCRAPER_PROXY_URL=proxy_url,
|
||||
SCRAPER_PROXY_MODE=proxy_mode,
|
||||
SCRAPER_FLARESOLVERR_URL=flaresolverr_url,
|
||||
SCRAPER_FLARESOLVERR_TIMEOUT=flaresolverr_timeout,
|
||||
),
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resilient_fetch loop
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_on_first_attempt(monkeypatch):
|
||||
state = _patch_responses(monkeypatch, [_FakeResponse(200, body=b"<html>recipe</html>")])
|
||||
result = await fetch.resilient_fetch("https://x/r")
|
||||
|
||||
assert result is not None
|
||||
assert result.status_code == 200
|
||||
assert result.content == b"<html>recipe</html>"
|
||||
assert state["attempts"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rotates_through_all_impersonations_on_block(monkeypatch):
|
||||
state = _patch_responses(monkeypatch, [_FakeResponse(403) for _ in fetch.BROWSER_IMPERSONATIONS])
|
||||
result = await fetch.resilient_fetch("https://x/r")
|
||||
|
||||
assert result is None
|
||||
assert state["attempts"] == len(fetch.BROWSER_IMPERSONATIONS)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rotates_past_200_challenge_body(monkeypatch):
|
||||
state = _patch_responses(
|
||||
monkeypatch,
|
||||
[
|
||||
_FakeResponse(200, body=b"<html>__cf_chl just a moment</html>"),
|
||||
_FakeResponse(200, body=b"<html>real recipe</html>"),
|
||||
],
|
||||
)
|
||||
result = await fetch.resilient_fetch("https://x/r")
|
||||
|
||||
assert result is not None
|
||||
assert result.content == b"<html>real recipe</html>"
|
||||
assert state["attempts"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hard_error_stops_immediately(monkeypatch):
|
||||
state = _patch_responses(monkeypatch, [_FakeResponse(404) for _ in fetch.BROWSER_IMPERSONATIONS])
|
||||
result = await fetch.resilient_fetch("https://x/r")
|
||||
|
||||
assert result is None
|
||||
assert state["attempts"] == 1 # did not rotate on a 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_head_request_skips_body(monkeypatch):
|
||||
# A body that *would* look like a challenge is ignored for HEAD (no body is read).
|
||||
state = _patch_responses(monkeypatch, [_FakeResponse(200, body=b"__cf_chl")])
|
||||
result = await fetch.resilient_fetch("https://x/r", method="HEAD")
|
||||
|
||||
assert result is not None
|
||||
assert result.content == b""
|
||||
assert state["attempts"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_triggers_backoff_then_succeeds(monkeypatch):
|
||||
slept: list[float] = []
|
||||
|
||||
async def fake_sleep(delay: float):
|
||||
slept.append(delay)
|
||||
|
||||
monkeypatch.setattr(fetch.asyncio, "sleep", fake_sleep)
|
||||
state = _patch_responses(
|
||||
monkeypatch,
|
||||
[_FakeResponse(429, headers={"Retry-After": "2"}), _FakeResponse(200, body=b"ok")],
|
||||
)
|
||||
|
||||
result = await fetch.resilient_fetch("https://x/r")
|
||||
|
||||
assert result is not None
|
||||
assert result.content == b"ok"
|
||||
assert state["attempts"] == 2
|
||||
assert len(slept) == 1
|
||||
assert slept[0] >= 2.0 # honored Retry-After
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_backoff_on_plain_403(monkeypatch):
|
||||
slept: list[float] = []
|
||||
|
||||
async def fake_sleep(delay: float):
|
||||
slept.append(delay)
|
||||
|
||||
monkeypatch.setattr(fetch.asyncio, "sleep", fake_sleep)
|
||||
_patch_responses(monkeypatch, [_FakeResponse(403), _FakeResponse(200, body=b"ok")])
|
||||
|
||||
result = await fetch.resilient_fetch("https://x/r")
|
||||
|
||||
assert result is not None
|
||||
assert slept == [] # 403 rotates immediately, no rate-limit backoff
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Proxy behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_proxy_when_unconfigured(monkeypatch):
|
||||
state = _patch_responses(monkeypatch, [_FakeResponse(200, body=b"ok")], proxy_url=None)
|
||||
result = await fetch.resilient_fetch("https://x/r")
|
||||
|
||||
assert result is not None
|
||||
assert state["proxies"] == [None]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_always_mode_proxies_first_request(monkeypatch):
|
||||
state = _patch_responses(
|
||||
monkeypatch,
|
||||
[_FakeResponse(200, body=b"ok")],
|
||||
proxy_url="http://proxy:8080",
|
||||
proxy_mode=fetch.ScraperProxyMode.always,
|
||||
)
|
||||
result = await fetch.resilient_fetch("https://x/r")
|
||||
|
||||
assert result is not None
|
||||
# proxy is used from the very first request, even though it succeeded (no block)
|
||||
assert state["proxies"] == ["http://proxy:8080"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_mode_direct_first_then_proxy_on_block(monkeypatch):
|
||||
responses = [_FakeResponse(403) for _ in fetch.BROWSER_IMPERSONATIONS] # direct all blocked
|
||||
responses.append(_FakeResponse(200, body=b"ok")) # first proxied attempt succeeds
|
||||
state = _patch_responses(
|
||||
monkeypatch,
|
||||
responses,
|
||||
proxy_url="http://proxy:8080",
|
||||
proxy_mode=fetch.ScraperProxyMode.fallback,
|
||||
)
|
||||
result = await fetch.resilient_fetch("https://x/r")
|
||||
|
||||
assert result is not None
|
||||
assert result.content == b"ok"
|
||||
# direct rotation used no proxy; escalation used the proxy
|
||||
n = len(fetch.BROWSER_IMPERSONATIONS)
|
||||
assert state["proxies"][:n] == [None] * n
|
||||
assert state["proxies"][n] == "http://proxy:8080"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_mode_does_not_escalate_on_hard_error(monkeypatch):
|
||||
state = _patch_responses(
|
||||
monkeypatch,
|
||||
[_FakeResponse(404) for _ in fetch.BROWSER_IMPERSONATIONS],
|
||||
proxy_url="http://proxy:8080",
|
||||
proxy_mode=fetch.ScraperProxyMode.fallback,
|
||||
)
|
||||
result = await fetch.resilient_fetch("https://x/r")
|
||||
|
||||
assert result is None
|
||||
# a 404 is a hard error: no rotation, and no proxy escalation
|
||||
assert state["proxies"] == [None]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_always_mode_does_not_double_escalate(monkeypatch):
|
||||
# Every attempt blocked in always-mode should not trigger a second (proxy) rotation.
|
||||
state = _patch_responses(
|
||||
monkeypatch,
|
||||
[_FakeResponse(403) for _ in fetch.BROWSER_IMPERSONATIONS],
|
||||
proxy_url="http://proxy:8080",
|
||||
proxy_mode=fetch.ScraperProxyMode.always,
|
||||
)
|
||||
result = await fetch.resilient_fetch("https://x/r")
|
||||
|
||||
assert result is None
|
||||
assert state["attempts"] == len(fetch.BROWSER_IMPERSONATIONS)
|
||||
assert all(p == "http://proxy:8080" for p in state["proxies"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FlareSolverr escalation
|
||||
# ---------------------------------------------------------------------------
|
||||
def _patch_flaresolverr(monkeypatch, solution):
|
||||
"""Records calls to flaresolverr.solve and returns the given solution (or None)."""
|
||||
calls: list[tuple] = []
|
||||
|
||||
async def fake_solve(base_url, url, timeout):
|
||||
calls.append((base_url, url, timeout))
|
||||
return solution
|
||||
|
||||
monkeypatch.setattr(fetch.flaresolverr, "solve", fake_solve)
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escalates_to_flaresolverr_when_blocked(monkeypatch):
|
||||
_patch_responses(
|
||||
monkeypatch,
|
||||
[_FakeResponse(403) for _ in fetch.BROWSER_IMPERSONATIONS],
|
||||
flaresolverr_url="http://flaresolverr:8191",
|
||||
)
|
||||
solution = fetch.flaresolverr.FlareSolverrSolution(html="<html>solved</html>", status_code=200, url="https://x/r")
|
||||
calls = _patch_flaresolverr(monkeypatch, solution)
|
||||
|
||||
result = await fetch.resilient_fetch("https://x/r")
|
||||
|
||||
assert result is not None
|
||||
assert result.text == "<html>solved</html>"
|
||||
assert len(calls) == 1
|
||||
assert calls[0] == ("http://flaresolverr:8191", "https://x/r", 60)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_flaresolverr_when_unconfigured(monkeypatch):
|
||||
_patch_responses(monkeypatch, [_FakeResponse(403) for _ in fetch.BROWSER_IMPERSONATIONS], flaresolverr_url=None)
|
||||
calls = _patch_flaresolverr(monkeypatch, None)
|
||||
|
||||
result = await fetch.resilient_fetch("https://x/r")
|
||||
|
||||
assert result is None
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_flaresolverr_on_hard_error(monkeypatch):
|
||||
_patch_responses(monkeypatch, [_FakeResponse(404)], flaresolverr_url="http://flaresolverr:8191")
|
||||
calls = _patch_flaresolverr(monkeypatch, None)
|
||||
|
||||
result = await fetch.resilient_fetch("https://x/r")
|
||||
|
||||
assert result is None
|
||||
assert calls == [] # a 404 is a hard error, not a block
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_flaresolverr_for_images(monkeypatch):
|
||||
# allow_flaresolverr=False (as the image path passes) must skip the browser escalation.
|
||||
_patch_responses(
|
||||
monkeypatch,
|
||||
[_FakeResponse(403) for _ in fetch.BROWSER_IMPERSONATIONS],
|
||||
flaresolverr_url="http://flaresolverr:8191",
|
||||
)
|
||||
calls = _patch_flaresolverr(monkeypatch, None)
|
||||
|
||||
result = await fetch.resilient_fetch("https://x/r", allow_flaresolverr=False)
|
||||
|
||||
assert result is None
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_flaresolverr_for_head(monkeypatch):
|
||||
# HEAD requests read no body; FlareSolverr (HTML-only) is pointless, so it must be skipped.
|
||||
_patch_responses(
|
||||
monkeypatch,
|
||||
[_FakeResponse(403) for _ in fetch.BROWSER_IMPERSONATIONS],
|
||||
flaresolverr_url="http://flaresolverr:8191",
|
||||
)
|
||||
calls = _patch_flaresolverr(monkeypatch, None)
|
||||
|
||||
result = await fetch.resilient_fetch("https://x/r", method="HEAD")
|
||||
|
||||
assert result is None
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flaresolverr_failure_degrades_gracefully(monkeypatch):
|
||||
_patch_responses(
|
||||
monkeypatch,
|
||||
[_FakeResponse(403) for _ in fetch.BROWSER_IMPERSONATIONS],
|
||||
flaresolverr_url="http://flaresolverr:8191",
|
||||
)
|
||||
calls = _patch_flaresolverr(monkeypatch, None) # solve() returns None (unreachable / unsolved)
|
||||
|
||||
result = await fetch.resilient_fetch("https://x/r")
|
||||
|
||||
assert result is None
|
||||
assert len(calls) == 1 # it tried, then gave up cleanly
|
||||
112
tests/unit_tests/pkgs/safehttp/test_flaresolverr.py
Normal file
112
tests/unit_tests/pkgs/safehttp/test_flaresolverr.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from mealie.pkgs.safehttp import flaresolverr
|
||||
|
||||
|
||||
class _FakeHTTPResponse:
|
||||
def __init__(self, status_code: int, json_data=None, raise_json: bool = False):
|
||||
self.status_code = status_code
|
||||
self._json_data = json_data
|
||||
self._raise_json = raise_json
|
||||
|
||||
def json(self):
|
||||
if self._raise_json:
|
||||
raise ValueError("not json")
|
||||
return self._json_data
|
||||
|
||||
|
||||
class _FakePostClient:
|
||||
"""Stands in for httpx.AsyncClient, capturing the POST and returning a scripted response."""
|
||||
|
||||
last_call: dict = {}
|
||||
|
||||
def __init__(self, response=None, exc=None):
|
||||
self._response = response
|
||||
self._exc = exc
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
async def post(self, endpoint: str, json: dict):
|
||||
_FakePostClient.last_call = {"endpoint": endpoint, "json": json}
|
||||
if self._exc:
|
||||
raise self._exc
|
||||
return self._response
|
||||
|
||||
|
||||
def _patch_client(monkeypatch, *, response=None, exc=None):
|
||||
monkeypatch.setattr(flaresolverr.httpx, "AsyncClient", lambda *a, **k: _FakePostClient(response=response, exc=exc))
|
||||
|
||||
|
||||
_OK_ENVELOPE = {
|
||||
"status": "ok",
|
||||
"solution": {
|
||||
"url": "https://x/final",
|
||||
"status": 200,
|
||||
"response": "<html>solved</html>",
|
||||
"cookies": [{"name": "cf_clearance", "value": "abc"}],
|
||||
"userAgent": "Mozilla/5.0 ...",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_solve_success_parses_solution(monkeypatch):
|
||||
_patch_client(monkeypatch, response=_FakeHTTPResponse(200, _OK_ENVELOPE))
|
||||
|
||||
solution = await flaresolverr.solve("http://flaresolverr:8191", "https://x/r", 45)
|
||||
|
||||
assert solution is not None
|
||||
assert solution.html == "<html>solved</html>"
|
||||
assert solution.status_code == 200
|
||||
assert solution.url == "https://x/final"
|
||||
assert solution.cookies == [{"name": "cf_clearance", "value": "abc"}]
|
||||
assert solution.user_agent == "Mozilla/5.0 ..."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_solve_builds_correct_request(monkeypatch):
|
||||
_patch_client(monkeypatch, response=_FakeHTTPResponse(200, _OK_ENVELOPE))
|
||||
|
||||
# trailing slash on base URL should be normalized
|
||||
await flaresolverr.solve("http://flaresolverr:8191/", "https://x/r", 30)
|
||||
|
||||
call = _FakePostClient.last_call
|
||||
assert call["endpoint"] == "http://flaresolverr:8191/v1"
|
||||
assert call["json"]["cmd"] == "request.get"
|
||||
assert call["json"]["url"] == "https://x/r"
|
||||
assert call["json"]["maxTimeout"] == 30_000 # seconds -> milliseconds
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_solve_returns_none_on_http_error(monkeypatch):
|
||||
_patch_client(monkeypatch, response=_FakeHTTPResponse(500, {}))
|
||||
assert await flaresolverr.solve("http://fs:8191", "https://x/r", 30) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_solve_returns_none_on_error_status(monkeypatch):
|
||||
_patch_client(monkeypatch, response=_FakeHTTPResponse(200, {"status": "error", "message": "challenge failed"}))
|
||||
assert await flaresolverr.solve("http://fs:8191", "https://x/r", 30) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_solve_returns_none_on_empty_solution(monkeypatch):
|
||||
_patch_client(monkeypatch, response=_FakeHTTPResponse(200, {"status": "ok", "solution": {"response": ""}}))
|
||||
assert await flaresolverr.solve("http://fs:8191", "https://x/r", 30) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_solve_returns_none_on_non_json(monkeypatch):
|
||||
_patch_client(monkeypatch, response=_FakeHTTPResponse(200, raise_json=True))
|
||||
assert await flaresolverr.solve("http://fs:8191", "https://x/r", 30) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_solve_returns_none_on_connection_error(monkeypatch):
|
||||
_patch_client(monkeypatch, exc=httpx.ConnectError("unreachable"))
|
||||
assert await flaresolverr.solve("http://fs:8191", "https://x/r", 30) is None
|
||||
Reference in New Issue
Block a user