Skip to content

Commit f4d0034

Browse files
jrhager84claude
andcommitted
Degrade failing instances at startup instead of exiting the whole app
Previously any single instance failing its startup check terminated decluttarr via wait_and_exit(), taking healthy instances down with it and causing container crash-loops on slow servers (ManiMatter#317 follow-up). Now each unit (arr, qBittorrent, SABnzbd) records a readiness state: - Transient failures (timeout, connection, 5xx, unknown) degrade the instance; setup is re-attempted every timer cycle and the instance rejoins automatically (including its detect_deletions watchers). - Definitive config errors (401/403, non-English UI, client version too old) degrade the instance with a per-cycle ERROR + tip; they are not retried since they cannot heal without user action. - The app only exits when nothing is configured (unchanged) or when every configured unit failed definitively. Wrong-arr-type and arr-version-too-old keep their existing log-and-continue behavior. Repeated identical setup failures log a single-line skip instead of the full error block each cycle. Also fixes main.py handing the deletion watchers to a throwaway WatcherManager, which left terminate() stopping an instance that owned no observers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4c4f201 commit f4d0034

13 files changed

Lines changed: 599 additions & 70 deletions

main.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from src.job_manager import JobManager
99
from src.settings.settings import Settings
1010
from src.utils.log_setup import logger
11-
from src.utils.startup import launch_steps
11+
from src.utils.startup import launch_steps, retry_degraded_instances
1212

1313
settings = Settings()
1414
job_manager = JobManager(settings)
@@ -53,13 +53,18 @@ async def main():
5353
await launch_steps(settings)
5454

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

61+
# Give degraded instances a chance to rejoin before this cycle's jobs
62+
await retry_degraded_instances(settings, watch_manager)
63+
6164
# Refresh qBit Cookies (SABnzbd doesn't need cookie refresh)
6265
for qbit in settings.download_clients.qbittorrent:
66+
if not qbit.ready:
67+
continue
6368
try:
6469
await qbit.refresh_cookie()
6570
except Exception as err: # noqa: BLE001
@@ -70,6 +75,8 @@ async def main():
7075

7176
# Run script for each instance
7277
for arr in settings.instances:
78+
if not arr.ready: # skip was already logged by retry_degraded_instances
79+
continue
7380
await job_manager.run_jobs(arr)
7481
logger.verbose("")
7582

src/deletion_handler/deletion_handler.py

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -99,35 +99,52 @@ async def setup(self):
9999
for arr, folder_path in folders_to_watch:
100100
self.set_watcher(arr, folder_path)
101101

102+
async def setup_for_arr(self, arr):
103+
"""Set up deletion watchers for a single arr (e.g. one that rejoined after a degraded startup)."""
104+
if self.loop is None:
105+
self.loop = asyncio.get_running_loop()
106+
for folder_path in await self.get_folders_to_watch_for_arr(arr):
107+
self.set_watcher(arr, folder_path)
108+
102109
async def get_folders_to_watch(self):
103110
"""Gets from all arrs the root folders and lists those that are accessible for the arr, and have present for decluttarr."""
104111
folders_to_watch = []
105112
logger.verbose("")
106113
logger.verbose("*** Setting up monitoring for deletions ***")
107114
for arr in self.settings.instances:
108-
if arr.arr_type not in (
109-
"sonarr",
110-
"radarr",
111-
): # only working for sonarr / radarr for now
112-
continue
113-
root_folders = await arr.get_root_folders()
114-
115-
for folder in root_folders:
116-
if folder.get("accessible") and "path" in folder:
117-
path = Path(folder["path"])
118-
if path.exists():
119-
folders_to_watch.append((arr, folder["path"]))
120-
else:
121-
logger.warning(
122-
f"Job 'detect_deletions' on {arr.name} ({arr.base_url}) does not have access to this path and will not monitor it: '{path}'"
123-
)
115+
for folder_path in await self.get_folders_to_watch_for_arr(arr):
116+
folders_to_watch.append((arr, folder_path))
117+
118+
return folders_to_watch
119+
120+
async def get_folders_to_watch_for_arr(self, arr):
121+
"""Root folder paths of one arr that are accessible for both the arr and decluttarr."""
122+
folders_to_watch = []
123+
if arr.arr_type not in (
124+
"sonarr",
125+
"radarr",
126+
): # only working for sonarr / radarr for now
127+
return folders_to_watch
128+
if not arr.ready: # degraded instance; watchers are added when it rejoins
129+
return folders_to_watch
130+
root_folders = await arr.get_root_folders()
131+
132+
for folder in root_folders:
133+
if folder.get("accessible") and "path" in folder:
134+
path = Path(folder["path"])
135+
if path.exists():
136+
folders_to_watch.append(folder["path"])
137+
else:
138+
logger.warning(
139+
f"Job 'detect_deletions' on {arr.name} ({arr.base_url}) does not have access to this path and will not monitor it: '{path}'"
140+
)
141+
logger.info(
142+
">>> 💡 Tip: Make sure that the paths in decluttarr and in your arr instance are identical."
143+
)
144+
if self.settings.envs.in_docker:
124145
logger.info(
125-
">>> 💡 Tip: Make sure that the paths in decluttarr and in your arr instance are identical."
146+
">>> 💡 Tip: Make sure decluttarr and your arr instance have the same mount points"
126147
)
127-
if self.settings.envs.in_docker:
128-
logger.info(
129-
">>> 💡 Tip: Make sure decluttarr and your arr instance have the same mount points"
130-
)
131148

132149
return folders_to_watch
133150

src/job_manager.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ async def run_download_client_jobs(self):
5656
)
5757

5858
for client in download_clients:
59+
if not client.ready:
60+
continue
61+
5962
# Get jobs for this client
6063
download_client_jobs = self._get_download_client_jobs_for_client(
6164
client,
@@ -150,6 +153,8 @@ async def _download_clients_connected(self):
150153

151154
async def _check_client_connection_status(self, clients):
152155
for client in clients:
156+
if not client.ready: # never-set-up client must not veto or be polled
157+
continue
153158
logger.debug(
154159
f"job_manager.py/_check_client_connection_status: Checking if {client.name} is connected",
155160
)

src/settings/_download_clients_qbit.py

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@
22
from requests.cookies import RequestsCookieJar
33

44
from src.settings._constants import ApiEndpoints, MinVersions
5-
from src.utils.common import extract_json_from_response, make_request, wait_and_exit
5+
from src.utils.common import (
6+
extract_json_from_response,
7+
is_definitive_setup_error,
8+
make_request,
9+
)
610
from src.utils.log_setup import logger
711

812

913
class QbitError(Exception):
10-
pass
14+
def __init__(self, message, tip="", definitive=False):
15+
super().__init__(message)
16+
self.tip = tip
17+
self.definitive = definitive
1118

1219

1320
class QbitClients(list):
@@ -39,6 +46,10 @@ class QbitClient:
3946
cookie: dict[str, str] = None
4047
version: str = None
4148
bandwidth_usage: int = 0
49+
ready: bool = False
50+
failure_kind: str = None # None | "transient" | "definitive"
51+
last_error: str = None
52+
setup_tip: str = ""
4253

4354
def __init__(
4455
self,
@@ -149,7 +160,7 @@ async def validate_version(self):
149160
f"Please update qBittorrent to at least version {min_version}. Current version: {self.version}",
150161
)
151162
error = f"qBittorrent version {self.version} is too old. Please update."
152-
raise QbitError(error)
163+
raise QbitError(error, definitive=True)
153164
if version.parse(self.version) < version.parse("5.0.0"):
154165
logger.info(
155166
"[Tip!] Consider upgrading to qBittorrent v5.0.0 or newer to reduce network overhead.",
@@ -237,8 +248,9 @@ async def check_qbit_reachability(self):
237248

238249
except Exception as e: # noqa: BLE001
239250
tip = "💡 Tip: Did you specify the URL (and username/password if required) correctly?"
240-
logger.error(f"-- | qBittorrent\n❗️ {e}\n{tip}\n")
241-
wait_and_exit()
251+
if str(e) != self.last_error: # Only report new failure modes in full
252+
logger.error(f"-- | qBittorrent\n❗️ {e}\n{tip}\n")
253+
raise QbitError(e, tip=tip) from e
242254

243255
async def check_connected(self):
244256
"""Check if the qBittorrent is connected to internet."""
@@ -260,26 +272,38 @@ async def check_connected(self):
260272
return True
261273

262274
async def setup(self):
263-
"""Perform the qBittorrent setup by calling relevant managers."""
264-
# Check reachabilty
265-
await self.check_qbit_reachability()
275+
"""Perform the qBittorrent setup; degrade instead of exiting on failure."""
276+
try:
277+
await self.check_qbit_reachability()
266278

267-
# Refresh the qBittorrent cookie first
268-
await self.refresh_cookie()
279+
# Refresh the qBittorrent cookie first
280+
await self.refresh_cookie()
269281

270-
try:
271282
# Fetch version and validate it
272283
await self.fetch_version()
273284
await self.validate_version()
274-
logger.info(f"OK | qBittorrent ({self.base_url})")
275-
except QbitError as e:
276-
logger.error(f"qBittorrent version check failed: {e}")
277-
wait_and_exit() # Exit if version check fails
278285

279-
# Continue with other setup tasks regardless of version check result
280-
await self.create_required_tags()
281-
await self.set_unwanted_folder()
282-
await self.warn_no_bandwidth_limit_set()
286+
await self.create_required_tags()
287+
await self.set_unwanted_folder()
288+
await self.warn_no_bandwidth_limit_set()
289+
290+
logger.info(f"OK | qBittorrent ({self.base_url})")
291+
self.ready = True
292+
self.failure_kind = None
293+
self.last_error = None
294+
self.setup_tip = ""
295+
except Exception as e: # noqa: BLE001
296+
if not isinstance(e, QbitError) and str(e) != self.last_error:
297+
logger.error(
298+
f"Unhandled error during qBittorrent setup: {e}", exc_info=True
299+
)
300+
self.ready = False
301+
self.failure_kind = (
302+
"definitive" if is_definitive_setup_error(e) else "transient"
303+
)
304+
self.last_error = str(e)
305+
self.setup_tip = getattr(e, "tip", "")
306+
return self.ready
283307

284308
async def get_protected_and_private(self):
285309
"""Fetch torrents from qBittorrent and checks for protected and private status."""
@@ -395,15 +419,14 @@ async def get_qbit_items(self, hashes: list[str] | str | None = None) -> list[di
395419
async def get_torrent_properties(self, qbit_hash):
396420
params = {"hash": qbit_hash.lower()}
397421
response = await make_request(
398-
"get",
399-
self.api_url + "/torrents/properties",
400-
self.settings,
401-
params=params,
402-
cookies=self.cookie,
403-
)
422+
"get",
423+
self.api_url + "/torrents/properties",
424+
self.settings,
425+
params=params,
426+
cookies=self.cookie,
427+
)
404428
return response.json()
405429

406-
407430
async def get_torrent_files(self, download_id):
408431
# this may not work if the wrong qbit
409432
logger.debug("_download_clients_qBit/get_torrent_files: Getting torrent files")

src/settings/_download_clients_sabnzbd.py

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
from packaging import version
22

33
from src.settings._constants import MinVersions
4-
from src.utils.common import make_request, wait_and_exit
4+
from src.utils.common import is_definitive_setup_error, make_request
55
from src.utils.log_setup import logger
66

77

88
class SabnzbdError(Exception):
9-
pass
9+
def __init__(self, message, tip="", definitive=False):
10+
super().__init__(message)
11+
self.tip = tip
12+
self.definitive = definitive
1013

1114

1215
class SabnzbdClients(list):
@@ -36,6 +39,10 @@ class SabnzbdClient:
3639
"""Represents a single SABnzbd client."""
3740

3841
version: str = None
42+
ready: bool = False
43+
failure_kind: str = None # None | "transient" | "definitive"
44+
last_error: str = None
45+
setup_tip: str = ""
3946

4047
def __init__(
4148
self,
@@ -96,7 +103,7 @@ async def validate_version(self):
96103
f"Please update SABnzbd to at least version {min_version}. Current version: {self.version}",
97104
)
98105
error = f"SABnzbd version {self.version} is too old. Please update."
99-
raise SabnzbdError(error)
106+
raise SabnzbdError(error, definitive=True)
100107

101108
async def check_sabnzbd_reachability(self):
102109
"""Check if the SABnzbd URL is reachable."""
@@ -116,8 +123,9 @@ async def check_sabnzbd_reachability(self):
116123

117124
except Exception as e: # noqa: BLE001
118125
tip = "💡 Tip: Did you specify the URL and API key correctly?"
119-
logger.error(f"-- | SABnzbd\n❗️ {e}\n{tip}\n")
120-
wait_and_exit()
126+
if str(e) != self.last_error: # Only report new failure modes in full
127+
logger.error(f"-- | SABnzbd\n❗️ {e}\n{tip}\n")
128+
raise SabnzbdError(e, tip=tip) from e
121129

122130
async def check_connected(self):
123131
"""Check if SABnzbd is connected and operational."""
@@ -137,18 +145,31 @@ async def check_connected(self):
137145
return "status" in status_data
138146

139147
async def setup(self):
140-
"""Perform the SABnzbd setup by calling relevant managers."""
141-
# Check reachability
142-
await self.check_sabnzbd_reachability()
143-
148+
"""Perform the SABnzbd setup; degrade instead of exiting on failure."""
144149
try:
150+
await self.check_sabnzbd_reachability()
151+
145152
# Fetch version and validate it
146153
await self.fetch_version()
147154
await self.validate_version()
155+
148156
logger.info(f"OK | SABnzbd ({self.base_url})")
149-
except SabnzbdError as e:
150-
logger.error(f"SABnzbd version check failed: {e}")
151-
wait_and_exit() # Exit if version check fails
157+
self.ready = True
158+
self.failure_kind = None
159+
self.last_error = None
160+
self.setup_tip = ""
161+
except Exception as e: # noqa: BLE001
162+
if not isinstance(e, SabnzbdError) and str(e) != self.last_error:
163+
logger.error(
164+
f"Unhandled error during SABnzbd setup: {e}", exc_info=True
165+
)
166+
self.ready = False
167+
self.failure_kind = (
168+
"definitive" if is_definitive_setup_error(e) else "transient"
169+
)
170+
self.last_error = str(e)
171+
self.setup_tip = getattr(e, "tip", "")
172+
return self.ready
152173

153174
async def get_queue_items(self):
154175
"""Fetch queue items from SABnzbd."""

0 commit comments

Comments
 (0)