Fix blocking commands timeout when blocking_timeout exceeds socket_timeout - #4143
Fix blocking commands timeout when blocking_timeout exceeds socket_timeout#4143C1-BA-B1-F3 wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit efce672. Configure here.
|
Please clean up the branch from unrelated changes. |
…meout Issue redis#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 redis#2807
- 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
…king 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.
efce672 to
1766bdc
Compare
|
Branch cleaned up @petyaslavova! Rebased onto upstream master, only the blocking timeout commits remain. Unrelated hiredis/FT.INFO changes removed. |
|
Is I'm seeing regular timeout errors after upgrading to v8.0.1. I did not change anything else. EDIT: FTR, I used |
|
@simonhammes Good catch — you're right that So yes: with Do you want me to extend this fix to |
eeshsaxena
left a comment
There was a problem hiding this comment.
The overall approach (thread the blocking command's own timeout through to the socket read so socket_timeout doesn't abort it) is right, but I think the timeout == 0 -> None mapping is incorrect on the async path, and it's exactly the "block indefinitely" case.
redis/asyncio/connection.py::read_response documents its timeout semantics as:
None(default): fall back toself.socket_timeout.math.inf: block indefinitely with no timeout.float: apply that timeout for this single read.
So on the async side, mapping the server-side "block forever" value (BLPOP key 0) to None:
blocking_timeout = options.pop("_blocking_timeout", None)
if blocking_timeout == 0:
blocking_timeout = None
...
response = await connection.read_response(timeout=blocking_timeout)makes the read fall back to self.socket_timeout rather than blocking indefinitely. With a socket_timeout configured, BLPOP key 0 (or BLPOP with timeout 0) would abort after socket_timeout instead of waiting forever - which is the very bug this PR is trying to fix, just for the timeout=0 case. Per that docstring, the async block-forever value should be math.inf, not None.
The sync path happens to be fine, but for a different reason: sync read_response defaults to SENTINEL (= use configured socket_timeout) and timeout=None there flows down to settimeout(None) = blocking mode, so 0 -> None does block forever. That asymmetry (async None = "use socket_timeout", sync None = "block forever") is also why the two parse_response bodies use different pop defaults (None vs SENTINEL), and it's easy to trip over - a short comment on each would help.
Two smaller notes:
- For a positive blocking timeout
T,read_response(timeout=T)uses exactlyT. Since the server holds the reply until ~Tand then sends it, the socket read can expire right as the response arrives (network latency pushes it pastT), giving a spurious timeout at the boundary. A small margin (e.g.T + socket_timeout, or a fixed slack) is the usual guard. - Worth a test for the
timeout=0async case specifically (assert the read is issued with no effective timeout /math.inf), since that's the one most likely to regress.
Happy to be corrected if I'm misreading the async timeout=None semantics, but the docstring reads pretty clearly. Deferring to the maintainers.
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.
|
@eeshsaxena Thanks for the careful read — you are right about the async path.
So mapping Redis Pushed a fix in 8f30074:
On the positive-timeout margin ( Also noting from earlier discussion: |
petyaslavova
left a comment
There was a problem hiding this comment.
Thanks for the contribution, and for cleaning up the branch — the underlying issue (#2807) is real and forwarding the blocking timeout to the read is a reasonable direction.
However, this can't be merged in its current form because of a correctness regression on the async client.
With a live server, I reproduced the following deterministically on both RESP2 and RESP3 (socket_timeout=1, brpop(timeout=3) on an empty key, max_connections=1): brpop returns None at ~3 s, and the next command on that pooled connection returns a corrupted result (echo("AAA") -> None).
The async read_response(timeout=T) returns None on expiry without disconnecting, and the connection is returned to the pool while the server's blocking reply is still in flight, so it desynchronizes the next command.
On master the same scenario raises TimeoutError and the connection stays clean — so the change currently trades a loud, recoverable error for silent corruption. Using timeout=T with no margin also means the sync path still raises TimeoutError at ~T for the full-block/no-element case.
Could you (1) set the read timeout to the server block timeout plus a margin and ensure a genuine client-side expiry invalidates the connection rather than returning None, and (2) add a sync regression test plus a live-server integration test (socket_timeout < timeout, protocol 2 and 3) that fails on base and passes here? The current tests mock read_response, so they don't exercise this. It's also worth deciding whether BZPOPMIN/MAX, BZMPOP, and XREAD/XREADGROUP block= belong here or as follow-ups, since they share the same root cause.
|
Nice fix for the For something like Would it be worth giving the client a small margin over the server's timeout (e.g. |

Problem
Blocking commands like
BRPOP,BLPOP,BRPOPLPUSH,BLMOVE, andBLMPOPfail withTimeoutErrorwhen the command's blocking timeout exceeds the client'ssocket_timeout.Issue: #2807
Root Cause
Blocking commands send the timeout to the Redis server but do not pass it to the connection's
read_response()method. The parser already supports custom timeouts via thetimeoutparameter, but it wasn't being used for blocking commands.For example, with
socket_timeout=4andbrpop('key', timeout=8):BRPOP key 8to serverFix
This fix:
_blocking_timeoutoption to all blocking list commandsparse_response()in sync, async, and cluster clients to extract_blocking_timeoutand pass it toconnection.read_response()Changes
redis/commands/core.py: Added_blocking_timeoutparameter toblpop,brpop,brpoplpush,blmove,blmpopredis/client.py: Modifiedparse_response()to extract and pass_blocking_timeoutredis/asyncio/client.py: Same change for async clientredis/asyncio/cluster.py: Same change for async cluster clientTesting
Verified with unit tests that:
_blocking_timeoutcorrectlyparse_response()correctly extracts and passes timeout toconnection.read_response()Note
Medium Risk
Touches core command execution and socket read timeouts across sync/async/cluster clients; behavior change is targeted to blocking commands but incorrect timeout mapping could affect long-blocking or indefinite waits.
Overview
Fixes premature
TimeoutErroron blocking list commands when the Redis wait time is longer than the clientsocket_timeout.Blocking commands (
BLPOP,BRPOP,BRPOPLPUSH,BLMOVE,BLMPOP) now pass an internal_blocking_timeoutthroughexecute_command, and sync, async, and async clusterparse_response()forward it toconnection.read_response(timeout=…)so the read waits at least as long as the server block. Redistimeout=0(block forever) is mapped toNoneon sync reads andmath.infon async reads to match each stack’s timeout semantics.Adds offline async unit tests for
parse_responsetimeout forwarding and the zero-timeout mapping.Reviewed by Cursor Bugbot for commit 8f30074. Bugbot is set up for automated code reviews on this repo. Configure here.