Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions core/redis_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
)
Expand Down
10 changes: 5 additions & 5 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
70 changes: 70 additions & 0 deletions tests/test_redis_client.py
Original file line number Diff line number Diff line change
@@ -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()
Loading