Skip to content

Commit 2778765

Browse files
committed
Truncate pipeline exception message to a sane size
Fixes #20234.
1 parent 6f752b0 commit 2778765

File tree

8 files changed

+110
-21
lines changed

8 files changed

+110
-21
lines changed

redis/asyncio/client.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -1501,9 +1501,17 @@ def annotate_exception(
15011501
self, exception: Exception, number: int, command: Iterable[object]
15021502
) -> None:
15031503
cmd = " ".join(map(safe_str, command))
1504-
msg = f"Command # {number} ({cmd}) of pipeline caused error: {exception.args}"
1504+
msg = (
1505+
f"Command # {number} ({self._truncate_command(cmd)}) "
1506+
"of pipeline caused error: {exception.args}"
1507+
)
15051508
exception.args = (msg,) + exception.args[1:]
15061509

1510+
def _truncate_command(self, command, max_length=100):
1511+
if len(command) > max_length:
1512+
return command[: max_length - 3] + "..."
1513+
return command
1514+
15071515
async def parse_response(
15081516
self, connection: Connection, command_name: Union[str, bytes], **options
15091517
):

redis/asyncio/cluster.py

+10-6
Original file line numberDiff line numberDiff line change
@@ -1154,9 +1154,7 @@ def get_node(
11541154
return self.nodes_cache.get(node_name)
11551155
else:
11561156
raise DataError(
1157-
"get_node requires one of the following: "
1158-
"1. node name "
1159-
"2. host and port"
1157+
"get_node requires one of the following: 1. node name 2. host and port"
11601158
)
11611159

11621160
def set_nodes(
@@ -1338,7 +1336,7 @@ async def initialize(self) -> None:
13381336
if len(disagreements) > 5:
13391337
raise RedisClusterException(
13401338
f"startup_nodes could not agree on a valid "
1341-
f'slots cache: {", ".join(disagreements)}'
1339+
f"slots cache: {', '.join(disagreements)}"
13421340
)
13431341

13441342
# Validate if all slots are covered or if we should try next startup node
@@ -1593,8 +1591,9 @@ async def _execute(
15931591
if isinstance(result, Exception):
15941592
command = " ".join(map(safe_str, cmd.args))
15951593
msg = (
1596-
f"Command # {cmd.position + 1} ({command}) of pipeline "
1597-
f"caused error: {result.args}"
1594+
f"Command # {cmd.position + 1} "
1595+
f"({self._truncate_command(command)}) "
1596+
f"of pipeline caused error: {result.args}"
15981597
)
15991598
result.args = (msg,) + result.args[1:]
16001599
raise result
@@ -1637,6 +1636,11 @@ def mset_nonatomic(
16371636

16381637
return self
16391638

1639+
def _truncate_command(self, command, max_length=100):
1640+
if len(command) > max_length:
1641+
return command[: max_length - 3] + "..."
1642+
return command
1643+
16401644

16411645
for command in PIPELINE_BLOCKED_COMMANDS:
16421646
command = command.replace(" ", "_").lower()

redis/client.py

100755100644
+6-1
Original file line numberDiff line numberDiff line change
@@ -1514,11 +1514,16 @@ def raise_first_error(self, commands, response):
15141514
def annotate_exception(self, exception, number, command):
15151515
cmd = " ".join(map(safe_str, command))
15161516
msg = (
1517-
f"Command # {number} ({cmd}) of pipeline "
1517+
f"Command # {number} ({self._truncate_command(cmd)}) of pipeline "
15181518
f"caused error: {exception.args[0]}"
15191519
)
15201520
exception.args = (msg,) + exception.args[1:]
15211521

1522+
def _truncate_command(self, command, max_length=100):
1523+
if len(command) > max_length:
1524+
return command[: max_length - 3] + "..."
1525+
return command
1526+
15221527
def parse_response(self, connection, command_name, **options):
15231528
result = Redis.parse_response(self, connection, command_name, **options)
15241529
if command_name in self.UNWATCH_COMMANDS:

redis/cluster.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -1628,7 +1628,7 @@ def initialize(self):
16281628
if len(disagreements) > 5:
16291629
raise RedisClusterException(
16301630
f"startup_nodes could not agree on a valid "
1631-
f'slots cache: {", ".join(disagreements)}'
1631+
f"slots cache: {', '.join(disagreements)}"
16321632
)
16331633

16341634
fully_covered = self.check_slots_coverage(tmp_slots)
@@ -2047,11 +2047,16 @@ def annotate_exception(self, exception, number, command):
20472047
"""
20482048
cmd = " ".join(map(safe_str, command))
20492049
msg = (
2050-
f"Command # {number} ({cmd}) of pipeline "
2050+
f"Command # {number} ({self._truncate_command(cmd)}) of pipeline "
20512051
f"caused error: {exception.args[0]}"
20522052
)
20532053
exception.args = (msg,) + exception.args[1:]
20542054

2055+
def _truncate_command(self, command, max_length=100):
2056+
if len(command) > max_length:
2057+
return command[: max_length - 3] + "x.."
2058+
return command
2059+
20552060
def execute(self, raise_on_error: bool = True) -> List[Any]:
20562061
"""
20572062
Execute all the commands in the current pipeline

tests/test_asyncio/test_cluster.py

+24-6
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,6 @@ async def get_mocked_redis_client(
146146
with mock.patch.object(ClusterNode, "execute_command") as execute_command_mock:
147147

148148
async def execute_command(*_args, **_kwargs):
149-
150149
if _args[0] == "CLUSTER SLOTS":
151150
if cluster_slots_raise_error:
152151
raise ResponseError()
@@ -1577,23 +1576,23 @@ async def test_cluster_bitop_not_empty_string(self, r: RedisCluster) -> None:
15771576

15781577
@skip_if_server_version_lt("2.6.0")
15791578
async def test_cluster_bitop_not(self, r: RedisCluster) -> None:
1580-
test_str = b"\xAA\x00\xFF\x55"
1579+
test_str = b"\xaa\x00\xff\x55"
15811580
correct = ~0xAA00FF55 & 0xFFFFFFFF
15821581
await r.set("{foo}a", test_str)
15831582
await r.bitop("not", "{foo}r", "{foo}a")
15841583
assert int(binascii.hexlify(await r.get("{foo}r")), 16) == correct
15851584

15861585
@skip_if_server_version_lt("2.6.0")
15871586
async def test_cluster_bitop_not_in_place(self, r: RedisCluster) -> None:
1588-
test_str = b"\xAA\x00\xFF\x55"
1587+
test_str = b"\xaa\x00\xff\x55"
15891588
correct = ~0xAA00FF55 & 0xFFFFFFFF
15901589
await r.set("{foo}a", test_str)
15911590
await r.bitop("not", "{foo}a", "{foo}a")
15921591
assert int(binascii.hexlify(await r.get("{foo}a")), 16) == correct
15931592

15941593
@skip_if_server_version_lt("2.6.0")
15951594
async def test_cluster_bitop_single_string(self, r: RedisCluster) -> None:
1596-
test_str = b"\x01\x02\xFF"
1595+
test_str = b"\x01\x02\xff"
15971596
await r.set("{foo}a", test_str)
15981597
await r.bitop("and", "{foo}res1", "{foo}a")
15991598
await r.bitop("or", "{foo}res2", "{foo}a")
@@ -1604,8 +1603,8 @@ async def test_cluster_bitop_single_string(self, r: RedisCluster) -> None:
16041603

16051604
@skip_if_server_version_lt("2.6.0")
16061605
async def test_cluster_bitop_string_operands(self, r: RedisCluster) -> None:
1607-
await r.set("{foo}a", b"\x01\x02\xFF\xFF")
1608-
await r.set("{foo}b", b"\x01\x02\xFF")
1606+
await r.set("{foo}a", b"\x01\x02\xff\xff")
1607+
await r.set("{foo}b", b"\x01\x02\xff")
16091608
await r.bitop("and", "{foo}res1", "{foo}a", "{foo}b")
16101609
await r.bitop("or", "{foo}res2", "{foo}a", "{foo}b")
16111610
await r.bitop("xor", "{foo}res3", "{foo}a", "{foo}b")
@@ -2803,6 +2802,25 @@ async def test_asking_error(self, r: RedisCluster) -> None:
28032802
assert ask_node._free.pop().read_response.await_count
28042803
assert res == ["MOCK_OK"]
28052804

2805+
async def test_error_is_truncated(self, r) -> None:
2806+
"""
2807+
Test that an error from the pipeline is truncated correctly.
2808+
"""
2809+
key = "a" * 5000
2810+
2811+
async with r.pipeline() as pipe:
2812+
pipe.set(key, 1)
2813+
pipe.llen(key)
2814+
pipe.expire(key, 100)
2815+
2816+
with pytest.raises(Exception) as ex:
2817+
await pipe.execute()
2818+
2819+
expected = (
2820+
"Command # 2 (LLEN " + ("a" * 92) + "...) of pipeline caused error: "
2821+
)
2822+
assert str(ex.value).startswith(expected)
2823+
28062824
async def test_moved_redirection_on_slave_with_default(
28072825
self, r: RedisCluster
28082826
) -> None:

tests/test_asyncio/test_pipeline.py

+15
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,21 @@ async def test_exec_error_in_no_transaction_pipeline_unicode_command(self, r):
368368

369369
assert await r.get(key) == b"1"
370370

371+
async def test_exec_error_in_pipeline_truncated(self, r):
372+
key = "a" * 5000
373+
await r.set(key, 1)
374+
async with r.pipeline(transaction=False) as pipe:
375+
pipe.llen(key)
376+
pipe.expire(key, 100)
377+
378+
with pytest.raises(redis.ResponseError) as ex:
379+
await pipe.execute()
380+
381+
expected = (
382+
"Command # 1 (LLEN " + ("a" * 92) + "...) of pipeline caused error: "
383+
)
384+
assert str(ex.value).startswith(expected)
385+
371386
async def test_pipeline_with_bitfield(self, r):
372387
async with r.pipeline() as pipe:
373388
pipe.set("a", "1")

tests/test_cluster.py

+24-5
Original file line numberDiff line numberDiff line change
@@ -1692,23 +1692,23 @@ def test_cluster_bitop_not_empty_string(self, r):
16921692

16931693
@skip_if_server_version_lt("2.6.0")
16941694
def test_cluster_bitop_not(self, r):
1695-
test_str = b"\xAA\x00\xFF\x55"
1695+
test_str = b"\xaa\x00\xff\x55"
16961696
correct = ~0xAA00FF55 & 0xFFFFFFFF
16971697
r["{foo}a"] = test_str
16981698
r.bitop("not", "{foo}r", "{foo}a")
16991699
assert int(binascii.hexlify(r["{foo}r"]), 16) == correct
17001700

17011701
@skip_if_server_version_lt("2.6.0")
17021702
def test_cluster_bitop_not_in_place(self, r):
1703-
test_str = b"\xAA\x00\xFF\x55"
1703+
test_str = b"\xaa\x00\xff\x55"
17041704
correct = ~0xAA00FF55 & 0xFFFFFFFF
17051705
r["{foo}a"] = test_str
17061706
r.bitop("not", "{foo}a", "{foo}a")
17071707
assert int(binascii.hexlify(r["{foo}a"]), 16) == correct
17081708

17091709
@skip_if_server_version_lt("2.6.0")
17101710
def test_cluster_bitop_single_string(self, r):
1711-
test_str = b"\x01\x02\xFF"
1711+
test_str = b"\x01\x02\xff"
17121712
r["{foo}a"] = test_str
17131713
r.bitop("and", "{foo}res1", "{foo}a")
17141714
r.bitop("or", "{foo}res2", "{foo}a")
@@ -1719,8 +1719,8 @@ def test_cluster_bitop_single_string(self, r):
17191719

17201720
@skip_if_server_version_lt("2.6.0")
17211721
def test_cluster_bitop_string_operands(self, r):
1722-
r["{foo}a"] = b"\x01\x02\xFF\xFF"
1723-
r["{foo}b"] = b"\x01\x02\xFF"
1722+
r["{foo}a"] = b"\x01\x02\xff\xff"
1723+
r["{foo}b"] = b"\x01\x02\xff"
17241724
r.bitop("and", "{foo}res1", "{foo}a", "{foo}b")
17251725
r.bitop("or", "{foo}res2", "{foo}a", "{foo}b")
17261726
r.bitop("xor", "{foo}res3", "{foo}a", "{foo}b")
@@ -3260,6 +3260,25 @@ def raise_ask_error():
32603260
assert ask_node.redis_connection.connection.read_response.called
32613261
assert res == ["MOCK_OK"]
32623262

3263+
def test_error_is_truncated(self, r):
3264+
"""
3265+
Test that an error from the pipeline is truncated correctly.
3266+
"""
3267+
key = "a" * 5000
3268+
3269+
with r.pipeline() as pipe:
3270+
pipe.set(key, 1)
3271+
pipe.llen(key)
3272+
pipe.expire(key, 100)
3273+
3274+
with pytest.raises(Exception) as ex:
3275+
pipe.execute()
3276+
3277+
expected = (
3278+
"Command # 2 (LLEN " + ("a" * 92) + "...) of pipeline caused error: "
3279+
)
3280+
assert str(ex.value).startswith(expected)
3281+
32633282
def test_return_previously_acquired_connections(self, r):
32643283
# in order to ensure that a pipeline will make use of connections
32653284
# from different nodes

tests/test_pipeline.py

+15
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,21 @@ def test_exec_error_in_no_transaction_pipeline_unicode_command(self, r):
369369

370370
assert r[key] == b"1"
371371

372+
def test_exec_error_in_pipeline_truncated(self, r):
373+
key = "a" * 5000
374+
r[key] = 1
375+
with r.pipeline(transaction=False) as pipe:
376+
pipe.llen(key)
377+
pipe.expire(key, 100)
378+
379+
with pytest.raises(redis.ResponseError) as ex:
380+
pipe.execute()
381+
382+
expected = (
383+
"Command # 1 (LLEN " + ("a" * 92) + "...) of pipeline caused error: "
384+
)
385+
assert str(ex.value).startswith(expected)
386+
372387
def test_pipeline_with_bitfield(self, r):
373388
with r.pipeline() as pipe:
374389
pipe.set("a", "1")

0 commit comments

Comments
 (0)