Skip to content

Commit a44aad3

Browse files
UgaTheDevclaude
andcommitted
fix: treat malformed numeric frames as connection-invalidating
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>
1 parent 227ba2d commit a44aad3

3 files changed

Lines changed: 138 additions & 1 deletion

File tree

redis/_parsers/base.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@
5555
# - RecursionError: deeply nested aggregate replies exhaust the stack in the
5656
# pure-Python parsers. #4144 turns this into InvalidResponse at a bounded
5757
# depth; until then, catching it here is what stops the loop.
58-
UNRECOVERABLE_PARSE_ERRORS = (InvalidResponse, UnicodeDecodeError, RecursionError)
58+
# - ValueError: malformed numeric frames (e.g., `:abc\r\n`) where int(response)
59+
# fails when parsing integer, bulk length, or aggregate length fields.
60+
UNRECOVERABLE_PARSE_ERRORS = (InvalidResponse, UnicodeDecodeError, RecursionError, ValueError)
5961

6062
MODULE_LOAD_ERROR = "Error loading the extension. Please check the server logs."
6163
NO_SUCH_MODULE_ERROR = "Error unloading module: no such module with that name"

tests/test_asyncio/test_connection.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,3 +1205,78 @@ async def test_binary_pubsub_payload_invalidates_connection(parser_class):
12051205
)
12061206

12071207
await conn.disconnect()
1208+
1209+
1210+
@pytest.mark.parametrize(
1211+
"parser_class",
1212+
[_AsyncRESP2Parser],
1213+
ids=["AsyncRESP2Parser"],
1214+
)
1215+
class TestAsyncMalformedNumericFrameInvalidatesConnection:
1216+
"""Async version: malformed numeric frames raise ValueError and must drop
1217+
the connection even with disconnect_on_error=False. The async parser
1218+
re-parses via self._pos = 0, so undecodable bytes stay queued on retry
1219+
unless the connection is invalidated.
1220+
"""
1221+
1222+
@pytest.mark.asyncio
1223+
async def test_malformed_integer_frame_disconnects(self, parser_class):
1224+
"""Malformed integer frame `:abc\r\n` raises ValueError."""
1225+
conn = Connection(protocol=2, parser_class=parser_class)
1226+
_attach_stream(conn, b":abc\r\n+SECOND\r\n")
1227+
1228+
with pytest.raises(ValueError):
1229+
await conn.read_response(disconnect_on_error=False, push_request=True)
1230+
1231+
assert conn.is_connected is False
1232+
await conn.disconnect()
1233+
1234+
@pytest.mark.asyncio
1235+
async def test_malformed_bulk_length_disconnects(self, parser_class):
1236+
"""Malformed bulk string length `$xyz\r\n` raises ValueError."""
1237+
conn = Connection(protocol=2, parser_class=parser_class)
1238+
_attach_stream(conn, b"$xyz\r\n+SECOND\r\n")
1239+
1240+
with pytest.raises(ValueError):
1241+
await conn.read_response(disconnect_on_error=False, push_request=True)
1242+
1243+
assert conn.is_connected is False
1244+
await conn.disconnect()
1245+
1246+
@pytest.mark.asyncio
1247+
async def test_malformed_array_length_disconnects(self, parser_class):
1248+
"""Malformed array length `*abc\r\n` raises ValueError."""
1249+
conn = Connection(protocol=2, parser_class=parser_class)
1250+
_attach_stream(conn, b"*abc\r\n+SECOND\r\n")
1251+
1252+
with pytest.raises(ValueError):
1253+
await conn.read_response(disconnect_on_error=False, push_request=True)
1254+
1255+
assert conn.is_connected is False
1256+
await conn.disconnect()
1257+
1258+
@pytest.mark.asyncio
1259+
async def test_next_read_is_clean_after_malformed_integer(self, parser_class):
1260+
"""Reconnect after malformed integer frame serves new stream cleanly."""
1261+
conn = Connection(protocol=2, parser_class=parser_class)
1262+
_attach_stream(conn, b":abc\r\n")
1263+
1264+
with pytest.raises(ValueError):
1265+
await conn.read_response(disconnect_on_error=False, push_request=True)
1266+
1267+
# Reconnect with fresh stream
1268+
_attach_stream(conn, b"+RECOVERED\r\n")
1269+
result = await conn.read_response(disconnect_on_error=False, push_request=True)
1270+
assert result == b"RECOVERED"
1271+
await conn.disconnect()
1272+
1273+
@pytest.mark.asyncio
1274+
async def test_valid_integer_frame_leaves_connection_up(self, parser_class):
1275+
"""Valid integer frames must not trigger the new predicate."""
1276+
conn = Connection(protocol=2, parser_class=parser_class)
1277+
_attach_stream(conn, b":42\r\n")
1278+
1279+
result = await conn.read_response(disconnect_on_error=False, push_request=True)
1280+
assert result == 42
1281+
assert conn.is_connected is True
1282+
await conn.disconnect()

tests/test_connection.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2275,3 +2275,63 @@ def test_shallow_nesting_still_parses(self):
22752275

22762276
assert conn.read_response(**self.PUBSUB_KWARGS) == [[[[]]]]
22772277
assert conn.is_connected is True
2278+
2279+
@pytest.mark.parametrize(
2280+
"parser_class",
2281+
[_RESP2Parser],
2282+
ids=["RESP2Parser"],
2283+
)
2284+
class TestMalformedNumericFrameInvalidatesConnection:
2285+
"""Malformed numeric frames (non-numeric integers, bulk lengths, or array
2286+
lengths such as `:abc\r\n`, `$xyz\r\n`, `*abc\r\n`) raise ValueError when
2287+
int(response) is called. Before the fix, these stayed queued with
2288+
disconnect_on_error=False, causing infinite retries on the same frame.
2289+
See #4291.
2290+
"""
2291+
2292+
PUBSUB_KWARGS = dict(disconnect_on_error=False, push_request=True)
2293+
2294+
def test_malformed_integer_frame_disconnects(self, parser_class):
2295+
"""Malformed integer frame `:abc\r\n` raises ValueError."""
2296+
conn = _connection_with_stream(b":abc\r\n+SECOND\r\n", parser_class)
2297+
2298+
with pytest.raises(ValueError):
2299+
conn.read_response(**self.PUBSUB_KWARGS)
2300+
2301+
assert conn.is_connected is False
2302+
2303+
def test_malformed_bulk_length_disconnects(self, parser_class):
2304+
"""Malformed bulk string length `$xyz\r\n` raises ValueError."""
2305+
conn = _connection_with_stream(b"$xyz\r\n+SECOND\r\n", parser_class)
2306+
2307+
with pytest.raises(ValueError):
2308+
conn.read_response(**self.PUBSUB_KWARGS)
2309+
2310+
assert conn.is_connected is False
2311+
2312+
def test_malformed_array_length_disconnects(self, parser_class):
2313+
"""Malformed array length `*abc\r\n` raises ValueError."""
2314+
conn = _connection_with_stream(b"*abc\r\n+SECOND\r\n", parser_class)
2315+
2316+
with pytest.raises(ValueError):
2317+
conn.read_response(**self.PUBSUB_KWARGS)
2318+
2319+
assert conn.is_connected is False
2320+
2321+
def test_next_read_is_clean_after_malformed_integer(self, parser_class, monkeypatch):
2322+
"""Reconnect after malformed integer frame serves new stream cleanly."""
2323+
conn = _connection_with_stream(b":abc\r\n+SECOND\r\n", parser_class)
2324+
_reconnect_with(conn, monkeypatch, b"+RECOVERED\r\n")
2325+
2326+
with pytest.raises(ValueError):
2327+
conn.read_response(**self.PUBSUB_KWARGS)
2328+
2329+
conn.connect()
2330+
assert conn.read_response(**self.PUBSUB_KWARGS) == b"RECOVERED"
2331+
2332+
def test_valid_integer_frame_leaves_connection_up(self, parser_class):
2333+
"""Valid integer frames must not trigger the new predicate."""
2334+
conn = _connection_with_stream(b":42\r\n", parser_class)
2335+
2336+
assert conn.read_response(**self.PUBSUB_KWARGS) == 42
2337+
assert conn.is_connected is True

0 commit comments

Comments
 (0)