From 3851fafed606cc7a3c3388f9632b55ad2736d10e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 16:59:51 +0200 Subject: [PATCH] Recover from a 401 by refreshing and retrying instead of forcing re-login Previously a 401 from the Helix API showed a hard "OAuth token is required" dialog and exited, even when a valid refresh token was still stored -- which happens routinely when the short-lived access token lapses (Kodi or the network only just came up after a reboot or an overnight gap). The proactive refresh in Twitch.__init__ doesn't cover a token that expires or is rejected mid-session. error_check now treats a 401 with a stored refresh token as recoverable: it forces a silent refresh (ensure_valid_token(force=True)) and, on success, raises TokenRefreshed so api_error_handler reloads the new token onto the instance and retries the request once -- no toast, no re-login. A one-shot guard prevents any refresh/retry loop; if the retry still 401s while a refresh token survives, it bails quietly (the next visit or the proactive service refresh recovers). The hard sign-in dialog is now reached only when no refresh token remains -- a genuinely dead session. The private-credential 401 path is unchanged. Also add an offline guard: while Kodi reports no internet (System.InternetState), ensure_valid_token skips the doomed refresh instead of hanging ~15s on a dead connection; the stored token is kept and refreshed once connectivity returns. A forced post-401 recovery bypasses the guard. Co-Authored-By: Claude Fable 5 --- resources/lib/twitch_addon/addon/api.py | 36 +++++++++++++++++-- .../lib/twitch_addon/addon/common/kodi.py | 9 +++++ .../lib/twitch_addon/addon/error_handling.py | 15 +++++--- .../twitch_addon/addon/twitch_exceptions.py | 6 ++++ resources/lib/twitch_addon/addon/utils.py | 5 +++ 5 files changed, 63 insertions(+), 8 deletions(-) diff --git a/resources/lib/twitch_addon/addon/api.py b/resources/lib/twitch_addon/addon/api.py index 2cf4f370..341e4818 100644 --- a/resources/lib/twitch_addon/addon/api.py +++ b/resources/lib/twitch_addon/addon/api.py @@ -16,7 +16,7 @@ from .common import kodi, log_utils from .constants import Keys, SCOPES from .error_handling import api_error_handler -from .twitch_exceptions import PlaybackFailed, TwitchException +from .twitch_exceptions import PlaybackFailed, TwitchException, TokenRefreshed from twitch import queries as twitch_queries from twitch import oauth @@ -45,6 +45,9 @@ class Twitch: required_scopes = SCOPES def __init__(self): + # Guards the one-shot silent 401 recovery in error_check, so a brand-new token that + # is somehow still rejected can't drive an endless refresh/retry loop. + self._oauth_recovery_attempted = False # Silently refresh the Helix OAuth token if it is (near) expired (Device Code Flow). utils.ensure_valid_token() self.access_token = utils.get_oauth_token(token_only=True, required=False) @@ -65,6 +68,12 @@ def __init__(self): self.queries.OAUTH_TOKEN = '' self.access_token = '' + def reload_access_token(self): + """Re-read the (just-refreshed) access token from the store onto this instance and + the query layer, so an in-flight retry (see api_error_handler) uses the new token.""" + self.access_token = utils.get_oauth_token(token_only=True, required=False) + self.queries.OAUTH_TOKEN = self.access_token + @cache.cache_method(cache_limit=1) def valid_token(self, client_id, token, scopes): # client_id, token used for unique caching only token_check = self.root() @@ -393,8 +402,7 @@ def video_request(self, video_id): results = self.usher.video_request(video_id, headers=self.get_private_credential_headers(), **self._codec_kwargs()) return self.error_check(results, private=True) - @staticmethod - def error_check(results, private=False): + def error_check(self, results, private=False): if isinstance(results, list): return results @@ -404,6 +412,28 @@ def error_check(results, private=False): if ('error' in payload) and (payload['status'] == 401): if not private: + # A 401 while a refresh token is still stored is almost always recoverable: + # the short-lived access token lapsed (e.g. Kodi / the network only just came + # up after a reboot or an overnight gap) but the refresh token is still good. + # Refresh once and re-issue the request transparently instead of dragging the + # user through a needless re-login. The hard sign-in dialog is reached ONLY + # when no refresh token remains (a definitively dead session). + refresh_token = utils.get_refresh_token() + if refresh_token: + # Forced refresh is attempted at most once per instance (one-shot guard), + # so a token still rejected after a successful refresh can't loop. + if not self._oauth_recovery_attempted: + self._oauth_recovery_attempted = True + if utils.ensure_valid_token(force=True): + # Refreshed: hand back up to api_error_handler, which reloads the + # token and retries the request once (no toast, no re-login). + raise TokenRefreshed() + # Recovery already spent this instance (the retry itself 401'd), or the + # refresh returned nothing -- but a refresh token still survives, so this + # is a transient / in-flight refresh, not a dead session. Bail silently + # this once (next visit, or the proactive service refresh, recovers). + if utils.get_refresh_token(): + sys.exit() _ = kodi.Dialog().ok( i18n('oauth_heading'), i18n('oauth_message') % (i18n('settings'), i18n('login'), i18n('device_login')) diff --git a/resources/lib/twitch_addon/addon/common/kodi.py b/resources/lib/twitch_addon/addon/common/kodi.py index 1f0a95a0..1ee4ae39 100644 --- a/resources/lib/twitch_addon/addon/common/kodi.py +++ b/resources/lib/twitch_addon/addon/common/kodi.py @@ -111,6 +111,15 @@ def has_addon(addon_id): return xbmc.getCondVisibility('System.HasAddon(%s)' % addon_id) == 1 +def connection_available(): + """Kodi's own view of whether the host currently has internet access. Used to skip a + doomed OAuth token refresh while offline (an outage, or the network simply not up yet + after a reboot) so we never hammer a dead connection -- and never mistake "no + connection" for "bad token". Kodi can lag a network change by a few seconds, which is + fine: the refresh is just deferred until this reports back true.""" + return xbmc.getCondVisibility('System.InternetState') == 1 + + def addon_enabled(addon_id): rpc_request = {"jsonrpc": "2.0", "method": "Addons.GetAddonDetails", diff --git a/resources/lib/twitch_addon/addon/error_handling.py b/resources/lib/twitch_addon/addon/error_handling.py index 1c01257f..fb8ef31c 100644 --- a/resources/lib/twitch_addon/addon/error_handling.py +++ b/resources/lib/twitch_addon/addon/error_handling.py @@ -15,7 +15,7 @@ from . import utils from .common import kodi, log_utils -from .twitch_exceptions import TwitchException, SubRequired, ResourceUnavailableException, NotFound, PlaybackFailed +from .twitch_exceptions import TwitchException, SubRequired, ResourceUnavailableException, NotFound, PlaybackFailed, TokenRefreshed i18n = utils.i18n @@ -69,9 +69,14 @@ def api_error_handler(func): @wraps(func) def wrapper(*args, **kwargs): try: - result = func(*args, **kwargs) - return result - except: - raise + return func(*args, **kwargs) + except TokenRefreshed: + # error_check() silently refreshed an expired token after a 401. Reload it onto + # the Twitch instance (args[0]==self) and retry the request once, so the user + # gets their data on the first try instead of a re-login prompt. The one-shot + # guard in error_check ensures the retry can't raise TokenRefreshed again. + if args and hasattr(args[0], 'reload_access_token'): + args[0].reload_access_token() + return func(*args, **kwargs) return wrapper diff --git a/resources/lib/twitch_addon/addon/twitch_exceptions.py b/resources/lib/twitch_addon/addon/twitch_exceptions.py index fd86dc41..67834daa 100644 --- a/resources/lib/twitch_addon/addon/twitch_exceptions.py +++ b/resources/lib/twitch_addon/addon/twitch_exceptions.py @@ -27,3 +27,9 @@ class NotFound(Exception): class PlaybackFailed(Exception): pass + + +class TokenRefreshed(Exception): + """Signal raised by error_check() when a 401 was recovered by a silent token + refresh, so api_error_handler can reload the token and retry the request once.""" + pass diff --git a/resources/lib/twitch_addon/addon/utils.py b/resources/lib/twitch_addon/addon/utils.py index 85431754..71928475 100644 --- a/resources/lib/twitch_addon/addon/utils.py +++ b/resources/lib/twitch_addon/addon/utils.py @@ -337,6 +337,11 @@ def ensure_valid_token(force=False): # falls back to the manual login until they configure a public client id. if not force and kodi.get_setting('oauth_refresh_unsupported') == client_id: return (_read_oauth_store().get('access') or '').strip() + # While Kodi reports no internet, skip the (doomed) refresh entirely instead of hanging + # up to ~15s on a dead connection. The stored token is kept untouched and refreshed once + # we're back online. A forced recovery (post-401) bypasses this and attempts anyway. + if not force and not kodi.connection_available(): + return (_read_oauth_store().get('access') or '').strip() with _OAuthLock(): store = _read_oauth_store() refresh_token = (store.get('refresh') or '').strip()