Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e155765f47
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…w connection Addresses two review findings on redis#4293. The pending-invalidation drain reads replies directly off the wrapped raw connection, so the disconnect() that InvalidResponse now forces runs on the raw connection and skips CacheProxyConnection.disconnect() -- and with it the cache flush that disconnect performs. The following connect() opens a fresh CLIENT TRACKING session the server holds no invalidation state for, while the local cache keeps serving entries recorded under the previous session. Both drain sites now share one helper that flushes the cache before re-raising. The new sync tests also drove reads through a fake socket that only implemented recv(), so under the hiredis CI jobs _HiredisParser's recv_into() raised AttributeError before the framing error was ever reached. The fake now implements recv_into() as well, which keeps the tests exercising whichever parser is installed rather than pinning one: _HiredisParser builds its reader with protocolError=InvalidResponse, so the fix applies to both parsers, and pinning parser_class would have left that unverified. The dropped _parser._buffer assertion was a pure-Python parser internal already implied by is_connected being False. Signed-off-by: Kush Zingade <kush.zingade@gmail.com>
|
Both good catches — fixed in the follow-up commit. Details below, including one place where the suggested fix would have hidden something real. 1.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 98f2e2cf86
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| # queued, so honouring disconnect_on_error=False here would make | ||
| # the next read fail identically, forever. Drop the connection | ||
| # regardless of what the caller asked for. See #4291. | ||
| self.disconnect() |
There was a problem hiding this comment.
Flush proxy caches on every new forced disconnect
When client-side caching is enabled and a pooled connection that previously populated an entry is later used by PubSub, PubSub reads through CacheProxyConnection.read_response with disconnect_on_error=False, but that method delegates directly to the raw _conn. This new call therefore disconnects only the raw connection and bypasses CacheProxyConnection.disconnect(), leaving entries from the old CLIENT TRACKING session in the shared cache; after reconnecting, a missed invalidation can make those entries be served stale. Although the drain-loop fix addresses the earlier review comment, fresh evidence remains in the direct delegation at CacheProxyConnection.read_response, which is outside _drain_invalidations; this forced-disconnect path must notify the proxy or otherwise flush its cache too.
Useful? React with 👍 / 👎.
petyaslavova
left a comment
There was a problem hiding this comment.
Hey @UgaTheDev, thank you for your contribution!
The defect is real and Connection.read_response is the right place to fix it - it also covers SentinelManagedConnection.read_response, which defaults to disconnect_on_error=False and is a fourth opt-out site you did not list. Before merging, though, the predicate needs to be widened, because InvalidResponse is not what the reachable cases raise:
- The reproduction in #4291 raises
RecursionErroron current master, so this patch does not fix it unless #4144 lands first. That case is real, not synthetic:EVAL "local t={} local c=t for i=1,3000 do local n={} c[1]=n c=n end return t" 0makes Redis emit exactly your fake stream (12004 bytes), and through a sentinel-managed connection it is on the ordinary command path. - The realistic pubsub trigger is
Encoder.decodeat the tail of_read_response, which runs after the cursor has passed the payload. Adecode_responses=Truesubscriber receiving a binaryPUBLISHpayload raisesUnicodeDecodeErrorand loops exactly as described, on both parser backends. Please add that as a regression test.
Keep the allowlist shape rather than inverting to "disconnect unless resumable" - test_connection_parse_response_resume drives resume with a bare BaseException under disconnect_on_error=False and would break.
One correction to the rationale, since it is now in the code comments: the rewind and disconnect_on_error come from #2510 ("Make PythonParser resumable") and #2695, so they exist to re-parse an interrupted read, not to survive in-band errors. An in-band ResponseError is returned as a value and purged, never rewound.
Also needed: an async test that exercises the pubsub read path and asserts the next read is clean (the async parser re-parses via self._pos = 0, not the sync rewind), and a rebase for the end-of-file conflicts in the two test files.
98f2e2c to
227ba2d
Compare
…w connection Addresses two review findings on redis#4293. The pending-invalidation drain reads replies directly off the wrapped raw connection, so the disconnect() that InvalidResponse now forces runs on the raw connection and skips CacheProxyConnection.disconnect() -- and with it the cache flush that disconnect performs. The following connect() opens a fresh CLIENT TRACKING session the server holds no invalidation state for, while the local cache keeps serving entries recorded under the previous session. Both drain sites now share one helper that flushes the cache before re-raising. The new sync tests also drove reads through a fake socket that only implemented recv(), so under the hiredis CI jobs _HiredisParser's recv_into() raised AttributeError before the framing error was ever reached. The fake now implements recv_into() as well, which keeps the tests exercising whichever parser is installed rather than pinning one: _HiredisParser builds its reader with protocolError=InvalidResponse, so the fix applies to both parsers, and pinning parser_class would have left that unverified. The dropped _parser._buffer assertion was a pure-Python parser internal already implied by is_connected being False. Signed-off-by: Kush Zingade <kush.zingade@gmail.com>
|
Thanks for the detailed review, and in particular for the You were also right about the rationale. I had written the comments as though the rewind and What the reachable cases actually raiseBefore widening anything I measured the two cases you named against this branch, on both parser backends, through
So your read is confirmed on both counts. The widened predicateKept as an allowlist, defined once in UNRECOVERABLE_PARSE_ERRORS = (InvalidResponse, UnicodeDecodeError, RecursionError)The shared name is because the sync and async handlers have to agree, and the third consumer is the I did not invert to "disconnect unless resumable", for the reason you gave: RecursionError and #4144: what this covers and what it does notBeing precise, because these are two different problems:
So the accurate statement about #4291 is: the reported symptom (a One consequence worth flagging: a user-supplied push-notification callback that recurses deeply enough to raise TestsNew regression coverage:
Fail-beforeWith the tuple temporarily narrowed back to The four that pass pre-widening are the negative controls and the hiredis nesting case, which as noted was already covered. Restoring the widened tuple: Full runsAll five resume variants: Two suites have pre-existing failures in this environment, so I diffed the failing test names against the same run on
So no suite regressed; nothing that passes on master fails here. Run with hiredis 3.4.1 installed, so the hiredis-parametrized cases were genuinely exercised rather than skipped. RebaseRebased onto |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 227ba2d3e7
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit 227ba2d. Configure here.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a44aad3cef
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a62dc413ab
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| # they land here too. Plain ValueError is deliberately NOT in this tuple: a | ||
| # user push handler may raise it after the frame was fully consumed, and that | ||
| # must not tear down a healthy connection. | ||
| UNRECOVERABLE_PARSE_ERRORS = (InvalidResponse, UnicodeDecodeError, RecursionError) |
There was a problem hiding this comment.
Separate push-handler exceptions from parser failures
With RESP3 PubSub using hiredis, the public push_handler_func runs after hiredis has consumed the complete push frame, so if that callback raises UnicodeDecodeError, RecursionError, or InvalidResponse, this tuple now causes Connection.read_response(disconnect_on_error=False) to disconnect an otherwise healthy subscription instead of leaving the next queued frame readable. The fresh evidence after excluding ValueError is that these other callback-raisable types remain classified solely by exception class; parser-originated failures need to be distinguished from exceptions escaping a user handler.
Useful? React with 👍 / 👎.
petyaslavova
left a comment
There was a problem hiding this comment.
Thanks for the follow-ups. The widened predicate is what I was after, and the measured per-backend table settles the reachability question. The async mirror and the cache flush on the invalidation drain both look right to me.
Two things before merge: please rebase onto master, and get the integration matrix green - the head now routes every RESP numeric field through _parse_int, so I want a full protocol x legacy_responses run rather than just the bot check. Also, the malformed-numeric test classes parametrize over a single parser; please extend them to the hiredis parsers, which report these frames as InvalidResponse, so backend parity is covered.
On the two open bot threads: the cache-flush one on CacheProxyConnection.read_response is a real gap, but it predates this PR - a raw disconnect already skips the proxy flush on every disconnect_on_error=True error path - so we will track it separately rather than grow this PR. The push-handler one is also real for hiredis, where the frame is consumed before the handler runs, though on the pure-Python parser your change improves that case rather than regressing it. If you would like to close it properly, converting the parsers' own decode failures to InvalidResponse at the decode sites and dropping UnicodeDecodeError from the tuple would do it; I am fine merging without that.
The _parse_int/_parse_float conversion is beyond what I asked for, but I am happy to keep it: without it a desynced stream yielding a non-numeric length still loops, and plain ValueError cannot go in the tuple because a push handler may raise it.
…nnect_on_error=False A framing violation means the parser can no longer locate reply boundaries, so the stream position is untrustworthy. The parsers rewind the buffer on any exception, leaving every byte of the offending reply queued, so the three call sites that pass disconnect_on_error=False (PubSub.parse_response, the send_command pending-push drain, _process_pending_invalidations) re-parse the same bytes and raise the same error on every subsequent read, indefinitely. Catch InvalidResponse ahead of the general handler and disconnect regardless of disconnect_on_error, in both the sync and async connections. In-band ResponseError is unaffected: it is a sibling of InvalidResponse under RedisError, it belongs to a real reply, and the rewind exists to serve it. This turns the failure from permanent into terminal-but-recoverable: one clean error, connection dropped, subscriber reconnects and resubscribes. Fixes redis#4291 Signed-off-by: Kush Zingade <kush.zingade@gmail.com>
…w connection Addresses two review findings on redis#4293. The pending-invalidation drain reads replies directly off the wrapped raw connection, so the disconnect() that InvalidResponse now forces runs on the raw connection and skips CacheProxyConnection.disconnect() -- and with it the cache flush that disconnect performs. The following connect() opens a fresh CLIENT TRACKING session the server holds no invalidation state for, while the local cache keeps serving entries recorded under the previous session. Both drain sites now share one helper that flushes the cache before re-raising. The new sync tests also drove reads through a fake socket that only implemented recv(), so under the hiredis CI jobs _HiredisParser's recv_into() raised AttributeError before the framing error was ever reached. The fake now implements recv_into() as well, which keeps the tests exercising whichever parser is installed rather than pinning one: _HiredisParser builds its reader with protocolError=InvalidResponse, so the fix applies to both parsers, and pinning parser_class would have left that unverified. The dropped _parser._buffer assertion was a pure-Python parser internal already implied by is_connected being False. Signed-off-by: Kush Zingade <kush.zingade@gmail.com>
… parse errors
InvalidResponse was the wrong predicate. Measured on both parser backends,
the two reachable cases raise something else:
- A decode_responses=True subscriber handed a binary PUBLISH payload
raises UnicodeDecodeError from Encoder.decode at the tail of
_read_response, after the cursor has already passed the payload. Neither
backend disconnected, so the undecodable bytes stayed queued and every
later read failed identically.
- A deeply nested reply (EVAL emitting ~12004 bytes of nesting) raises
RecursionError in the pure-Python parsers. hiredis reports its own depth
limit as InvalidResponse and so was already covered.
Collect the three into UNRECOVERABLE_PARSE_ERRORS in redis/_parsers/base.py
and apply it at the sync and async read_response sites plus the
CacheProxyConnection invalidation drain, which reads off the raw connection
and must flush on the same set. The predicate stays an allowlist: inverting
to "disconnect unless resumable" would break
test_connection_parse_response_resume, which drives resume with a bare
BaseException under disconnect_on_error=False.
This closes the redis#4291 loop on both backends. The unbounded recursion itself
still needs redis#4144, which bounds the depth so RecursionError never fires and
turns the case into an InvalidResponse the first entry already covers.
Also correct the comments: the rewind and disconnect_on_error come from
in-band errors. An in-band ResponseError is returned as a value and purged,
never rewound.
Signed-off-by: Kush Zingade <kush.zingade@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qnxf2u2kSBxRM1AT7kD8BD Signed-off-by: Kush Zingade <kush.zingade@gmail.com>
…tching ValueError a44aad3 added ValueError to UNRECOVERABLE_PARSE_ERRORS so that `:abc\r\n`, `$xyz\r\n` and `*abc\r\n` invalidate the connection. That was too broad: a ValueError raised by a user push handler (after hiredis has already consumed the complete push frame) would also force a disconnect of a healthy subscription, even with disconnect_on_error=False. Convert the pure-Python parsers' int()/float() failures into InvalidResponse at the source (_parse_int/_parse_float) and drop ValueError from the tuple. Malformed frames still invalidate the connection via InvalidResponse; handler errors propagate without touching the connection. Adds a hiredis regression test for the push-handler case, which fails with ValueError in the tuple and passes without it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qnxf2u2kSBxRM1AT7kD8BD Signed-off-by: Kush Zingade <kush.zingade@gmail.com>
Both malformed-numeric classes parametrized over a single pure-Python parser, so backend parity was untested. hiredis reports these frames as InvalidResponse too, which is the same exception the tests already assert, so the classes extend without changing any test body. TestMalformedNumericFrameInvalidatesConnection now runs against _RESP2Parser, _RESP3Parser and _HiredisParser; the async class against _AsyncRESP2Parser, _AsyncRESP3Parser and _AsyncHiredisParser. The hiredis params are skipped when hiredis is not installed, matching the guard already used by TestDeeplyNestedReplyInvalidatesConnection. 30 tests pass locally with hiredis 3.4.1 installed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qnxf2u2kSBxRM1AT7kD8BD Signed-off-by: Kush Zingade <kush.zingade@gmail.com>
a62dc41 to
739fe97
Compare
|
Thanks @petyaslavova. All three items are in, head is now Rebased onto master. The conflicts were in except UNRECOVERABLE_PARSE_ERRORS as e:
# ... (unchanged rationale)
add_debug_log_for_connection_failure(self, e, "reading response")
self.disconnect()
raiseThat keeps the new path consistent with the neighbouring handlers rather than being the one that logs nothing. Branch is 0 commits behind master. Hiredis parity on the malformed-numeric classes. You were right that they parametrized over a single parser. Both classes now cover all three backends, and since hiredis reports these frames as
The hiredis params carry the same Integration matrix. Over to CI for the full protocol x legacy_responses run, since I do not have the sentinel/cluster/SSL setup locally. For what it is worth, On the two bot threads, agreed on both counts, and I am content to leave the |

Fixes #4291
Problem
Connection.read_responseprotects callers from a malformed reply by disconnecting, which drops the parser buffer so the poisoned stream is discarded and the pool reconnects. Three callers opt out withdisconnect_on_error=False:PubSub.parse_response(redis/client.py:1420)Connection.send_command(redis/connection.py:1811)_process_pending_invalidations(redis/connection.py:2070)On those paths a protocol-level parse failure is unrecoverable. The parsers rewind the buffer on any exception (
resp2.py:13-26, same inresp3.py), so the offending reply is not consumed — the read position returns to where it started and every byte stays queued. Combined with no disconnect, the next read re-parses the same bytes and raises the same error, indefinitely. A subscriber spins on the error with no way to make progress, and any reply queued behind the bad one is permanently unreachable.Fix
Catch
InvalidResponseahead of the generalexcept BaseExceptionhandler and disconnect regardless ofdisconnect_on_error, in bothredis/connection.pyandredis/asyncio/connection.py.The reasoning is a semantic distinction between two error classes that happen to share a code path:
ResponseErrorlegitimately belongs to a reply. The stream stays framed, the error may need re-reading, anddisconnect_on_error=Falseplus the rewind exist to serve exactly this — pubsub surviving an in-band error without dropping its subscriptions. Unchanged by this PR.InvalidResponseis a framing violation: the parser can no longer locate reply boundaries, so the stream position is untrustworthy. Preserving it only guarantees the next read fails identically. Handling this in the parser (consuming and discarding the bad reply instead of rewinding) is not viable — once framing is lost there is no reliable way to find where the next reply begins.InvalidResponseandResponseErrorare siblings underRedisErrorwith no subclasses, so the new handler cannot catch an in-band error.The failure changes from permanent to terminal-but-recoverable: one clean error, connection dropped, subscriber reconnects and resubscribes via the
on_connectcallback.Behavior change
InvalidResponsestill propagates to the caller on all three paths — only the connection state changes. Per-path effect:get_message/listenreconnects (conn.connect()on the blocking path,conn.can_read()on the non-blocking one, both of which reconnect a closed connection) and resubscribes.InvalidResponseis not in the retry-supported errors, so it is not silently retried.while can_read()drain loops (send_commandpending-push drain,_process_pending_invalidations): these previously left the connection open with unread bytes on a framing error. They now lose the connection. Both already let a non-TimeoutErrorpropagate, so the loops exited via the exception either way; the difference is that the connection is no longer handed back in an unusable state.can_read()reconnects a closed connection, so neither loop can spin.Callers that relied on keeping a connection alive across a framing violation will see it dropped — that is the intent, since such a connection could not produce a correct reply again.
Tests
tests/test_connection.py::TestInvalidResponseInvalidatesConnection— fake-socket repro from the issue, no server needed:disconnect_on_error=False, push_request=True) disconnects and releases the buffer — fails on masterResponseErroron the same path still does not disconnect, and the next reply is readable — guards the rewind's purposetests/test_asyncio/test_connection.py::test_invalid_response_always_disconnects— parametrized overdisconnect_on_error, assertsdisconnect(nowait=True)in both cases; theFalsecase fails on master.Full runs of
tests/test_connection.py,tests/test_asyncio/test_connection.py,tests/test_pubsub.py,tests/test_asyncio/test_pubsub.py: 477 passed, with the same 8 pre-existing cluster-dependent failures as on master (471 passed baseline; the delta is the 6 new tests).Relationship to #4144
Independent. #4144 makes deep nesting raise
InvalidResponseinstead ofRecursionError; the loop described here is identical before and after it, because the cause is the missing invalidation rather than the exception type. This PR does make #4144's error recoverable on the pubsub path.Note
Medium Risk
Changes connection teardown on PubSub and CLIENT TRACKING cache paths; behavior is intentional but alters recovery when the stream is corrupted mid-reply.
Overview
Fixes #4291 by treating certain mid-reply parse failures as connection-invalidating even when callers pass
disconnect_on_error=False(PubSub, push drains, CLIENT TRACKING invalidation drains).Introduces
UNRECOVERABLE_PARSE_ERRORS(InvalidResponse,UnicodeDecodeError,RecursionError) and handles it in sync/asyncConnection.read_responseby always disconnecting and re-raising. PlainValueErrorfrom push handlers is explicitly excluded.RESP2/RESP3 parsers now use
_parse_int/_parse_floatso malformed numeric frames (:abc,$xyz,*abc) raiseInvalidResponseinstead ofValueError, aligning them with the same disconnect path.CacheProxyConnectionrefactors invalidation draining into_drain_invalidationsand flushes the local cache when an unrecoverable parse error occurs during drain (because the raw disconnect bypasses the proxy’s normaldisconnect()flush).Tests cover framing errors, binary pub/sub with
decode_responses=True, deep nesting, malformed numerics, in-bandResponseErrorstill rewinding, and push-handlerValueErrornot disconnecting.Reviewed by Cursor Bugbot for commit 739fe97. Bugbot is set up for automated code reviews on this repo. Configure here.