Skip to content

Commit 2a78981

Browse files
authored
fix: remove corrupt timers on decode so the parser has no side effects (#113)
1 parent e06a1cd commit 2a78981

3 files changed

Lines changed: 60 additions & 11 deletions

File tree

‎faststream_redis_timers/message.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,26 @@ class TimerStreamMessage(StreamMessage["TimerMessage"]):
2121
(with its score pushed forward by `lease_ttl`) while the handler runs.
2222
`ack()` and `reject()` atomically remove it; `nack()` is a no-op so the
2323
lease expires and another worker re-claims the timer.
24+
25+
A timer whose payload cannot be parsed is removed and raises on `decode()`,
26+
so the handler never runs.
2427
"""
2528

2629
def __init__(
2730
self,
2831
*args: typing.Any,
2932
_remove: "Callable[[], Awaitable[None]] | None" = None,
33+
_discard_corrupt: "Callable[[], Awaitable[typing.NoReturn]] | None" = None,
3034
**kwargs: typing.Any,
3135
) -> None:
3236
super().__init__(*args, **kwargs)
3337
self._remove = _remove
38+
self._discard_corrupt = _discard_corrupt
39+
40+
async def decode(self) -> typing.Any:
41+
if self._discard_corrupt is not None:
42+
await self._discard_corrupt()
43+
return await super().decode()
3444

3545
async def _commit(self) -> None:
3646
if self._remove is None:

‎faststream_redis_timers/parser/parser.py‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,13 @@ def __init__(self, config: "TimersSubscriberConfig") -> None:
1919

2020
async def parse_message(self, msg: "TimerMessage") -> TimerStreamMessage:
2121
timer_id = msg["timer_id"]
22-
outer_config = self._config._outer_config # noqa: SLF001
23-
store = outer_config.store
22+
store = self._config._outer_config.store # noqa: SLF001
23+
discard_corrupt = None
2424
try:
2525
body, headers = TimerMessageFormat.parse(msg["data"])
2626
except ValueError as e:
27-
await store.remove(self._config.full_topic, timer_id)
28-
outer_config.logger.log(
29-
f"Timer {timer_id!r} on {self._config.full_topic!r} removed: "
30-
f"its {len(msg['data'])}-byte payload cannot be parsed",
31-
logging.ERROR,
32-
exc_info=e,
33-
)
34-
raise
27+
body, headers = msg["data"], {}
28+
discard_corrupt = partial(self._discard_corrupt, msg, e)
3529
return TimerStreamMessage(
3630
raw_message=msg,
3731
body=body,
@@ -41,7 +35,19 @@ async def parse_message(self, msg: "TimerMessage") -> TimerStreamMessage:
4135
correlation_id=headers.get("correlation_id", timer_id),
4236
reply_to=headers.get("reply_to", ""),
4337
_remove=partial(store.remove, self._config.full_topic, timer_id),
38+
_discard_corrupt=discard_corrupt,
39+
)
40+
41+
async def _discard_corrupt(self, msg: "TimerMessage", error: ValueError) -> typing.NoReturn:
42+
outer_config = self._config._outer_config # noqa: SLF001
43+
await outer_config.store.remove(self._config.full_topic, msg["timer_id"])
44+
outer_config.logger.log(
45+
f"Timer {msg['timer_id']!r} on {self._config.full_topic!r} removed: "
46+
f"its {len(msg['data'])}-byte payload cannot be parsed",
47+
logging.ERROR,
48+
exc_info=error,
4449
)
50+
raise error
4551

4652
async def decode_message(self, msg: TimerStreamMessage) -> typing.Any:
4753
return decode_message(msg)

‎tests/test_envelope.py‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
import logging
44
import os
55
import time
6+
import typing
67
import uuid
78

89
import pytest
910
from faststream import Context
11+
from faststream.message import StreamMessage
1012
from redis.asyncio import Redis
1113

1214
from faststream_redis_timers import TimersBroker
@@ -199,7 +201,7 @@ async def test_legacy_envelope_with_non_hex_body_is_removed_and_logged_once(
199201
seen: list[bytes] = []
200202

201203
@broker.subscriber("topic")
202-
async def handler(body: bytes) -> None: # pragma: no cover - never invoked; the parser rejects the timer
204+
async def handler(body: bytes) -> None: # pragma: no cover - never invoked; the timer is corrupt
203205
seen.append(body)
204206

205207
with caplog.at_level(logging.ERROR, logger=f"corrupt-{suffix}"):
@@ -212,3 +214,34 @@ async def handler(body: bytes) -> None: # pragma: no cover - never invoked; the
212214
removals = [record for record in caplog.records if "removed" in record.getMessage()]
213215
assert len(removals) == 1
214216
assert "'old-timer'" in removals[0].getMessage()
217+
assert not [record for record in caplog.records if record.levelno == logging.CRITICAL]
218+
219+
220+
async def test_corrupt_timer_never_reaches_handler_with_custom_decoder(redis_client: Redis) -> None:
221+
suffix = uuid.uuid4().hex
222+
broker = TimersBroker(
223+
redis_client,
224+
timeline_key=f"corrupt_dec_tl_{suffix}",
225+
payloads_key=f"corrupt_dec_pl_{suffix}",
226+
)
227+
timeline_key = f"corrupt_dec_tl_{suffix}:topic"
228+
payloads_key = f"corrupt_dec_pl_{suffix}:topic"
229+
230+
await redis_client.zadd(timeline_key, {"old-timer": time.time() - 1})
231+
await redis_client.hset(payloads_key, "old-timer", b'{"b": "zz"}')
232+
233+
async def raw_body_decoder(message: StreamMessage[typing.Any]) -> bytes: # pragma: no cover - never invoked
234+
return message.body
235+
236+
seen: list[bytes] = []
237+
238+
@broker.subscriber("topic", decoder=raw_body_decoder)
239+
async def handler(body: bytes) -> None: # pragma: no cover - never invoked; the timer is corrupt
240+
seen.append(body)
241+
242+
async with broker:
243+
await asyncio.sleep(0.3)
244+
245+
assert seen == []
246+
assert await redis_client.zscore(timeline_key, "old-timer") is None
247+
assert not await redis_client.hexists(payloads_key, "old-timer")

0 commit comments

Comments
 (0)