Files
shelfmark/tests/prowlarr/test_api_timeout.py
T
CaliBrain e7007865a4 fix(prowlarr): stop turning indexer failures into empty results and 404s (#1251)
Two independent bugs, both from an indexer that Prowlarr proxies rather
than answers for itself: the search never reported that it had failed,
and the grab never resolved what it was handed.

Search. A Torznab search is Prowlarr proxying a live request out to the
tracker, so for a Cloudflare-fronted indexer it waits on FlareSolverr.
The client gave it the 30s budget sized for Prowlarr's own JSON
endpoints, then swallowed every failure -- the timeout, the 429 Prowlarr
returns once it has disabled an indexer, a parse error -- into the same
empty list that means "this indexer has nothing". A cold challenge
routinely runs past a minute, so the UI said "No releases found for this
book" while FlareSolverr was still solving. That empty list also drove
the auto-expand retry, which fires on "no results with the category
filter". A timeout satisfies it, so Shelfmark sent a second search to an
indexer still busy with the first -- two Chromes at once, enough to take
FlareSolverr's down on a small host.

torznab_search now raises ProwlarrSearchError, and an empty list
strictly
means the indexer answered with no matches. The source records which
indexer searches failed: one dead indexer no longer sinks the others,
auto-expand runs only when every indexer genuinely answered, and zero
results with at least one failure raises SourceUnavailableError, which
the releases endpoint already turns into a 503 carrying a real message.
Prowlarr being unreachable was the same lie by another route -- the
indexer list came back empty, leaving nothing to query -- and now says
so.

Indexer searches also get their own timeout, PROWLARR_INDEXER_TIMEOUT,
defaulting to 90s and clamped to 5-300. Prowlarr's status and indexer
list keep 30s so Test Connection stays responsive, and the connect
timeout is split out at 10s so an unreachable Prowlarr fails fast rather
than hanging for the whole read budget. The overall per-request search
budget now scales to twice the indexer timeout, capped at 240s, so
raising the setting is not undone by the cap one level up while staying
under the 300s gunicorn worker timeout.

Grab. Prowlarr hands out a proxy download URL, with no magnetUrl and no
infoHash, for any indexer that only publishes torrent files. The native
Real-Debrid client built its magnet as "if not
url.startswith('magnet:') and expected_hash", so with no hash to work
from it left the URL alone and POSTed it to /torrents/addMagnet as the
magnet field. Real-Debrid answered 404 and the grab died on a raw HTTP
error. AllDebrid carried the same line and the same bug.

Both now resolve the URL first, through the extract_torrent_info path
the
torrent clients have used since #1108: pass a magnet through untouched,
follow a redirect or a response body that turns out to be a magnet,
otherwise upload the fetched .torrent, and fall back to a magnet built
from the infoHash only when the fetch failed. The file is preferred over
a synthesized urn:btih: magnet because it carries the tracker list; a
bare hash leaves the service to find the swarm on DHT alone. Fetches are
shared with the rest of the add path through the torrent fetch cache, so
resolving costs at most one request. Real-Debrid takes the file on PUT
/torrents/addTorrent with the raw bytes as the request body, AllDebrid
on
POST /magnet/upload/file as multipart files[]. A URL that resolves to
neither form now raises before any request reaches the service, so the
user reads why instead of a 404. Neither debrid client had any test
coverage; both have some now.

Fixes #1249
Fixes #1250
2026-08-21 09:07:03 -04:00

127 lines
4.6 KiB
Python

"""Prowlarr Torznab timeout handling (#1249).
A Torznab search is Prowlarr proxying a live request to the tracker, so for a
Cloudflare-fronted indexer it waits on FlareSolverr. Those searches need their
own budget, and when one runs out the caller has to hear about it instead of
receiving an empty list that reads as "this book has no releases".
"""
import pytest
import requests
import shelfmark.release_sources.prowlarr.api as prowlarr_api
from shelfmark.release_sources.prowlarr.api import (
DEFAULT_INDEXER_TIMEOUT_SECONDS,
MAX_INDEXER_TIMEOUT_SECONDS,
MIN_INDEXER_TIMEOUT_SECONDS,
ProwlarrClient,
ProwlarrSearchError,
resolve_indexer_timeout,
)
_TORZNAB_EMPTY = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel></channel></rss>"""
class _Response:
def __init__(self, text="", status_code=200, reason="OK"):
self.text = text
self.status_code = status_code
self.reason = reason
self.ok = status_code < 400
def raise_for_status(self):
if not self.ok:
raise requests.exceptions.HTTPError(f"{self.status_code} {self.reason}", response=self)
def json(self):
return {}
@pytest.fixture
def no_config(monkeypatch):
"""Keep the client off any persisted PROWLARR_INDEXER_TIMEOUT."""
monkeypatch.setattr(prowlarr_api.config, "get", lambda key, default=None: default)
class TestResolveIndexerTimeout:
def test_defaults_when_unset(self, no_config):
assert resolve_indexer_timeout() == DEFAULT_INDEXER_TIMEOUT_SECONDS
def test_reads_config(self, monkeypatch):
monkeypatch.setattr(
prowlarr_api.config,
"get",
lambda key, default=None: 150 if key == "PROWLARR_INDEXER_TIMEOUT" else default,
)
assert resolve_indexer_timeout() == 150
@pytest.mark.parametrize(
("value", "expected"),
[
(1, MIN_INDEXER_TIMEOUT_SECONDS),
(9000, MAX_INDEXER_TIMEOUT_SECONDS),
("120", 120),
],
)
def test_clamps_and_coerces(self, value, expected):
assert resolve_indexer_timeout(value) == expected
def test_unparsable_value_falls_back_rather_than_raising(self):
assert resolve_indexer_timeout("soon") == DEFAULT_INDEXER_TIMEOUT_SECONDS
class TestTorznabSearchFailures:
def _client(self, monkeypatch, response_or_error):
client = ProwlarrClient("http://prowlarr:9696", "apikey", indexer_timeout=90)
captured: dict[str, object] = {}
def fake_get(**kwargs):
captured.update(kwargs)
if isinstance(response_or_error, Exception):
raise response_or_error
return response_or_error
monkeypatch.setattr(client._session, "get", fake_get)
return client, captured
def test_read_timeout_raises_instead_of_returning_empty(self, monkeypatch):
client, _ = self._client(monkeypatch, requests.exceptions.ReadTimeout("read timeout=90"))
with pytest.raises(ProwlarrSearchError, match="did not respond within 90s"):
client.torznab_search(indexer_id=1, query="Dune")
def test_http_error_raises_instead_of_returning_empty(self, monkeypatch):
"""Prowlarr answers 429 once it has disabled an indexer for recent failures."""
client, _ = self._client(monkeypatch, _Response("", status_code=429, reason="Too Many"))
with pytest.raises(ProwlarrSearchError, match="indexer 1 search failed"):
client.torznab_search(indexer_id=1, query="Dune")
def test_an_indexer_with_nothing_still_returns_empty(self, monkeypatch):
client, _ = self._client(monkeypatch, _Response(_TORZNAB_EMPTY))
assert client.torznab_search(indexer_id=1, query="Dune") == []
def test_search_uses_the_indexer_timeout_with_a_short_connect_timeout(self, monkeypatch):
client, captured = self._client(monkeypatch, _Response(_TORZNAB_EMPTY))
client.torznab_search(indexer_id=1, query="Dune")
connect_timeout, read_timeout = captured["timeout"]
assert read_timeout == 90
assert connect_timeout < read_timeout
def test_json_endpoints_keep_the_short_timeout(self, monkeypatch, no_config):
client = ProwlarrClient("http://prowlarr:9696", "apikey")
captured: dict[str, object] = {}
def fake_request(**kwargs):
captured.update(kwargs)
return _Response("{}")
monkeypatch.setattr(client._session, "request", fake_request)
client.test_connection()
assert captured["timeout"] == client.timeout
assert client.timeout < client.indexer_timeout