From 9ef7a8bd06651e4959342a98ffc8b663b2d51fdb Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 13 Oct 2026 19:59:14 -0600 Subject: [PATCH] fix(session): cap Retry-After on the requests retry policies A server-sent Retry-After could park a run for up to six hours. --- tests/core/test_retry_after.py | 16 +++++++++++++++- unshackle/core/providers/_base.py | 2 +- unshackle/core/remote_service.py | 8 +++++++- unshackle/core/service.py | 1 + unshackle/core/tracks/track.py | 9 +++++++-- 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/tests/core/test_retry_after.py b/tests/core/test_retry_after.py index 0f4ba101..f21fdf80 100644 --- a/tests/core/test_retry_after.py +++ b/tests/core/test_retry_after.py @@ -6,7 +6,9 @@ from types import SimpleNamespace import pytest -from unshackle.core.session import RnetSession +from unshackle.core.providers.imdb import IMDBProvider +from unshackle.core.service import Service +from unshackle.core.session import MAX_BACKOFF, RnetSession @pytest.fixture @@ -67,3 +69,15 @@ def test_exponential_backoff_without_retry_after(session): def test_no_response_still_backs_off(session): assert session.get_sleep_time(None, 30) == session.max_backoff + + +def test_service_session_caps_retry_after(): + retry = Service.get_session().get_adapter("https://example.invalid/").max_retries + assert retry.respect_retry_after_header + assert retry.retry_after_max == MAX_BACKOFF + + +def test_provider_session_gives_up_on_a_rate_limit(): + retry = IMDBProvider().session.get_adapter("https://example.invalid/").max_retries + assert not retry.respect_retry_after_header + assert 429 not in retry.status_forcelist diff --git a/unshackle/core/providers/_base.py b/unshackle/core/providers/_base.py index c8d60a08..f42b4353 100644 --- a/unshackle/core/providers/_base.py +++ b/unshackle/core/providers/_base.py @@ -61,7 +61,7 @@ class MetadataProvider(metaclass=ABCMeta): retry = Retry( total=3, backoff_factor=1, - status_forcelist=[429, 500, 502, 503, 504], + status_forcelist=[500, 502, 503, 504], allowed_methods=["GET", "POST"], respect_retry_after_header=False, ) diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index e4af1964..aa283c16 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -38,6 +38,7 @@ from unshackle.core.console import console, prompt_user from unshackle.core.constants import AnyTrack from unshackle.core.credential import Credential from unshackle.core.proxies.resolve import is_loopback +from unshackle.core.session import MAX_BACKOFF from unshackle.core.titles import Title_T, Titles_T, remap_titles from unshackle.core.titles.episode import Episode, Series from unshackle.core.titles.movie import Movie, Movies @@ -921,7 +922,12 @@ class RemoteService: self._session.mount( "https://", HTTPAdapter( - max_retries=Retry(total=5, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503, 504]), + max_retries=Retry( + total=5, + backoff_factor=0.2, + status_forcelist=[429, 500, 502, 503, 504], + retry_after_max=MAX_BACKOFF, + ), pool_maxsize=64, pool_block=True, ), diff --git a/unshackle/core/service.py b/unshackle/core/service.py index e98492f9..f5fac123 100644 --- a/unshackle/core/service.py +++ b/unshackle/core/service.py @@ -431,6 +431,7 @@ class Service(metaclass=ABCMeta): status_forcelist=STATUS_FORCELIST, allowed_methods=RETRY_METHODS, respect_retry_after_header=True, + retry_after_max=MAX_BACKOFF, ), pool_connections=POOL_MAX_SIZE, pool_maxsize=POOL_MAX_SIZE, diff --git a/unshackle/core/tracks/track.py b/unshackle/core/tracks/track.py index 890aa909..de5699a8 100644 --- a/unshackle/core/tracks/track.py +++ b/unshackle/core/tracks/track.py @@ -31,7 +31,7 @@ from unshackle.core.downloaders import requests from unshackle.core.drm import DRM_T, ClearKeyCENC, PlayReady, Widevine from unshackle.core.drm.verify import decrypt_track from unshackle.core.events import events -from unshackle.core.session import RnetSession, utf8_text +from unshackle.core.session import MAX_BACKOFF, RnetSession, utf8_text from unshackle.core.tracks import resume from unshackle.core.utilities import get_boxes, log_event, try_ensure_utf8 from unshackle.core.utils.subprocess import ffprobe @@ -78,7 +78,12 @@ def direct_session(session: Union[Session, "RnetSession"], proxy: Optional[str] new.mount( "https://", HTTPAdapter( - max_retries=Retry(total=5, backoff_factor=0.2, status_forcelist=[429, 500, 502, 503, 504]), + max_retries=Retry( + total=5, + backoff_factor=0.2, + status_forcelist=[429, 500, 502, 503, 504], + retry_after_max=MAX_BACKOFF, + ), pool_maxsize=64, pool_block=True, ),