diff --git a/redis/asyncio/client.py b/redis/asyncio/client.py index 0039cea540..a35a5f1f8c 100644 --- a/redis/asyncio/client.py +++ b/redis/asyncio/client.py @@ -77,6 +77,7 @@ get_lib_version, safe_str, str_if_bytes, + truncate_text, ) if TYPE_CHECKING and SSL_AVAILABLE: @@ -1513,7 +1514,10 @@ def annotate_exception( self, exception: Exception, number: int, command: Iterable[object] ) -> None: cmd = " ".join(map(safe_str, command)) - msg = f"Command # {number} ({cmd}) of pipeline caused error: {exception.args}" + msg = ( + f"Command # {number} ({truncate_text(cmd)}) " + "of pipeline caused error: {exception.args}" + ) exception.args = (msg,) + exception.args[1:] async def parse_response( diff --git a/redis/asyncio/cluster.py b/redis/asyncio/cluster.py index e2a4fbe2cc..11f86cceb4 100644 --- a/redis/asyncio/cluster.py +++ b/redis/asyncio/cluster.py @@ -71,6 +71,7 @@ get_lib_version, safe_str, str_if_bytes, + truncate_text, ) if SSL_AVAILABLE: @@ -1633,8 +1634,9 @@ async def _execute( if isinstance(result, Exception): command = " ".join(map(safe_str, cmd.args)) msg = ( - f"Command # {cmd.position + 1} ({command}) of pipeline " - f"caused error: {result.args}" + f"Command # {cmd.position + 1} " + f"({truncate_text(command)}) " + f"of pipeline caused error: {result.args}" ) result.args = (msg,) + result.args[1:] raise result diff --git a/redis/client.py b/redis/client.py index 2c4a1fadff..9fb89ec5cd 100755 --- a/redis/client.py +++ b/redis/client.py @@ -61,6 +61,7 @@ get_lib_version, safe_str, str_if_bytes, + truncate_text, ) if TYPE_CHECKING: @@ -1524,7 +1525,8 @@ def raise_first_error(self, commands, response): def annotate_exception(self, exception, number, command): cmd = " ".join(map(safe_str, command)) msg = ( - f"Command # {number} ({cmd}) of pipeline caused error: {exception.args[0]}" + f"Command # {number} ({truncate_text(cmd)}) of pipeline " + f"caused error: {exception.args[0]}" ) exception.args = (msg,) + exception.args[1:] diff --git a/redis/cluster.py b/redis/cluster.py index 0488608a60..4ec03ac98f 100644 --- a/redis/cluster.py +++ b/redis/cluster.py @@ -47,6 +47,7 @@ merge_result, safe_str, str_if_bytes, + truncate_text, ) @@ -2125,7 +2126,8 @@ def annotate_exception(self, exception, number, command): """ cmd = " ".join(map(safe_str, command)) msg = ( - f"Command # {number} ({cmd}) of pipeline caused error: {exception.args[0]}" + f"Command # {number} ({truncate_text(cmd)}) of pipeline " + f"caused error: {exception.args[0]}" ) exception.args = (msg,) + exception.args[1:] diff --git a/redis/utils.py b/redis/utils.py index 9d9b4a9580..1f0b24d768 100644 --- a/redis/utils.py +++ b/redis/utils.py @@ -1,5 +1,6 @@ import datetime import logging +import textwrap from contextlib import contextmanager from functools import wraps from typing import Any, Dict, List, Mapping, Optional, Union @@ -298,3 +299,9 @@ def extract_expire_flags( exp_options.extend(["PXAT", pxat]) return exp_options + + +def truncate_text(txt, max_length=100): + return textwrap.shorten( + text=txt, width=max_length, placeholder="...", break_long_words=True + ) diff --git a/tests/test_asyncio/test_cluster.py b/tests/test_asyncio/test_cluster.py index f57718b44f..a0429152ec 100644 --- a/tests/test_asyncio/test_cluster.py +++ b/tests/test_asyncio/test_cluster.py @@ -2905,6 +2905,25 @@ async def test_asking_error(self, r: RedisCluster) -> None: assert ask_node._free.pop().read_response.await_count assert res == ["MOCK_OK"] + async def test_error_is_truncated(self, r) -> None: + """ + Test that an error from the pipeline is truncated correctly. + """ + key = "a" * 50 + a_value = "a" * 20 + b_value = "b" * 20 + + async with r.pipeline() as pipe: + pipe.set(key, 1) + pipe.hset(key, mapping={"field_a": a_value, "field_b": b_value}) + pipe.expire(key, 100) + + with pytest.raises(Exception) as ex: + await pipe.execute() + + expected = f"Command # 2 (HSET {key} field_a {a_value} field_b...) of pipeline caused error: " + assert str(ex.value).startswith(expected) + async def test_moved_redirection_on_slave_with_default( self, r: RedisCluster ) -> None: diff --git a/tests/test_asyncio/test_pipeline.py b/tests/test_asyncio/test_pipeline.py index 31759d84a3..19e11dc792 100644 --- a/tests/test_asyncio/test_pipeline.py +++ b/tests/test_asyncio/test_pipeline.py @@ -368,6 +368,22 @@ async def test_exec_error_in_no_transaction_pipeline_unicode_command(self, r): assert await r.get(key) == b"1" + async def test_exec_error_in_pipeline_truncated(self, r): + key = "a" * 50 + a_value = "a" * 20 + b_value = "b" * 20 + + await r.set(key, 1) + async with r.pipeline(transaction=False) as pipe: + pipe.hset(key, mapping={"field_a": a_value, "field_b": b_value}) + pipe.expire(key, 100) + + with pytest.raises(redis.ResponseError) as ex: + await pipe.execute() + + expected = f"Command # 1 (HSET {key} field_a {a_value} field_b...) of pipeline caused error: " + assert str(ex.value).startswith(expected) + async def test_pipeline_with_bitfield(self, r): async with r.pipeline() as pipe: pipe.set("a", "1") diff --git a/tests/test_cluster.py b/tests/test_cluster.py index b71908d396..d96342f87a 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -3315,6 +3315,25 @@ def raise_ask_error(): assert ask_node.redis_connection.connection.read_response.called assert res == ["MOCK_OK"] + def test_error_is_truncated(self, r): + """ + Test that an error from the pipeline is truncated correctly. + """ + key = "a" * 50 + a_value = "a" * 20 + b_value = "b" * 20 + + with r.pipeline() as pipe: + pipe.set(key, 1) + pipe.hset(key, mapping={"field_a": a_value, "field_b": b_value}) + pipe.expire(key, 100) + + with pytest.raises(Exception) as ex: + pipe.execute() + + expected = f"Command # 2 (HSET {key} field_a {a_value} field_b...) of pipeline caused error: " + assert str(ex.value).startswith(expected) + def test_return_previously_acquired_connections(self, r): # in order to ensure that a pipeline will make use of connections # from different nodes diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index be7784ad0b..bbf1ec9eb5 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -369,6 +369,22 @@ def test_exec_error_in_no_transaction_pipeline_unicode_command(self, r): assert r[key] == b"1" + def test_exec_error_in_pipeline_truncated(self, r): + key = "a" * 50 + a_value = "a" * 20 + b_value = "b" * 20 + + r[key] = 1 + with r.pipeline(transaction=False) as pipe: + pipe.hset(key, mapping={"field_a": a_value, "field_b": b_value}) + pipe.expire(key, 100) + + with pytest.raises(redis.ResponseError) as ex: + pipe.execute() + + expected = f"Command # 1 (HSET {key} field_a {a_value} field_b...) of pipeline caused error: " + assert str(ex.value).startswith(expected) + def test_pipeline_with_bitfield(self, r): with r.pipeline() as pipe: pipe.set("a", "1")