Skip to content

Degrade unreachable instances at startup instead of exiting the whole app - #366

Merged
jrhager84 merged 1 commit into
ManiMatter:devfrom
jrhager84:startup-degraded-mode
Jul 10, 2026
Merged

Degrade unreachable instances at startup instead of exiting the whole app#366
jrhager84 merged 1 commit into
ManiMatter:devfrom
jrhager84:startup-degraded-mode

Conversation

@jrhager84

Copy link
Copy Markdown
Collaborator

Summary

Today, if any single instance (Sonarr/Radarr/qBittorrent/SABnzbd) fails its startup check, decluttarr calls wait_and_exit() and the whole container goes down — taking healthy instances with it, and on a slow or briefly-unreachable server causing a crash-loop where it never gets past startup. This makes startup degrade per-instance instead.

Behavior

Each unit now records a readiness state at setup:

  • Transient failures (timeout, connection refused, 5xx) degrade only that instance; setup is retried every cycle and it rejoins automatically once its server responds (including re-establishing its detect_deletions watchers).
  • Definitive config errors (401/403, wrong qBittorrent username/password, bad SABnzbd API key, non-English UI, client version too old) degrade the instance with a per-cycle ERROR + tip; they are not retried, since they can't self-heal — and retrying a bad qBittorrent password would get the source IP banned by qBit's failed-login protection.
  • The app exits only when nothing is configured, or when every configured unit has failed definitively — re-checked each cycle, not only at launch.

Safety: degraded download clients fail closed

A degraded download client is skipped everywhere a job would call it. Critically, removal jobs fail closed: a download whose configured client is degraded is left untouched rather than deleted, because its protection status (protected tag, private/public tracker) can't be verified while the client is down. This prevents a misconfigured or unreachable qBittorrent from causing protected or private torrents to be removed. Downloads on healthy clients — and on clients not configured in decluttarr — are unaffected.

Why

This complements #333: that made the runtime resilient to request errors; this extends the same resilience to startup, so a single slow or misconfigured instance no longer crash-loops the container — the exact pain reported in #317. It also fixes a latent bug where main.py handed the deletion watchers to a throwaway WatcherManager, so terminate() stopped an instance that owned no observers.

Validation

  • Full test suite passes (the 2 tests/deletion_handler failures are pre-existing Windows path-separator issues that reproduce identically on dev).
  • New unit tests cover: transient-vs-definitive classification, per-cycle retry + automatic rejoin, the all-definitive exit (both at launch and post-launch), degraded-client skipping across the job paths, and the fail-closed removal guard (built on the real grouped-download shape so it can't drift).
  • Verified on a live setup: a degraded download client degrades gracefully while the healthy Sonarr/Radarr instances keep running, no protected/private torrents are touched, the app stays up (no crash-loop), and the client rejoins on recovery.

Notes

@jrhager84 jrhager84 added the Bug Something isn't working label Jul 9, 2026
@jrhager84 jrhager84 self-assigned this Jul 9, 2026
@jrhager84
jrhager84 marked this pull request as ready for review July 9, 2026 23:28
@jrhager84

Copy link
Copy Markdown
Collaborator Author

@lolimmlost — any chance you can review (and test if you're able) to make sure I didn't miss anything? It also sets up a follow-up PR I have that adds qBit 5.2 API-key auth — with graceful degradation in place, a bad or rotated key degrades instead of crash-looping the container.

@jrhager84
jrhager84 force-pushed the startup-degraded-mode branch from 350dae1 to 18e2e51 Compare July 10, 2026 17:53
@jrhager84
jrhager84 requested a review from ManiMatter July 10, 2026 18:20
@jrhager84

Copy link
Copy Markdown
Collaborator Author

Is anybody available to review this? My qBit API key branch builds on this degradation logic so a bad or rotated key degrades the client instead of taking down the whole app in a crash loop. Tagging @ManiMatter for visibility. @lolimmlost or @Dark3clipse, you guys around today? Thanks!

@lolimmlost lolimmlost left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff. Architecture is sound and the fail-closed removal guard is the most important piece -- correctly skips downloads whose client is degraded rather than deleting them without being able to verify protection status.

Key points verified:

  • Transient vs definitive classification is correct across all three client types. The qBit bad-password detection (HTTP 200 + "Fails.") as definitive is a good catch -- avoids retry-loop IP bans.
  • _ignore_degraded_client_downloads uses the real group_by_download_id shape in its test, so the guard can't silently drift if the grouped dict changes.
  • ready_only=True on get_download_client_by_name returns (None, None) for degraded clients, and all downstream call sites already handle the None case. Backward compatible since the default is False.
  • Log dedup via str(e) != self.last_error keeps logs clean on repeated transient failures without hiding new failure modes.
  • _exit_if_all_failed_definitively re-evaluates after each retry cycle, catching the edge case where a transient unit turns definitive on retry (e.g. slow qBit finally answers and reveals a bad key).
  • Watcher setup on rejoin via setup_for_arr is a clean extraction from the existing get_folders_to_watch.

Test coverage is thorough -- all classification paths, recovery, log dedup, fail-closed guard, exit conditions, and watcher rejoin are exercised.

One minor note (not blocking): is_definitive_setup_error checks exc.cause but not context. All current raises use explicit "from e" so this is fine, but worth keeping in mind if future error paths use bare raise.

LGTM.

Comment thread src/utils/common.py
for candidate in (exc, exc.__cause__):
if getattr(candidate, "definitive", False):
return True
if isinstance(candidate, requests.exceptions.HTTPError):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: is_definitive_setup_error checks exc.__cause__ but not __context__. All current raises use explicit from e so this works, but if a future error path uses a bare raise inside an except block, the implicit chaining would land on __context__ instead and bypass the definitive check. Easy fix if it ever matters -- just add getattr(candidate, '__context__', None) to the loop.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. I'll take a look 👍

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, wrong username/password, bad
  SABnzbd api key, 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 (and retrying a bad password
  would get the IP banned by qBittorrent).
- The app exits when nothing is configured, or when every configured
  unit has failed definitively - re-checked each cycle, not only at
  launch.

Degraded download clients are skipped everywhere a job would call them
(bandwidth checks, obsolete-tagging, bad-file handling) via a ready_only
lookup. Removal jobs additionally fail closed: a download whose
configured client is degraded is left untouched rather than deleted,
since its protection status (protected tag, private/public tracker)
cannot be verified while the client is down.

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.
@jrhager84
jrhager84 force-pushed the startup-degraded-mode branch from 18e2e51 to cf225b8 Compare July 10, 2026 23:46
@jrhager84
jrhager84 merged commit 5050cc4 into ManiMatter:dev Jul 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants