Skip to content

Commit 58510b1

Browse files
authored
Merge pull request ManiMatter#333 from tomerh2001/fix-detect-deletions-timeout-resilience
[NEEDS CODE REVIEWER] (time-out related) Fix detect_deletions disable gating and harden timeout handling
2 parents c382850 + 2e39485 commit 58510b1

13 files changed

Lines changed: 243 additions & 9 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Looking to **upgrade from V1 to V2**? Look [here](#upgrading-from-v1-to-v2)
2020
- [LOG_LEVEL](#log_level)
2121
- [TEST_RUN](#test_run)
2222
- [TIMER](#timer)
23+
- [REQUEST_TIMEOUT](#request_timeout)
2324
- [SSL_VERIFICATION](#ssl_verification)
2425
- [IGNORE_DOWNLOAD_CLIENTS](#ignore_download_clients)
2526
- [PRIVATE_TRACKER_HANDLING / PUBLIC_TRACKER_HANDLING](#private_tracker_handling--public_tracker_handling)
@@ -193,6 +194,7 @@ services:
193194
LOG_LEVEL: INFO
194195
TEST_RUN: True
195196
TIMER: 10
197+
# REQUEST_TIMEOUT: 15
196198
# IGNORED_DOWNLOAD_CLIENTS: >
197199
# - emulerr
198200
# SSL_VERIFICATION: true
@@ -399,6 +401,13 @@ Configures the general behavior of the application (across all features)
399401
- Unit: Minutes
400402
- Is Mandatory: No (Defaults to 10)
401403

404+
#### REQUEST_TIMEOUT
405+
406+
- Timeout used for HTTP/API requests to *arr and download clients
407+
- Type: Integer or Float
408+
- Unit: Seconds
409+
- Is Mandatory: No (Defaults to 15)
410+
402411
#### SSL_VERIFICATION
403412

404413
- Turns SSL certificate verification on or off for all API calls

config/config_example.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ general:
22
log_level: INFO
33
test_run: true
44
timer: 10
5+
# request_timeout: 15 # Optional: timeout for all HTTP/API calls in seconds
56
# ignored_download_clients: ["emulerr"]
67
# ssl_verification: false # Optional: Defaults to true
78
# private_tracker_handling: "obsolete_tag" # remove, skip, obsolete_tag. Optional. Default: remove

main.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,23 +52,35 @@ async def wait_next_run():
5252
async def main():
5353
await launch_steps(settings)
5454

55-
if settings.jobs.detect_deletions:
55+
if settings.jobs.detect_deletions.enabled:
5656
await WatcherManager(settings).setup()
5757
# Start Cleaning
5858
while True:
5959
logger.info("-" * 50)
6060

6161
# Refresh qBit Cookies (SABnzbd doesn't need cookie refresh)
6262
for qbit in settings.download_clients.qbittorrent:
63-
await qbit.refresh_cookie()
63+
try:
64+
await qbit.refresh_cookie()
65+
except Exception as err: # noqa: BLE001
66+
logger.error(
67+
f"Error while refreshing cookie for {qbit.name} ({qbit.base_url}): {err}",
68+
exc_info=True,
69+
)
6470

6571
# Run script for each instance
6672
for arr in settings.instances:
6773
await job_manager.run_jobs(arr)
6874
logger.verbose("")
6975

7076
# Run download client jobs (these run independently of *arr instances)
71-
await job_manager.run_download_client_jobs()
77+
try:
78+
await job_manager.run_download_client_jobs()
79+
except Exception as err: # noqa: BLE001
80+
logger.error(
81+
f"Error while running download-client jobs: {err}",
82+
exc_info=True,
83+
)
7284

7385
# Wait for the next run
7486
await wait_next_run()

src/job_manager.py

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# Cleans the download queue
2+
import requests
3+
24
from src.jobs.remove_bad_files import RemoveBadFiles
35
from src.jobs.remove_done_seeding import RemoveDoneSeeding
46
from src.jobs.remove_failed_downloads import RemoveFailedDownloads
@@ -24,12 +26,25 @@ def __init__(self, settings):
2426
async def run_jobs(self, arr):
2527
self.arr = arr
2628
logger.info(f"*** Running jobs on {self.arr.name} ({self.arr.base_url}) ***")
27-
await self.removal_jobs()
28-
await self.search_jobs()
29+
await self._run_arr_job_group("Removal Jobs", self.removal_jobs)
30+
await self._run_arr_job_group("Search Jobs", self.search_jobs)
2931

3032
async def run_download_client_jobs(self):
3133
"""Run jobs that operate on download clients directly."""
32-
if not await self._download_clients_connected():
34+
try:
35+
if not await self._download_clients_connected():
36+
return None
37+
except requests.exceptions.RequestException as err:
38+
logger.error(
39+
f"Download client connectivity check failed with request error: {err}",
40+
exc_info=True,
41+
)
42+
return None
43+
except Exception as err: # noqa: BLE001
44+
logger.error(
45+
f"Download client connectivity check failed: {err}",
46+
exc_info=True,
47+
)
3348
return None
3449

3550
items_detected = 0
@@ -56,7 +71,9 @@ async def run_download_client_jobs(self):
5671

5772
for download_client_job in download_client_jobs:
5873
if download_client_job.job.enabled:
59-
items_detected += await download_client_job.run()
74+
items_detected += await self._run_download_client_job(
75+
download_client_job, client
76+
)
6077

6178
return items_detected
6279

@@ -143,6 +160,37 @@ async def _check_client_connection_status(self, clients):
143160
return False
144161
return True
145162

163+
async def _run_arr_job_group(self, label, job_runner):
164+
"""Run one ARR job group and keep the main loop alive on failures."""
165+
try:
166+
await job_runner()
167+
except requests.exceptions.RequestException as err:
168+
logger.error(
169+
f"{label}: request error on {self.arr.name} ({self.arr.base_url}): {err}",
170+
exc_info=True,
171+
)
172+
except Exception as err: # noqa: BLE001
173+
logger.error(
174+
f"{label}: unexpected error on {self.arr.name} ({self.arr.base_url}): {err}",
175+
exc_info=True,
176+
)
177+
178+
async def _run_download_client_job(self, download_client_job, client):
179+
"""Run one download-client job and keep the loop alive on failures."""
180+
try:
181+
return await download_client_job.run()
182+
except requests.exceptions.RequestException as err:
183+
logger.error(
184+
f"Download-client job '{download_client_job.job_name}' failed on {client.name} ({client.base_url}) due to request error: {err}",
185+
exc_info=True,
186+
)
187+
except Exception as err: # noqa: BLE001
188+
logger.error(
189+
f"Download-client job '{download_client_job.job_name}' failed on {client.name} ({client.base_url}): {err}",
190+
exc_info=True,
191+
)
192+
return 0
193+
146194
def _get_removal_jobs(self):
147195
"""
148196
Return a list of enabled removal job instances based on the provided settings.

src/settings/_general.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class General:
1111
log_level: str = "INFO"
1212
test_run: bool = False
1313
timer: float = 10.0
14+
request_timeout: float = 15.0
1415
ssl_verification: bool = True
1516
ignored_download_clients: list = []
1617
private_tracker_handling: str = "remove"
@@ -23,6 +24,9 @@ def __init__(self, config):
2324
self.log_level = general_config.get("log_level", self.log_level.upper())
2425
self.test_run = general_config.get("test_run", self.test_run)
2526
self.timer = general_config.get("timer", self.timer)
27+
self.request_timeout = general_config.get(
28+
"request_timeout", self.request_timeout
29+
)
2630
self.ssl_verification = general_config.get(
2731
"ssl_verification", self.ssl_verification
2832
)

src/settings/_jobs.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ def _remove_none_attributes(self):
4444
if getattr(self, attr) is None:
4545
delattr(self, attr)
4646

47+
def __bool__(self):
48+
"""Allow direct truthiness checks to reflect whether this job is enabled."""
49+
return bool(getattr(self, "enabled", False))
50+
4751

4852
class JobDefaults:
4953
"""Represents default job settings."""

src/settings/_user_config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"LOG_LEVEL",
1414
"TEST_RUN",
1515
"TIMER",
16+
"REQUEST_TIMEOUT",
1617
"SSL_VERIFICATION",
1718
"IGNORED_DOWNLOAD_CLIENTS",
1819
"PRIVATE_TRACKER_HANDLING",

src/utils/common.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ async def make_request(
4242
method: str,
4343
endpoint: str,
4444
settings,
45-
timeout: int = 15,
45+
timeout: float | None = None,
4646
*,
4747
log_error=True,
4848
**kwargs,
@@ -51,6 +51,11 @@ async def make_request(
5151
A utility function to make HTTP requests (GET, POST, DELETE, PUT).
5252
"""
5353
ignore_test_run = kwargs.pop("ignore_test_run", False)
54+
request_timeout = timeout
55+
if request_timeout is None:
56+
request_timeout = getattr(
57+
getattr(settings, "general", None), "request_timeout", 15
58+
)
5459

5560
if settings.general.test_run and not ignore_test_run:
5661
if method.lower() in ("put", "post", "delete"):
@@ -74,7 +79,7 @@ async def make_request(
7479
endpoint,
7580
**kwargs,
7681
verify=settings.general.ssl_verification,
77-
timeout=timeout,
82+
timeout=request_timeout,
7883
)
7984
response.raise_for_status()
8085
return response

tests/jobs/test_job_manager.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from unittest.mock import AsyncMock, MagicMock, patch
2+
3+
import pytest
4+
import requests
5+
6+
from src.job_manager import JobManager
7+
8+
9+
@pytest.mark.asyncio
10+
async def test_run_jobs_keeps_running_when_one_group_raises_request_error():
11+
settings = MagicMock()
12+
manager = JobManager(settings)
13+
arr = MagicMock()
14+
arr.name = "Sonarr"
15+
arr.base_url = "http://sonarr:8989"
16+
17+
with (
18+
patch.object(
19+
manager,
20+
"removal_jobs",
21+
AsyncMock(side_effect=requests.exceptions.ReadTimeout("timed out")),
22+
),
23+
patch.object(manager, "search_jobs", AsyncMock()) as search_jobs,
24+
):
25+
await manager.run_jobs(arr)
26+
27+
search_jobs.assert_awaited_once()
28+
29+
30+
@pytest.mark.asyncio
31+
async def test_run_download_client_jobs_handles_per_job_request_errors():
32+
settings = MagicMock()
33+
client = MagicMock()
34+
client.name = "qBittorrent"
35+
client.base_url = "http://qbittorrent:8080"
36+
settings.download_clients.qbittorrent = [client]
37+
settings.download_clients.sabnzbd = []
38+
39+
manager = JobManager(settings)
40+
failing_job = MagicMock()
41+
failing_job.job.enabled = True
42+
failing_job.job_name = "remove_done_seeding"
43+
failing_job.run = AsyncMock(
44+
side_effect=requests.exceptions.ReadTimeout("timed out")
45+
)
46+
47+
with (
48+
patch.object(
49+
manager, "_download_clients_connected", AsyncMock(return_value=True)
50+
),
51+
patch.object(
52+
manager,
53+
"_get_download_client_jobs_for_client",
54+
MagicMock(return_value=[failing_job]),
55+
),
56+
):
57+
result = await manager.run_download_client_jobs()
58+
59+
assert result == 0
60+
failing_job.run.assert_awaited_once()
61+
62+
63+
@pytest.mark.asyncio
64+
async def test_run_download_client_jobs_handles_connection_check_request_errors():
65+
settings = MagicMock()
66+
settings.download_clients.qbittorrent = []
67+
settings.download_clients.sabnzbd = []
68+
manager = JobManager(settings)
69+
70+
with patch.object(
71+
manager,
72+
"_download_clients_connected",
73+
AsyncMock(side_effect=requests.exceptions.ReadTimeout("timed out")),
74+
):
75+
result = await manager.run_download_client_jobs()
76+
77+
assert result is None

tests/settings/test_general.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from src.settings._general import General
2+
3+
4+
def test_request_timeout_defaults_to_15_seconds():
5+
general = General({})
6+
7+
assert general.request_timeout == 15.0
8+
9+
10+
def test_request_timeout_accepts_numeric_strings():
11+
general = General({"general": {"request_timeout": "42"}})
12+
13+
assert general.request_timeout == 42.0

0 commit comments

Comments
 (0)