From 02598179046dc94a4ce6deef9d158837ec4c5a74 Mon Sep 17 00:00:00 2001 From: Brandon Haney <121782102+Brandon-Haney@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:43:05 -0500 Subject: [PATCH 1/2] Retry the main-account plex.tv fetch like the home-user path Constructing MyPlexAccount in the `user is None` branch of _fetch_user_watchlist was the last plex.tv entry point without a retry; _get_main_account() and the switchHomeUser path already had one. On an install with no home users that branch is the only watchlist fetch there is, so a single read timeout marked the watchlist incomplete and skipped array restore for the whole run: [USER:Brandon] Fetching watchlist media ERROR (get Plex account for Brandon): plex.tv ... Read timed out WARNING Skipping array restore - watchlist data incomplete Wrapping it in _retry_plextv_call gives the same 3 attempts with 2s/4s backoff as its siblings. Auth failures still fail on the first attempt, and exhausting the retries still guards array restore as before. --- core/plex_api.py | 17 ++- tests/test_plex_api_main_account_retry.py | 174 ++++++++++++++++++++++ 2 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 tests/test_plex_api_main_account_retry.py diff --git a/core/plex_api.py b/core/plex_api.py index 86f7026..0545f35 100644 --- a/core/plex_api.py +++ b/core/plex_api.py @@ -1558,9 +1558,20 @@ def _fetch_user_watchlist(self, user, valid_sections: List[int], watchlist_episo fresh_session = requests.Session() if user is None: - # Main account - use the main token with a fresh session - self._rate_limited_api_call() - account = MyPlexAccount(token=self.plex_token, session=fresh_session) + # Main account - use the main token with a fresh session. + # Retried like its home-user sibling below: constructing the + # account is a plex.tv round trip, and on an install with no + # home users it is the only one, so a single read timeout here + # marks the whole watchlist incomplete and skips array restore + # for the run. + def _fresh_main_account(): + self._rate_limited_api_call() + return MyPlexAccount(token=self.plex_token, session=fresh_session) + + account = _retry_plextv_call( + _fresh_main_account, + label=f"main account for {current_username}", + ) logging.debug(f"[USER:{current_username}] Created fresh MyPlexAccount (main user)") else: # Home/managed user - create fresh admin account then switch to home user diff --git a/tests/test_plex_api_main_account_retry.py b/tests/test_plex_api_main_account_retry.py new file mode 100644 index 0000000..688d9d5 --- /dev/null +++ b/tests/test_plex_api_main_account_retry.py @@ -0,0 +1,174 @@ +"""The main-account watchlist path retries plex.tv like its home-user sibling. + +Observed 2026-08-13 20:32 on a single-user install: + + Processing 1 users for watchlist (main + 0 home users) + [USER:Brandon] Fetching watchlist media + ERROR [PLEX API] Error (get Plex account for Brandon): + HTTPSConnectionPool(host='plex.tv', port=443): Read timed out. (read timeout=30) + WARNING Skipping array restore - watchlist data incomplete (plex.tv unreachable) + +`MyPlexAccount(...)` in the `user is None` branch was the one plex.tv entry +point left unwrapped — `_get_main_account()` and the `switchHomeUser` path had +both been given retries already. On an install with no home users that branch is +the only watchlist fetch there is, so "this user failed" and "watchlist data is +incomplete" are the same event, and the run skipped array restore entirely. + +Retry semantics themselves live in `test_plex_api_retry.py`; this module only +checks that this call site is wired into the helper. +""" + +import os +import sys +import threading +from unittest.mock import MagicMock, patch + +import pytest + +sys.modules['fcntl'] = MagicMock() +for _mod in [ + 'apscheduler', 'apscheduler.schedulers', + 'apscheduler.schedulers.background', 'apscheduler.triggers', + 'apscheduler.triggers.cron', 'apscheduler.triggers.interval', + 'plexapi', 'plexapi.server', 'plexapi.video', 'plexapi.myplex', + 'plexapi.library', +]: + sys.modules.setdefault(_mod, MagicMock()) + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import requests + +from core.plex_api import PlexManager, PLEXTV_MAX_RETRIES + +# Verbatim from the log line above — a read timeout, not a connection reset. +# The two arrive as different exception classes and only ReadTimeout was seen +# in production. +READ_TIMEOUT = requests.exceptions.ReadTimeout( + "HTTPSConnectionPool(host='plex.tv', port=443): " + "Read timed out. (read timeout=30)" +) + + +def _bare_api(username="Brandon"): + """A PlexManager with just the state the main-account branch touches.""" + api = PlexManager.__new__(PlexManager) + api.plex_url = "http://localhost:32400" + api.plex_token = "ADMIN_TOKEN" + api._user_tokens = {} + api._user_is_home = {} + api._ondeck_data_complete = True + api._watchlist_data_complete = True + api._token_lock = threading.Lock() + api._rate_limited_api_call = MagicMock() + api._token_cache = MagicMock() + # user is None → the username comes from the local server, not plex.tv + api.plex = MagicMock() + api.plex.myPlexAccount.return_value.title = username + api.mark_watchlist_incomplete = MagicMock( + side_effect=lambda: setattr(api, '_watchlist_data_complete', False) + ) + return api + + +def _fetch(api): + """Drain the generator for the main account (user=None), no RSS.""" + return list(api._fetch_user_watchlist( + user=None, + valid_sections=[1], + watchlist_episodes=3, + skip_watchlist=[], + rss_url=None, + filtered_sections=[1], + )) + + +class TestMainAccountRetry: + + def test_transient_timeout_then_success_reaches_the_watchlist(self): + """The case from the log: one blip must not cost the run.""" + api = _bare_api() + account = MagicMock() + account.watchlist.return_value = [] + + with patch('core.plex_api.MyPlexAccount', + side_effect=[READ_TIMEOUT, account]) as mock_acct, \ + patch('core.plex_api.requests.Session'), \ + patch('core.plex_api.time.sleep'): + _fetch(api) + + assert mock_acct.call_count == 2 + assert api.mark_watchlist_incomplete.call_count == 0 + assert api.is_watchlist_data_complete() is True + account.watchlist.assert_called_once() + + def test_three_attempts_before_giving_up(self): + """Persistent timeout → PLEXTV_MAX_RETRIES tries, then mark incomplete.""" + api = _bare_api() + + with patch('core.plex_api.MyPlexAccount', + side_effect=READ_TIMEOUT) as mock_acct, \ + patch('core.plex_api.requests.Session'), \ + patch('core.plex_api.time.sleep'): + _fetch(api) + + assert mock_acct.call_count == PLEXTV_MAX_RETRIES == 3 + api.mark_watchlist_incomplete.assert_called_once() + + def test_backs_off_between_attempts(self): + """2s then 4s, so a brief plex.tv blip has time to clear.""" + api = _bare_api() + + with patch('core.plex_api.MyPlexAccount', side_effect=READ_TIMEOUT), \ + patch('core.plex_api.requests.Session'), \ + patch('core.plex_api.time.sleep') as mock_sleep: + _fetch(api) + + assert [c.args[0] for c in mock_sleep.call_args_list] == [2, 4] + + def test_connection_errors_are_retried_too(self): + """DNS failures surface as ConnectionError — see issue #197.""" + api = _bare_api() + account = MagicMock() + account.watchlist.return_value = [] + + with patch('core.plex_api.MyPlexAccount', + side_effect=[requests.ConnectionError( + "Failed to resolve 'plex.tv'"), account]) as mock_acct, \ + patch('core.plex_api.requests.Session'), \ + patch('core.plex_api.time.sleep'): + _fetch(api) + + assert mock_acct.call_count == 2 + assert api.mark_watchlist_incomplete.call_count == 0 + + def test_auth_failure_is_not_retried(self): + """A revoked token fails identically on every attempt; retrying only stalls.""" + api = _bare_api() + + with patch('core.plex_api.MyPlexAccount', + side_effect=ValueError("(401) Unauthorized")) as mock_acct, \ + patch('core.plex_api.requests.Session'), \ + patch('core.plex_api.time.sleep') as mock_sleep: + _fetch(api) + + assert mock_acct.call_count == 1 + mock_sleep.assert_not_called() + api.mark_watchlist_incomplete.assert_called_once() + + +class TestFailureStillGuardsArrayRestore: + """Retrying changes how often we give up, never what giving up means.""" + + def test_exhausted_retries_still_flag_incomplete_data(self): + api = _bare_api() + + with patch('core.plex_api.MyPlexAccount', side_effect=READ_TIMEOUT), \ + patch('core.plex_api.requests.Session'), \ + patch('core.plex_api.time.sleep'): + yielded = _fetch(api) + + assert yielded == [] + assert api.is_watchlist_data_complete() is False, ( + "array restore must stay blocked when the watchlist never loaded" + ) From 30f62baf62bc302b150f1b8becfc38244da35b4f Mon Sep 17 00:00:00 2001 From: Brandon Haney <121782102+Brandon-Haney@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:50:42 -0500 Subject: [PATCH 2/2] Retry the Settings user-list plex.tv calls myPlexAccount() and users() back the Settings user list and both are plex.tv round trips. A read timeout during the hourly refresh dropped the admin from the list until the next cycle: Refreshing Plex data cache... WARNING Could not get main account: plex.tv ... Read timed out INFO Fetched 24 shared users The shared users loaded from the very next call, so the two blocks keep fetching independently rather than sharing one account. Capped at 2 attempts instead of PLEXTV_MAX_RETRIES: this is reachable from GET /settings/plex/users, so attempts are paid in page latency, and giving up costs a stale user list rather than a skipped array restore. --- tests/test_settings_service_plextv_retry.py | 179 ++++++++++++++++++++ web/services/settings_service.py | 27 ++- 2 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 tests/test_settings_service_plextv_retry.py diff --git a/tests/test_settings_service_plextv_retry.py b/tests/test_settings_service_plextv_retry.py new file mode 100644 index 0000000..862183b --- /dev/null +++ b/tests/test_settings_service_plextv_retry.py @@ -0,0 +1,179 @@ +"""The Settings user list survives a transient plex.tv timeout. + +Observed 2026-08-12 15:02 during the hourly cache refresh: + + Refreshing Plex data cache... + WARNING Could not get main account: HTTPSConnectionPool(host='plex.tv', + port=443): Read timed out. (read timeout=30) + INFO Fetched 24 shared users + +The admin silently vanished from the Settings user list for that refresh +cycle while the shared users loaded fine — the very next plex.tv call +succeeded. That independence is why the two blocks retry separately instead +of sharing one fetched account. + +Retries here are capped at 2 attempts, not the PLEXTV_MAX_RETRIES of 3 used +by the caching engine: this code is reachable from GET /settings/plex/users, +so attempts are paid in page latency, and losing is a stale user list rather +than a skipped array restore. +""" + +import os +import sys +import threading +from datetime import datetime +from unittest.mock import MagicMock, patch + +import pytest + +sys.modules.setdefault('fcntl', MagicMock()) +for _mod in [ + 'apscheduler', 'apscheduler.schedulers', + 'apscheduler.schedulers.background', 'apscheduler.triggers', + 'apscheduler.triggers.cron', 'apscheduler.triggers.interval', + 'plexapi', 'plexapi.server', 'plexapi.video', 'plexapi.myplex', + 'plexapi.library', +]: + sys.modules.setdefault(_mod, MagicMock()) + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import requests + +from web.services.settings_service import SettingsService + +READ_TIMEOUT = requests.exceptions.ReadTimeout( + "HTTPSConnectionPool(host='plex.tv', port=443): " + "Read timed out. (read timeout=30)" +) + +UI_ATTEMPTS = 2 + + +def _bare_service(): + """A SettingsService with only the state get_plex_users() touches.""" + svc = SettingsService.__new__(SettingsService) + svc._cache_lock = threading.Lock() + svc._plex_users_cache = [] + svc._plex_cache_time = datetime.now() + svc._last_plex_error = None + svc._prefetched_users = None + svc._save_plex_cache_to_file = MagicMock() + svc._is_plex_cache_valid = MagicMock(return_value=False) + return svc + + +def _shared_user(title, home=False): + user = MagicMock() + user.title = title + user.home = home + return user + + +def _account(username="Brandon", shared=()): + account = MagicMock() + account.username = username + account.title = username + account.users.return_value = list(shared) + return account + + +def _fetch(svc): + """Explicit credentials bypass the cache branch and force a live fetch.""" + return svc.get_plex_users(plex_url="http://localhost:32400", + plex_token="TOKEN") + + +class TestTransientTimeoutIsRetried: + + def test_main_account_recovers_on_the_second_attempt(self): + account = _account(shared=[_shared_user("Paige")]) + plex = MagicMock() + plex.myPlexAccount.side_effect = [READ_TIMEOUT, account, account] + svc = _bare_service() + + with patch('plexapi.server.PlexServer', return_value=plex), \ + patch('core.plex_api.time.sleep'): + users = _fetch(svc) + + assert [u["username"] for u in users] == ["Brandon", "Paige"] + assert svc.get_last_plex_error() is None + + def test_gives_up_after_two_attempts(self): + """Capped lower than the engine's three — see the module docstring.""" + account = _account(shared=[_shared_user("Paige")]) + plex = MagicMock() + # Fail the two main-account attempts, then let the shared-user + # block's own fetch succeed. + plex.myPlexAccount.side_effect = [READ_TIMEOUT, READ_TIMEOUT, account] + svc = _bare_service() + + with patch('plexapi.server.PlexServer', return_value=plex), \ + patch('core.plex_api.time.sleep'): + users = _fetch(svc) + + assert plex.myPlexAccount.call_count == UI_ATTEMPTS + 1 + assert [u["username"] for u in users] == ["Paige"] + + def test_the_user_list_call_is_retried_too(self): + """account.users() is the actual shared-user round trip.""" + account = _account(shared=[_shared_user("Paige")]) + account.users.side_effect = [READ_TIMEOUT, [_shared_user("Paige")]] + plex = MagicMock() + plex.myPlexAccount.return_value = account + svc = _bare_service() + + with patch('plexapi.server.PlexServer', return_value=plex), \ + patch('core.plex_api.time.sleep'): + users = _fetch(svc) + + assert account.users.call_count == 2 + assert [u["username"] for u in users] == ["Brandon", "Paige"] + + +class TestTheTwoBlocksStayIndependent: + """The exact shape of the logged incident.""" + + def test_a_lost_main_account_does_not_cost_the_shared_users(self): + account = _account(shared=[_shared_user("Paige"), _shared_user("Alex")]) + plex = MagicMock() + # Both main-account attempts time out; the shared-user block then + # fetches its own account successfully, as happened in the log. + plex.myPlexAccount.side_effect = [READ_TIMEOUT, READ_TIMEOUT, account] + svc = _bare_service() + + with patch('plexapi.server.PlexServer', return_value=plex), \ + patch('core.plex_api.time.sleep'): + users = _fetch(svc) + + assert [u["username"] for u in users] == ["Paige", "Alex"] + assert all(u["is_admin"] is False for u in users) + + def test_total_plextv_failure_reports_an_error(self): + plex = MagicMock() + plex.myPlexAccount.side_effect = READ_TIMEOUT + svc = _bare_service() + + with patch('plexapi.server.PlexServer', return_value=plex), \ + patch('core.plex_api.time.sleep'): + users = _fetch(svc) + + assert users == [] + assert "Could not get account info" in svc.get_last_plex_error() + + +class TestNonTransientFailures: + + def test_auth_failure_is_not_retried(self): + """A revoked token fails the same way every time.""" + plex = MagicMock() + plex.myPlexAccount.side_effect = ValueError("(401) Unauthorized") + svc = _bare_service() + + with patch('plexapi.server.PlexServer', return_value=plex), \ + patch('core.plex_api.time.sleep') as mock_sleep: + _fetch(svc) + + # One attempt per block, no backoff. + assert plex.myPlexAccount.call_count == 2 + mock_sleep.assert_not_called() diff --git a/web/services/settings_service.py b/web/services/settings_service.py index d585ad3..7c0ec1d 100644 --- a/web/services/settings_service.py +++ b/web/services/settings_service.py @@ -1063,15 +1063,32 @@ def get_plex_users(self, plex_url: Optional[str] = None, plex_token: Optional[st try: import logging from plexapi.server import PlexServer + + from core.plex_api import _retry_plextv_call plex = PlexServer(plex_url, plex_token, timeout=10) users = [] account_error = None shared_users_error = None + # myPlexAccount() and users() are plex.tv round trips, so a transient + # timeout would otherwise drop the admin from this list for a whole + # refresh cycle. The blocks retry independently: a failure fetching + # the account has been seen while the very next call succeeded, so + # sharing one result would cost the shared users too. + # + # One retry rather than the usual three. This runs on the + # /settings/plex/users request path, not just the hourly refresh, so + # attempts are paid in page latency when plex.tv is down — and the + # cost of giving up is a stale user list, not a skipped array + # restore. One retry still covers the single blip seen in the logs. + PLEXTV_UI_ATTEMPTS = 2 + # Add main account first try: - account = plex.myPlexAccount() + account = _retry_plextv_call( + lambda: plex.myPlexAccount(), label="settings main account", + max_attempts=PLEXTV_UI_ATTEMPTS) users.append({ "username": account.username, "title": account.title or account.username, @@ -1085,9 +1102,13 @@ def get_plex_users(self, plex_url: Optional[str] = None, plex_token: Optional[st # Add shared users (all users from account.users() have server access) try: - account = plex.myPlexAccount() + account = _retry_plextv_call( + lambda: plex.myPlexAccount(), label="settings shared users", + max_attempts=PLEXTV_UI_ATTEMPTS) shared_count = 0 - for user in account.users(): + for user in _retry_plextv_call( + lambda: account.users(), label="settings user list", + max_attempts=PLEXTV_UI_ATTEMPTS): is_home = getattr(user, "home", False) users.append({ "username": user.title,