Skip to content

Commit 4c4f201

Browse files
tomerh2001jrhager84
authored andcommitted
Fix detect_deletions gating and harden timeout handling
- Make the request timeout configurable via general.request_timeout / REQUEST_TIMEOUT (defaults to the previous hardcoded 15 seconds) - Catch request errors per job group so a slow or unreachable server skips the current run instead of crashing the container (ManiMatter#317) - Fix detect_deletions truthiness so the deletion watchers only start when the job is actually enabled
1 parent c382850 commit 4c4f201

13 files changed

Lines changed: 319 additions & 85 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: 91 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,91 @@
1-
import asyncio
2-
import datetime
3-
import signal
4-
import sys
5-
import types
6-
7-
from src.deletion_handler.deletion_handler import WatcherManager
8-
from src.job_manager import JobManager
9-
from src.settings.settings import Settings
10-
from src.utils.log_setup import logger
11-
from src.utils.startup import launch_steps
12-
13-
settings = Settings()
14-
job_manager = JobManager(settings)
15-
watch_manager = WatcherManager(settings)
16-
17-
18-
def terminate(
19-
sigterm: signal.SIGTERM, # noqa: ARG001, pylint: disable=unused-argument
20-
frame: types.FrameType, # noqa: ARG001, pylint: disable=unused-argument
21-
) -> None:
22-
"""Terminate cleanly. Needed for respecting 'docker stop'.
23-
24-
Args:
25-
----
26-
sigterm (signal.Signal): The termination signal.
27-
frame: The execution frame.
28-
29-
"""
30-
31-
logger.info(
32-
f"Termination signal received at {datetime.datetime.now()}."
33-
) # noqa: DTZ005
34-
watch_manager.stop()
35-
sys.exit(0)
36-
37-
38-
async def wait_next_run():
39-
# Calculate next run time dynamically (to display)
40-
next_run = datetime.datetime.now() + datetime.timedelta(
41-
minutes=settings.general.timer
42-
)
43-
formatted_next_run = next_run.strftime("%Y-%m-%d %H:%M")
44-
45-
logger.verbose(f"*** Done - Next run at {formatted_next_run} ****")
46-
47-
# Wait for the next run
48-
await asyncio.sleep(settings.general.timer * 60)
49-
50-
51-
# Main function
52-
async def main():
53-
await launch_steps(settings)
54-
55-
if settings.jobs.detect_deletions:
56-
await WatcherManager(settings).setup()
57-
# Start Cleaning
58-
while True:
59-
logger.info("-" * 50)
60-
61-
# Refresh qBit Cookies (SABnzbd doesn't need cookie refresh)
62-
for qbit in settings.download_clients.qbittorrent:
63-
await qbit.refresh_cookie()
64-
65-
# Run script for each instance
66-
for arr in settings.instances:
67-
await job_manager.run_jobs(arr)
68-
logger.verbose("")
69-
70-
# Run download client jobs (these run independently of *arr instances)
71-
await job_manager.run_download_client_jobs()
72-
73-
# Wait for the next run
74-
await wait_next_run()
75-
76-
77-
if __name__ == "__main__":
78-
signal.signal(signal.SIGTERM, terminate)
79-
asyncio.run(main())
1+
import asyncio
2+
import datetime
3+
import signal
4+
import sys
5+
import types
6+
7+
from src.deletion_handler.deletion_handler import WatcherManager
8+
from src.job_manager import JobManager
9+
from src.settings.settings import Settings
10+
from src.utils.log_setup import logger
11+
from src.utils.startup import launch_steps
12+
13+
settings = Settings()
14+
job_manager = JobManager(settings)
15+
watch_manager = WatcherManager(settings)
16+
17+
18+
def terminate(
19+
sigterm: signal.SIGTERM, # noqa: ARG001, pylint: disable=unused-argument
20+
frame: types.FrameType, # noqa: ARG001, pylint: disable=unused-argument
21+
) -> None:
22+
"""Terminate cleanly. Needed for respecting 'docker stop'.
23+
24+
Args:
25+
----
26+
sigterm (signal.Signal): The termination signal.
27+
frame: The execution frame.
28+
29+
"""
30+
31+
logger.info(
32+
f"Termination signal received at {datetime.datetime.now()}."
33+
) # noqa: DTZ005
34+
watch_manager.stop()
35+
sys.exit(0)
36+
37+
38+
async def wait_next_run():
39+
# Calculate next run time dynamically (to display)
40+
next_run = datetime.datetime.now() + datetime.timedelta(
41+
minutes=settings.general.timer
42+
)
43+
formatted_next_run = next_run.strftime("%Y-%m-%d %H:%M")
44+
45+
logger.verbose(f"*** Done - Next run at {formatted_next_run} ****")
46+
47+
# Wait for the next run
48+
await asyncio.sleep(settings.general.timer * 60)
49+
50+
51+
# Main function
52+
async def main():
53+
await launch_steps(settings)
54+
55+
if settings.jobs.detect_deletions.enabled:
56+
await WatcherManager(settings).setup()
57+
# Start Cleaning
58+
while True:
59+
logger.info("-" * 50)
60+
61+
# Refresh qBit Cookies (SABnzbd doesn't need cookie refresh)
62+
for qbit in settings.download_clients.qbittorrent:
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+
)
70+
71+
# Run script for each instance
72+
for arr in settings.instances:
73+
await job_manager.run_jobs(arr)
74+
logger.verbose("")
75+
76+
# Run download client jobs (these run independently of *arr instances)
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+
)
84+
85+
# Wait for the next run
86+
await wait_next_run()
87+
88+
89+
if __name__ == "__main__":
90+
signal.signal(signal.SIGTERM, terminate)
91+
asyncio.run(main())

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

0 commit comments

Comments
 (0)