From b4961707ad0a8474f0a2c108bb20f9e31592ddad Mon Sep 17 00:00:00 2001 From: C1-BA-B1-F3 Date: Fri, 26 Jun 2026 07:50:18 +0800 Subject: [PATCH 1/4] Fix blocking commands timeout when blocking_timeout exceeds socket_timeout Issue #2807: Blocking commands like BRPOP, BLPOP, BRPOPLPUSH, BLMOVE, and BLMPOP would fail with TimeoutError when the command's blocking timeout exceeded the client's socket_timeout. The root cause was that blocking commands sent the timeout to the Redis server but did not pass it to the connection's read_response() method. The parser already supports custom timeouts via the timeout parameter, but it wasn't being used. This fix: - Adds _blocking_timeout option to all blocking list commands - Modifies parse_response() in sync, async, and cluster clients to extract _blocking_timeout and pass it to connection.read_response() - The socket buffer temporarily uses the blocking timeout for the read operation, preventing premature socket timeouts Fixes #2807 --- redis/asyncio/client.py | 6 +++++- redis/asyncio/cluster.py | 6 +++++- redis/client.py | 6 +++++- redis/commands/core.py | 20 +++++++++++++++----- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/redis/asyncio/client.py b/redis/asyncio/client.py index a0ef8172a3..fe43463e77 100644 --- a/redis/asyncio/client.py +++ b/redis/asyncio/client.py @@ -874,12 +874,16 @@ async def parse_response( self, connection: Connection, command_name: Union[str, bytes], **options ): """Parses a response from the Redis server""" + # Extract blocking timeout for blocking commands (BLPOP, BRPOP, etc.) + # This ensures the socket timeout is long enough to wait for the + # blocking command's timeout + blocking_timeout = options.pop("_blocking_timeout", SENTINEL) try: if NEVER_DECODE in options: response = await connection.read_response(disable_decoding=True) options.pop(NEVER_DECODE) else: - response = await connection.read_response() + response = await connection.read_response(timeout=blocking_timeout) except ResponseError: if EMPTY_RESPONSE in options: return options[EMPTY_RESPONSE] diff --git a/redis/asyncio/cluster.py b/redis/asyncio/cluster.py index 7c86b85bff..78f095857f 100644 --- a/redis/asyncio/cluster.py +++ b/redis/asyncio/cluster.py @@ -1657,12 +1657,16 @@ async def disconnect_free_connections(self) -> None: async def parse_response( self, connection: Connection, command: str, **kwargs: Any ) -> Any: + # Extract blocking timeout for blocking commands (BLPOP, BRPOP, etc.) + # This ensures the socket timeout is long enough to wait for the + # blocking command's timeout + blocking_timeout = kwargs.pop("_blocking_timeout", None) try: if NEVER_DECODE in kwargs: response = await connection.read_response(disable_decoding=True) kwargs.pop(NEVER_DECODE) else: - response = await connection.read_response() + response = await connection.read_response(timeout=blocking_timeout) except ResponseError: if EMPTY_RESPONSE in kwargs: return kwargs[EMPTY_RESPONSE] diff --git a/redis/client.py b/redis/client.py index 9f1496b7e9..4138f3150e 100755 --- a/redis/client.py +++ b/redis/client.py @@ -856,12 +856,16 @@ def failure_callback(error, failure_count): def parse_response(self, connection, command_name, **options): """Parses a response from the Redis server""" + # Extract blocking timeout for blocking commands (BLPOP, BRPOP, etc.) + # This ensures the socket timeout is long enough to wait for the + # blocking command's timeout + blocking_timeout = options.pop("_blocking_timeout", SENTINEL) try: if NEVER_DECODE in options: response = connection.read_response(disable_decoding=True) options.pop(NEVER_DECODE) else: - response = connection.read_response() + response = connection.read_response(timeout=blocking_timeout) except ResponseError: if EMPTY_RESPONSE in options: return options[EMPTY_RESPONSE] diff --git a/redis/commands/core.py b/redis/commands/core.py index 5967dd3f35..b76f6c3340 100644 --- a/redis/commands/core.py +++ b/redis/commands/core.py @@ -3603,7 +3603,9 @@ def blmove( For more information, see https://redis.io/commands/blmove """ params = [first_list, second_list, src, dest, timeout] - return self.execute_command("BLMOVE", *params) + return self.execute_command( + "BLMOVE", *params, _blocking_timeout=timeout + ) @overload def mget( @@ -4717,7 +4719,9 @@ def blpop( timeout = 0 keys = list_or_args(keys, None) keys.append(timeout) - return self.execute_command("BLPOP", *keys) + return self.execute_command( + "BLPOP", *keys, _blocking_timeout=timeout + ) @overload def brpop( @@ -4748,7 +4752,9 @@ def brpop( timeout = 0 keys = list_or_args(keys, None) keys.append(timeout) - return self.execute_command("BRPOP", *keys) + return self.execute_command( + "BRPOP", *keys, _blocking_timeout=timeout + ) @overload def brpoplpush( @@ -4775,7 +4781,9 @@ def brpoplpush(self, src: KeyT, dst: KeyT, timeout: Number | None = 0) -> ( """ if timeout is None: timeout = 0 - return self.execute_command("BRPOPLPUSH", src, dst, timeout) + return self.execute_command( + "BRPOPLPUSH", src, dst, timeout, _blocking_timeout=timeout + ) @overload def blmpop( @@ -4816,7 +4824,9 @@ def blmpop( """ cmd_args = [timeout, numkeys, *args, direction, "COUNT", count] - return self.execute_command("BLMPOP", *cmd_args) + return self.execute_command( + "BLMPOP", *cmd_args, _blocking_timeout=timeout + ) @overload def lmpop( From a88e6fe3435ff4f8bd815d8dd5c7aa7688724160 Mon Sep 17 00:00:00 2001 From: C1-BA-B1-F3 Date: Fri, 26 Jun 2026 12:07:40 +0800 Subject: [PATCH 2/4] fix(async): use None instead of SENTINEL for blocking timeout default - Change default from SENTINEL to None for _blocking_timeout in async client parse_response(), matching the cluster client behavior - Convert _blocking_timeout=0 to None to prevent zero-second socket timeout when Redis should block indefinitely Fixes two high-severity issues: 1. Async client passed SENTINEL object as literal timeout to read_response(), which only treats None as 'use default' 2. Zero timeout (indefinite blocking) was passed as 0 to socket, causing immediate timeout instead of waiting for server response --- redis/asyncio/client.py | 7 ++++++- redis/asyncio/cluster.py | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/redis/asyncio/client.py b/redis/asyncio/client.py index fe43463e77..9e90c5b7fa 100644 --- a/redis/asyncio/client.py +++ b/redis/asyncio/client.py @@ -877,7 +877,12 @@ async def parse_response( # Extract blocking timeout for blocking commands (BLPOP, BRPOP, etc.) # This ensures the socket timeout is long enough to wait for the # blocking command's timeout - blocking_timeout = options.pop("_blocking_timeout", SENTINEL) + blocking_timeout = options.pop("_blocking_timeout", None) + # When the Redis server is told to block indefinitely (timeout=0), + # the client must also wait indefinitely rather than issuing a + # zero-second socket read. + if blocking_timeout == 0: + blocking_timeout = None try: if NEVER_DECODE in options: response = await connection.read_response(disable_decoding=True) diff --git a/redis/asyncio/cluster.py b/redis/asyncio/cluster.py index 78f095857f..11788e4181 100644 --- a/redis/asyncio/cluster.py +++ b/redis/asyncio/cluster.py @@ -1661,6 +1661,11 @@ async def parse_response( # This ensures the socket timeout is long enough to wait for the # blocking command's timeout blocking_timeout = kwargs.pop("_blocking_timeout", None) + # When the Redis server is told to block indefinitely (timeout=0), + # the client must also wait indefinitely rather than issuing a + # zero-second socket read. + if blocking_timeout == 0: + blocking_timeout = None try: if NEVER_DECODE in kwargs: response = await connection.read_response(disable_decoding=True) From 1766bdc868cf9112b3d2f7efabb5914155e61b74 Mon Sep 17 00:00:00 2001 From: C1-BA-B1-F3 Date: Fri, 26 Jun 2026 13:07:42 +0800 Subject: [PATCH 3/4] Fix sync zero blocking timeout: convert 0 to None for indefinite blocking The sync parse_response was passing blocking_timeout=0 directly to read_response(), causing a zero-second socket read that raises TimeoutError immediately. The async clients already convert 0 to None for indefinite blocking; this applies the same fix to the sync path. --- redis/client.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/redis/client.py b/redis/client.py index 4138f3150e..c38d03c055 100755 --- a/redis/client.py +++ b/redis/client.py @@ -860,6 +860,11 @@ def parse_response(self, connection, command_name, **options): # This ensures the socket timeout is long enough to wait for the # blocking command's timeout blocking_timeout = options.pop("_blocking_timeout", SENTINEL) + # When the Redis server is told to block indefinitely (timeout=0), + # the client must also wait indefinitely rather than issuing a + # zero-second socket read. + if blocking_timeout == 0: + blocking_timeout = None try: if NEVER_DECODE in options: response = connection.read_response(disable_decoding=True) From 8f3007415948a315662e2b52fb4323b8e932a338 Mon Sep 17 00:00:00 2001 From: C1-BA-B1-F3 Date: Tue, 14 Jul 2026 09:33:35 +0800 Subject: [PATCH 4/4] fix(async): map blocking timeout 0 to math.inf for indefinite wait Async Connection.read_response treats timeout=None as "use socket_timeout" and math.inf as "block with no timeout". Mapping Redis BLPOP timeout=0 to None therefore still aborted after socket_timeout. Map 0 -> math.inf on the async client and cluster paths, document the sync/async asymmetry, and add unit coverage for the mapping. --- redis/asyncio/client.py | 11 +++-- redis/asyncio/cluster.py | 12 +++-- redis/client.py | 3 ++ tests/test_asyncio/test_blocking_timeout.py | 55 +++++++++++++++++++++ 4 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 tests/test_asyncio/test_blocking_timeout.py diff --git a/redis/asyncio/client.py b/redis/asyncio/client.py index 9e90c5b7fa..6910080213 100644 --- a/redis/asyncio/client.py +++ b/redis/asyncio/client.py @@ -877,12 +877,15 @@ async def parse_response( # Extract blocking timeout for blocking commands (BLPOP, BRPOP, etc.) # This ensures the socket timeout is long enough to wait for the # blocking command's timeout + # Default None means "no blocking override" so read_response falls + # back to connection.socket_timeout. This differs from the sync client, + # where the default is SENTINEL and timeout=None means block forever. blocking_timeout = options.pop("_blocking_timeout", None) - # When the Redis server is told to block indefinitely (timeout=0), - # the client must also wait indefinitely rather than issuing a - # zero-second socket read. + # Redis timeout=0 means block indefinitely on the server. Async + # Connection.read_response treats timeout=None as "use socket_timeout" + # and timeout=math.inf as "block with no timeout", so map 0 -> inf. if blocking_timeout == 0: - blocking_timeout = None + blocking_timeout = math.inf try: if NEVER_DECODE in options: response = await connection.read_response(disable_decoding=True) diff --git a/redis/asyncio/cluster.py b/redis/asyncio/cluster.py index 11788e4181..ddabb7c21d 100644 --- a/redis/asyncio/cluster.py +++ b/redis/asyncio/cluster.py @@ -1,6 +1,7 @@ import asyncio import collections import logging +import math import random import socket import threading @@ -1660,12 +1661,15 @@ async def parse_response( # Extract blocking timeout for blocking commands (BLPOP, BRPOP, etc.) # This ensures the socket timeout is long enough to wait for the # blocking command's timeout + # Default None means "no blocking override" so read_response falls + # back to connection.socket_timeout. This differs from the sync client, + # where the default is SENTINEL and timeout=None means block forever. blocking_timeout = kwargs.pop("_blocking_timeout", None) - # When the Redis server is told to block indefinitely (timeout=0), - # the client must also wait indefinitely rather than issuing a - # zero-second socket read. + # Redis timeout=0 means block indefinitely on the server. Async + # Connection.read_response treats timeout=None as "use socket_timeout" + # and timeout=math.inf as "block with no timeout", so map 0 -> inf. if blocking_timeout == 0: - blocking_timeout = None + blocking_timeout = math.inf try: if NEVER_DECODE in kwargs: response = await connection.read_response(disable_decoding=True) diff --git a/redis/client.py b/redis/client.py index c38d03c055..58773303bc 100755 --- a/redis/client.py +++ b/redis/client.py @@ -859,6 +859,9 @@ def parse_response(self, connection, command_name, **options): # Extract blocking timeout for blocking commands (BLPOP, BRPOP, etc.) # This ensures the socket timeout is long enough to wait for the # blocking command's timeout + # Default SENTINEL means "no blocking override" so read_response uses + # the configured socket_timeout. Unlike async, timeout=None here means + # block forever (settimeout(None)), so Redis timeout=0 maps to None. blocking_timeout = options.pop("_blocking_timeout", SENTINEL) # When the Redis server is told to block indefinitely (timeout=0), # the client must also wait indefinitely rather than issuing a diff --git a/tests/test_asyncio/test_blocking_timeout.py b/tests/test_asyncio/test_blocking_timeout.py new file mode 100644 index 0000000000..85fbaff68c --- /dev/null +++ b/tests/test_asyncio/test_blocking_timeout.py @@ -0,0 +1,55 @@ +"""Unit tests for async blocking-command timeout mapping. + +These do not require a live Redis server. +""" + +import math +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import redis.asyncio as redis + + +@pytest.mark.asyncio +async def test_async_parse_response_maps_zero_blocking_timeout_to_math_inf(): + """Redis timeout=0 must block indefinitely on async reads. + + Async Connection.read_response treats: + - None as \"use socket_timeout\" + - math.inf as \"no timeout\" + so parse_response must map _blocking_timeout=0 to math.inf, not None. + """ + conn = MagicMock() + conn.read_response = AsyncMock(return_value=None) + + client = redis.Redis(connection_pool=MagicMock()) + result = await client.parse_response(conn, "BLPOP", _blocking_timeout=0) + + assert result is None + conn.read_response.assert_awaited_once_with(timeout=math.inf) + + +@pytest.mark.asyncio +async def test_async_parse_response_forwards_positive_blocking_timeout(): + conn = MagicMock() + conn.read_response = AsyncMock(return_value=None) + + client = redis.Redis(connection_pool=MagicMock()) + await client.parse_response(conn, "BLPOP", _blocking_timeout=5) + + conn.read_response.assert_awaited_once_with(timeout=5) + + +@pytest.mark.asyncio +async def test_async_parse_response_without_blocking_timeout_uses_default(): + conn = MagicMock() + conn.read_response = AsyncMock(return_value=b"OK") + + client = redis.Redis(connection_pool=MagicMock()) + # Use a command without a response callback so the raw value is returned. + result = await client.parse_response(conn, "CUSTOMCMD") + + assert result == b"OK" + # No override: default None -> read_response falls back to socket_timeout. + conn.read_response.assert_awaited_once_with(timeout=None)