diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2519e8f..047be9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: # 3.10 = Ubuntu 22.04, 3.11 = Debian 12 (production targets); 3.12 = current. python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: actions/setup-python@v7 with: @@ -54,7 +54,7 @@ jobs: name: Shell scripts runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Syntax check run: | for script in install.sh scripts/streamvault_vps.sh bin/streamvault; do diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c0583c..70d9959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ All notable changes to this project are documented here. The format follows ### Breaking changes +- **New minimum versions, reinstall the requirements** after updating + (`pip install -r requirements.txt` in the virtualenv): redis ≥ 8.1.0, + streamlink ≥ 8.6.3, markupsafe ≥ 3.0.4, pycryptodome ≥ 4.4.0, + numpy ≥ 2.2.6. StreamVault keeps redis-py 5 behaviour: RESP2 (Redis servers + older than 6 keep working) and one retry on timeouts only, so an unreachable + Redis still falls back to local mode after a single connect attempt. - **Sessions expire after 30 days** (`VOD_SESSION_MAX_AGE_SEC`, `0` restores the old unlimited behaviour). Logout ends the session in this browser; the new "log out everywhere" action (`POST /logout/all`) and every password diff --git a/core/redis_client.py b/core/redis_client.py index 4f8309e..28ea239 100644 --- a/core/redis_client.py +++ b/core/redis_client.py @@ -22,6 +22,17 @@ REDIS_DB = int(os.environ.get("REDIS_DB", "0")) REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", None) + +def _legacy_retry(): + """Retry policy of redis-py 5: one immediate retry, on timeouts only.""" + import socket + + from redis.backoff import NoBackoff + from redis.exceptions import TimeoutError as RedisTimeoutError + from redis.retry import Retry + + return Retry(NoBackoff(), 1, supported_errors=(RedisTimeoutError, socket.timeout)) + KEY_PREFIX = "streamvault:" TASK_QUEUE_KEY = f"{KEY_PREFIX}tasks:pending" TASK_INFLIGHT_KEY = f"{KEY_PREFIX}tasks:inflight" @@ -53,6 +64,12 @@ def get_redis(): socket_timeout=3.0, socket_connect_timeout=3.0, retry_on_timeout=True, + # redis-py 8 changed the defaults: RESP3, and connection errors are + # retried too. Pin the redis-py 5 behaviour: RESP2 for older servers, + # one retry on timeouts only, so an unreachable Redis still falls + # back to local mode after a single connect attempt. + retry=_legacy_retry(), + protocol=2, max_connections=50, decode_responses=True, ) diff --git a/requirements.txt b/requirements.txt index 8ec16de..269a4dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,12 +5,12 @@ Flask-Babel>=4.0.0,<5 Werkzeug>=3.0.0,<3.2.0 Jinja2>=3.1.2 requests>=2.31.0 -streamlink>=8.0.0 +streamlink>=8.6.3 yt-dlp>=2024.1.0 -redis>=5.0.0 +redis>=8.1.0 limits>=3.10.0 -markupsafe>=2.1.0 +markupsafe>=3.0.4 gunicorn>=21.2.0; sys_platform != "win32" -pycryptodome>=3.20.0 -numpy>=1.26.0,<3.0.0 +pycryptodome>=4.4.0 +numpy>=2.2.6,<3.0.0 boto3>=1.34.0 diff --git a/tests/test_redis_client.py b/tests/test_redis_client.py new file mode 100644 index 0000000..28fa813 --- /dev/null +++ b/tests/test_redis_client.py @@ -0,0 +1,70 @@ +"""Redis connection settings that must survive dependency upgrades.""" + +import socket +import unittest +from unittest import mock + +import redis + +from core import redis_client + + +class RedisPoolSettingsTests(unittest.TestCase): + def setUp(self): + self._saved = ( + redis_client._REDIS_CLIENT, + redis_client._REDIS_AVAILABLE, + redis_client._LAST_CONNECT_TRY, + ) + redis_client._REDIS_CLIENT = None + redis_client._REDIS_AVAILABLE = None + redis_client._LAST_CONNECT_TRY = 0 + + def tearDown(self): + ( + redis_client._REDIS_CLIENT, + redis_client._REDIS_AVAILABLE, + redis_client._LAST_CONNECT_TRY, + ) = self._saved + + def _connection(self): + """Build the real pool get_redis() builds and return one (unconnected) connection.""" + with mock.patch.object(redis, "Redis") as redis_cls: + redis_cls.return_value.ping.return_value = True + redis_client.get_redis() + pool = redis_cls.call_args.kwargs["connection_pool"] + self.assertIsInstance(pool, redis.ConnectionPool) + return pool.make_connection() + + def test_pool_pins_resp2_for_older_servers(self): + self.assertEqual(self._connection().protocol, 2) + + def test_timeouts_are_retried_once(self): + retry = self._connection().retry + self.assertEqual(retry.get_retries(), 1) + self.assertIn(redis.exceptions.TimeoutError, retry._supported_errors) + self.assertIn(socket.timeout, retry._supported_errors) + + def test_connection_errors_are_not_retried(self): + # An unreachable Redis must fall back to local mode after one connect attempt. + retry = self._connection().retry + attempts = [] + + def refuse(): + attempts.append(1) + raise redis.exceptions.ConnectionError("refused") + + with self.assertRaises(redis.exceptions.ConnectionError): + retry.call_with_retry(refuse, lambda error: None) + self.assertEqual(len(attempts), 1) + + def test_unreachable_redis_falls_back_to_none(self): + with mock.patch.object(redis, "Redis") as redis_cls: + redis_cls.return_value.ping.side_effect = redis.ConnectionError("refused") + with self.assertLogs(redis_client.logger, level="WARNING"): + self.assertIsNone(redis_client.get_redis()) + self.assertFalse(redis_client._REDIS_AVAILABLE) + + +if __name__ == "__main__": + unittest.main()