Skip to content
Draft
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
5 changes: 5 additions & 0 deletions docs/connections.rst
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ Connection
.. autoclass:: redis.connection.Connection
:members:

For deployments that use rotating credentials, ``max_connection_lifetime``
can be set in seconds. A connection that reaches this age is refreshed the
next time it is checked out from the pool, allowing the configured credential
provider to obtain fresh credentials without interrupting an active command.

Connection (Async)
==================
.. autoclass:: redis.asyncio.connection.Connection
Expand Down
30 changes: 29 additions & 1 deletion redis/asyncio/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,8 @@ class AbstractConnection(AsyncMaintNotificationsAbstractConnection):
"retry_on_timeout",
"retry_on_error",
"health_check_interval",
"max_connection_lifetime",
"_connection_created_at",
"next_health_check",
"last_active_at",
"encoder",
Expand Down Expand Up @@ -597,6 +599,7 @@ def __init__(
parser_class: Type[BaseParser] = DefaultParser,
socket_read_size: int = DEFAULT_SOCKET_READ_SIZE,
health_check_interval: float = 0,
max_connection_lifetime: float | None = None,
client_name: str | None = None,
lib_name: str | object | None = SENTINEL,
lib_version: str | object | None = SENTINEL,
Expand Down Expand Up @@ -627,6 +630,11 @@ def __init__(

Parameters
----------
max_connection_lifetime : float, optional
Maximum time in seconds to reuse a connected socket. When the limit
is reached, the next connection attempt reconnects the socket so
credential providers can refresh their credentials. ``None``
disables the limit.
driver_info : DriverInfo, optional
Driver metadata for CLIENT SETINFO. If provided, lib_name and lib_version
are ignored. If not provided, a DriverInfo will be created from lib_name
Expand Down Expand Up @@ -679,6 +687,10 @@ def __init__(
else:
self.retry = Retry(NoBackoff(), 0)
self.health_check_interval = health_check_interval
if max_connection_lifetime is not None and max_connection_lifetime <= 0:
raise ValueError("max_connection_lifetime must be positive")
self.max_connection_lifetime = max_connection_lifetime
self._connection_created_at: float | None = None
self.next_health_check: float = -1
self.encoder = encoder_class(encoding, encoding_errors, decode_responses)
self.redis_connect_func = redis_connect_func
Expand Down Expand Up @@ -818,11 +830,21 @@ async def connect(self):
with_failure_count=True,
)

def is_connection_expired(self) -> bool:
return (
self.max_connection_lifetime is not None
and self._connection_created_at is not None
and time.monotonic() - self._connection_created_at
>= self.max_connection_lifetime
)

async def connect_check_health(
self, check_health: bool = True, retry_socket_connect: bool = True
):
if self.is_connected:
return
if not self.is_connection_expired():
return
await self.disconnect()
# Track actual retry attempts for error reporting
actual_retry_attempts = 0

Expand Down Expand Up @@ -895,6 +917,9 @@ def failure_callback(error, failure_count):
if task and inspect.isawaitable(task):
await task

if self.max_connection_lifetime is not None:
self._connection_created_at = time.monotonic()

def mark_for_reconnect(self):
self._should_reconnect = True

Expand Down Expand Up @@ -1052,6 +1077,7 @@ async def disconnect(
health_check_failed: bool = False,
) -> None:
"""Disconnects from the Redis server"""
self._connection_created_at = None
# On Python 3.13+, asyncio.timeout() raises RuntimeError when called
# outside a running Task (e.g. during GC finalization or event-loop
# callbacks). In that context we fall back to a synchronous close.
Expand Down Expand Up @@ -1717,6 +1743,7 @@ def parse_ssl_verify_flags(value):
"db": int,
"socket_timeout": float,
"socket_connect_timeout": float,
"max_connection_lifetime": float,
"socket_read_size": int,
"socket_keepalive": to_bool,
"retry_on_timeout": to_bool,
Expand All @@ -1740,6 +1767,7 @@ class ConnectKwargs(TypedDict, total=False):
host: str
port: int
db: int
max_connection_lifetime: float
path: str


Expand Down
4 changes: 3 additions & 1 deletion redis/asyncio/sentinel.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ async def connect_to(self, address):

async def _connect_retry(self):
if self._reader:
return # already connected
if not self.is_connection_expired():
return # already connected
await self.disconnect()
if self.connection_pool.is_master:
await self.connect_to(await self.connection_pool.get_master_address())
else:
Expand Down
27 changes: 26 additions & 1 deletion redis/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,7 @@ def __init__(
parser_class=DefaultParser,
socket_read_size: int = DEFAULT_SOCKET_READ_SIZE,
health_check_interval: int = 0,
max_connection_lifetime: Optional[float] = None,
client_name: Optional[str] = None,
lib_name: Union[Optional[str], object] = SENTINEL,
lib_version: Union[Optional[str], object] = SENTINEL,
Expand Down Expand Up @@ -850,6 +851,11 @@ def __init__(
`retry` to a valid `Retry` object.
To retry on TimeoutError, `retry_on_timeout` can also be set to `True`.

`max_connection_lifetime` limits how long a connected socket can be
reused. When the limit is reached, the next connection attempt closes
the socket and reconnects, allowing credential providers to refresh
their credentials. `None` disables the limit.

Parameters
----------
driver_info : DriverInfo, optional
Expand Down Expand Up @@ -907,6 +913,10 @@ def __init__(
else:
self.retry = Retry(NoBackoff(), 0)
self.health_check_interval = health_check_interval
if max_connection_lifetime is not None and max_connection_lifetime <= 0:
raise ValueError("max_connection_lifetime must be positive")
self.max_connection_lifetime = max_connection_lifetime
self._connection_created_at: Optional[float] = None
self.next_health_check = 0
self.redis_connect_func = redis_connect_func
self.encoder = Encoder(encoding, encoding_errors, decode_responses)
Expand Down Expand Up @@ -1025,11 +1035,21 @@ def connect(self):
lambda error: self.disconnect(error),
)

def is_connection_expired(self) -> bool:
return (
self.max_connection_lifetime is not None
and self._connection_created_at is not None
and time.monotonic() - self._connection_created_at
>= self.max_connection_lifetime
)

def connect_check_health(
self, check_health: bool = True, retry_socket_connect: bool = True
):
if self._sock:
return
if not self.is_connection_expired():
return
self.disconnect()
# Track actual retry attempts for error reporting
actual_retry_attempts = [0]

Expand Down Expand Up @@ -1091,6 +1111,9 @@ def failure_callback(error, failure_count):
if callback:
callback(self)

if self.max_connection_lifetime is not None:
self._connection_created_at = time.monotonic()

@abstractmethod
def _connect(self):
pass
Expand Down Expand Up @@ -1227,6 +1250,7 @@ def disconnect(self, *args, **kwargs):

conn_sock = self._sock
self._sock = None
self._connection_created_at = None
# reset the reconnect flag
self.reset_should_reconnect()

Expand Down Expand Up @@ -2259,6 +2283,7 @@ def parse_ssl_verify_flags(value):
"db": int,
"socket_timeout": float,
"socket_connect_timeout": float,
"max_connection_lifetime": float,
"socket_read_size": int,
"socket_keepalive": to_bool,
"retry_on_timeout": to_bool,
Expand Down
4 changes: 3 additions & 1 deletion redis/sentinel.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ def connect_to(self, address):

def _connect_retry(self):
if self._sock:
return # already connected
if not self.is_connection_expired():
return # already connected
self.disconnect()
if self.connection_pool.is_master:
self.connect_to(self.connection_pool.get_master_address())
else:
Expand Down
57 changes: 57 additions & 0 deletions tests/test_asyncio/test_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,63 @@ async def get_conn():
await r.aclose()


@pytest.mark.asyncio
async def test_reconnects_expired_connection():
conn = Connection(max_connection_lifetime=10)
conn._reader = mock.Mock()
conn._writer = mock.Mock()
conn._connection_created_at = 0

async def disconnect():
conn._reader = None
conn._writer = None

async def connect():
conn._reader = mock.Mock()
conn._writer = mock.Mock()

conn.disconnect = mock.AsyncMock(side_effect=disconnect)
conn._connect = mock.AsyncMock(side_effect=connect)
conn.on_connect_check_health = mock.AsyncMock()

with patch("redis.asyncio.connection.time.monotonic", return_value=11):
await conn.connect_check_health()

conn.disconnect.assert_awaited_once_with()
conn._connect.assert_awaited_once_with()
assert conn._connection_created_at == 11
conn._reader = None
conn._writer = None


@pytest.mark.asyncio
async def test_reuses_connection_before_lifetime_expires():
conn = Connection(max_connection_lifetime=10)
conn._reader = mock.Mock()
conn._writer = mock.Mock()
conn._connection_created_at = 0
conn.disconnect = mock.AsyncMock()
conn._connect = mock.AsyncMock()

with patch("redis.asyncio.connection.time.monotonic", return_value=9):
await conn.connect_check_health()

conn.disconnect.assert_not_awaited()
conn._connect.assert_not_awaited()
conn._reader = None
conn._writer = None


@pytest.mark.asyncio
async def test_parse_url_connection_lifetime():
assert (
parse_url("redis://localhost?max_connection_lifetime=60")[
"max_connection_lifetime"
]
== 60.0
)


@skip_if_server_version_lt("4.0.0")
@pytest.mark.redismod
@pytest.mark.onlynoncluster
Expand Down
41 changes: 41 additions & 0 deletions tests/test_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,39 @@ def test_redis_client_default_driver_info():

@pytest.mark.fixed_client
class TestConnection:
@pytest.mark.parametrize("max_connection_lifetime", [0, -1])
def test_rejects_non_positive_connection_lifetime(self, max_connection_lifetime):
with pytest.raises(ValueError, match="max_connection_lifetime"):
Connection(max_connection_lifetime=max_connection_lifetime)

def test_reconnects_expired_connection(self):
conn = Connection(max_connection_lifetime=10)
conn._sock = mock.Mock()
conn._connection_created_at = 0
conn.disconnect = mock.Mock(side_effect=lambda: setattr(conn, "_sock", None))
conn._connect = mock.Mock(return_value=mock.Mock())
conn.on_connect_check_health = mock.Mock()

with patch("redis.connection.time.monotonic", return_value=11):
conn.connect_check_health()

conn.disconnect.assert_called_once_with()
conn._connect.assert_called_once_with()
assert conn._connection_created_at == 11

def test_reuses_connection_before_lifetime_expires(self):
conn = Connection(max_connection_lifetime=10)
conn._sock = mock.Mock()
conn._connection_created_at = 0
conn.disconnect = mock.Mock()
conn._connect = mock.Mock()

with patch("redis.connection.time.monotonic", return_value=9):
conn.connect_check_health()

conn.disconnect.assert_not_called()
conn._connect.assert_not_called()

def test_disconnect(self):
conn = Connection()
mock_sock = mock.Mock()
Expand Down Expand Up @@ -662,6 +695,14 @@ def test_connect_timeout_error_without_retry(self):
assert conn._connect.call_count == 1
self.clear(conn)

def test_parse_url_connection_lifetime(self):
assert (
parse_url("redis://localhost?max_connection_lifetime=60")[
"max_connection_lifetime"
]
== 60.0
)


@pytest.mark.onlynoncluster
@pytest.mark.parametrize(
Expand Down