redis-message-queue 8.0.3__tar.gz → 8.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/PKG-INFO +32 -1
  2. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/README.md +31 -0
  3. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/pyproject.toml +1 -1
  4. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/__init__.py +12 -1
  5. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/_abstract_redis_gateway.py +14 -7
  6. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/_config.py +13 -2
  7. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/_event.py +6 -0
  8. redis_message_queue-8.2.0/redis_message_queue/_exceptions.py +165 -0
  9. redis_message_queue-8.2.0/redis_message_queue/_payload_limits.py +72 -0
  10. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/_queue_key_manager.py +2 -1
  11. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/_redis_gateway.py +110 -16
  12. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/_stored_message.py +17 -7
  13. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/asyncio/__init__.py +16 -2
  14. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/asyncio/_abstract_redis_gateway.py +14 -7
  15. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/asyncio/_redis_gateway.py +110 -16
  16. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/asyncio/redis_message_queue.py +242 -22
  17. redis_message_queue-8.2.0/redis_message_queue/interrupt_handler/__init__.py +9 -0
  18. redis_message_queue-8.2.0/redis_message_queue/interrupt_handler/_event_driven.py +24 -0
  19. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/redis_message_queue.py +256 -24
  20. redis_message_queue-8.0.3/redis_message_queue/_exceptions.py +0 -71
  21. redis_message_queue-8.0.3/redis_message_queue/interrupt_handler/__init__.py +0 -4
  22. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/LICENSE +0 -0
  23. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/_callable_utils.py +0 -0
  24. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/_redis_cluster.py +0 -0
  25. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
  26. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/interrupt_handler/_interface.py +0 -0
  27. {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redis-message-queue
3
- Version: 8.0.3
3
+ Version: 8.2.0
4
4
  Summary: Python message queuing with Redis and message deduplication
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -392,6 +392,32 @@ while not interrupt.is_interrupted():
392
392
  > processes, or install one top-level signal owner that calls `queue.drain()`
393
393
  > / `queue.aclose()` or sets an application stop event.
394
394
 
395
+ If another library owns SIGTERM/SIGINT in the same process, adapt its shutdown
396
+ signal to rmq with a user-owned event instead of installing rmq signal handlers:
397
+
398
+ ```python
399
+ import threading
400
+
401
+ from redis_message_queue import EventDrivenInterruptHandler, RedisMessageQueue
402
+
403
+ stop_event = threading.Event()
404
+ interrupt = EventDrivenInterruptHandler(stop_event)
405
+ queue = RedisMessageQueue("q", client=client, interrupt=interrupt)
406
+
407
+ while not interrupt.is_interrupted():
408
+ with queue.process_message() as message:
409
+ if message is not None:
410
+ process(message)
411
+
412
+ # In the sibling library's shutdown hook:
413
+ stop_event.set()
414
+ queue.drain(timeout=25)
415
+ ```
416
+
417
+ The caller MUST set `stop_event` before exiting. rmq observes
418
+ `is_interrupted()` and exits cooperatively; it does not call `sys.exit()` or
419
+ otherwise force process shutdown.
420
+
395
421
  There are three distinct shutdown shapes; pick the one that matches your runtime:
396
422
 
397
423
  | Shape | Trigger | In-flight handler | Pending claim IDs |
@@ -589,6 +615,11 @@ using `":queue:"` with a queue name that overlaps RQ keys. rmq has no fixed
589
615
  library prefix; generated keys share the Redis DB namespace with every other
590
616
  Redis user.
591
617
 
618
+ Set `strict_envelope_decoding=True` if this Redis is shared with sibling task
619
+ libraries (Celery, RQ, Dramatiq) to fail-fast on foreign payloads. With the
620
+ default `False`, non-rmq values that do not start with the rmq envelope prefix
621
+ remain backward-compatible raw messages and are yielded to the handler.
622
+
592
623
  ## Production notes
593
624
 
594
625
  ### Fork safety and pre-fork servers
@@ -366,6 +366,32 @@ while not interrupt.is_interrupted():
366
366
  > processes, or install one top-level signal owner that calls `queue.drain()`
367
367
  > / `queue.aclose()` or sets an application stop event.
368
368
 
369
+ If another library owns SIGTERM/SIGINT in the same process, adapt its shutdown
370
+ signal to rmq with a user-owned event instead of installing rmq signal handlers:
371
+
372
+ ```python
373
+ import threading
374
+
375
+ from redis_message_queue import EventDrivenInterruptHandler, RedisMessageQueue
376
+
377
+ stop_event = threading.Event()
378
+ interrupt = EventDrivenInterruptHandler(stop_event)
379
+ queue = RedisMessageQueue("q", client=client, interrupt=interrupt)
380
+
381
+ while not interrupt.is_interrupted():
382
+ with queue.process_message() as message:
383
+ if message is not None:
384
+ process(message)
385
+
386
+ # In the sibling library's shutdown hook:
387
+ stop_event.set()
388
+ queue.drain(timeout=25)
389
+ ```
390
+
391
+ The caller MUST set `stop_event` before exiting. rmq observes
392
+ `is_interrupted()` and exits cooperatively; it does not call `sys.exit()` or
393
+ otherwise force process shutdown.
394
+
369
395
  There are three distinct shutdown shapes; pick the one that matches your runtime:
370
396
 
371
397
  | Shape | Trigger | In-flight handler | Pending claim IDs |
@@ -563,6 +589,11 @@ using `":queue:"` with a queue name that overlaps RQ keys. rmq has no fixed
563
589
  library prefix; generated keys share the Redis DB namespace with every other
564
590
  Redis user.
565
591
 
592
+ Set `strict_envelope_decoding=True` if this Redis is shared with sibling task
593
+ libraries (Celery, RQ, Dramatiq) to fail-fast on foreign payloads. With the
594
+ default `False`, non-rmq values that do not start with the rmq envelope prefix
595
+ remain backward-compatible raw messages and are yielded to the handler.
596
+
566
597
  ## Production notes
567
598
 
568
599
  ### Fork safety and pre-fork servers
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "redis-message-queue"
3
- version = "8.0.3"
3
+ version = "8.2.0"
4
4
  description = "Python message queuing with Redis and message deduplication"
5
5
  authors = ["Elijas <4084885+Elijas@users.noreply.github.com>"]
6
6
  readme = "README.md"
@@ -1,20 +1,25 @@
1
1
  from redis_message_queue._abstract_redis_gateway import AbstractRedisGateway
2
2
  from redis_message_queue._event import EventOperation, EventOutcome, QueueEvent
3
3
  from redis_message_queue._exceptions import (
4
+ ClaimStoreFailedError,
4
5
  CleanupFailedError,
5
6
  ConfigurationError,
7
+ DrainFailedError,
6
8
  GatewayContractError,
7
9
  LuaScriptError,
8
10
  MalformedStoredMessageError,
11
+ PayloadTooDeepError,
12
+ PayloadTooLargeError,
9
13
  QueueBackpressureError,
10
14
  QueueDrainedError,
11
15
  RedisMessageQueueError,
12
16
  RetryBudgetExhaustedError,
13
17
  )
14
18
  from redis_message_queue._redis_gateway import RedisGateway
15
- from redis_message_queue._stored_message import ClaimedMessage, MessageData
19
+ from redis_message_queue._stored_message import ClaimedMessage, MessageData, MessagePayload
16
20
  from redis_message_queue.interrupt_handler import (
17
21
  BaseGracefulInterruptHandler,
22
+ EventDrivenInterruptHandler,
18
23
  GracefulInterruptHandler,
19
24
  )
20
25
  from redis_message_queue.redis_message_queue import RedisMessageQueue
@@ -25,16 +30,22 @@ __all__ = [
25
30
  "AbstractRedisGateway",
26
31
  "ClaimedMessage",
27
32
  "MessageData",
33
+ "MessagePayload",
34
+ "EventDrivenInterruptHandler",
28
35
  "GracefulInterruptHandler",
29
36
  "BaseGracefulInterruptHandler",
30
37
  "QueueEvent",
31
38
  "EventOperation",
32
39
  "EventOutcome",
33
40
  "RedisMessageQueueError",
41
+ "ClaimStoreFailedError",
34
42
  "ConfigurationError",
43
+ "DrainFailedError",
35
44
  "GatewayContractError",
36
45
  "LuaScriptError",
37
46
  "MalformedStoredMessageError",
47
+ "PayloadTooLargeError",
48
+ "PayloadTooDeepError",
38
49
  "QueueBackpressureError",
39
50
  "QueueDrainedError",
40
51
  "CleanupFailedError",
@@ -12,13 +12,12 @@ class AbstractRedisGateway(ABC):
12
12
  gateways MUST uphold the same behavioral contracts documented on each method
13
13
  to avoid phantom heartbeats, undetected lease conflicts, or silent data loss.
14
14
 
15
- Gateways that support visibility timeouts (lease-based claiming) MUST expose
16
- a ``message_visibility_timeout_seconds`` property (int or None). This is not
17
- abstract because it is configuration rather than protocol, but it is required
18
- when the queue is configured with ``heartbeat_interval_seconds``.
19
- Lease-capable custom gateways MUST expose this property; omitting it
20
- silently disables heartbeat validation and lease-token safety checks,
21
- causing the queue to treat the gateway as a non-lease implementation.
15
+ Gateways that support visibility timeouts (lease-based claiming) MUST
16
+ override the ``message_visibility_timeout_seconds`` property with a positive
17
+ int. The abstract base declares this property with a ``None`` default so
18
+ non-lease custom gateways keep the existing behavior, while lease-capable
19
+ custom gateways have a typeable contract to override. A positive value is
20
+ required when the queue is configured with ``heartbeat_interval_seconds``.
22
21
 
23
22
  The queue also reads ``max_delivery_count`` and ``dead_letter_queue``
24
23
  from the gateway. The abstract base provides ``None`` defaults via
@@ -65,6 +64,14 @@ class AbstractRedisGateway(ABC):
65
64
  def dead_letter_queue(self) -> str | None:
66
65
  return None
67
66
 
67
+ @property
68
+ def message_visibility_timeout_seconds(self) -> int | None:
69
+ """Visibility timeout (lease duration) in seconds. Override to enable
70
+ lease-based crash recovery; return None to disable. Required when the
71
+ queue is configured with ``heartbeat_interval_seconds``.
72
+ """
73
+ return None
74
+
68
75
  @abstractmethod
69
76
  def publish_message(self, queue: str, message: str, dedup_key: str) -> bool:
70
77
  """Publish a message with deduplication.
@@ -31,6 +31,7 @@ DEFAULT_RETRY_INITIAL_DELAY_SECONDS = 0.01
31
31
  DEFAULT_PENDING_OVERLOAD_BLOCK_TIMEOUT_SECONDS = 1.0
32
32
  INTERRUPTIBLE_RETRY_SLEEP_POLL_SECONDS = 0.05
33
33
  PENDING_OVERLOAD_LUA_SENTINEL = -1
34
+ CLAIM_STORE_FAILED_LUA_SENTINEL = "\0__rmq_claim_store_failed__"
34
35
  PENDING_OVERLOAD_POLICIES = ("raise", "drop_oldest", "block")
35
36
  DEDUPLICATION_REQUIRES_KEY_MESSAGE = (
36
37
  "deduplication=True requires get_deduplication_key (callable returning a non-empty str). "
@@ -53,6 +54,13 @@ def is_redis_retryable_exception(exception):
53
54
  if isinstance(exception, redis.exceptions.ClusterError) and "TTL exhausted" in str(exception):
54
55
  return True
55
56
 
57
+ no_script_error = getattr(redis.exceptions, "NoScriptError", None)
58
+ if no_script_error is not None and isinstance(exception, no_script_error):
59
+ return True
60
+
61
+ if isinstance(exception, redis.exceptions.ResponseError) and str(exception).startswith("NOSCRIPT"):
62
+ return True
63
+
56
64
  # 2. Explicit retryable exceptions (BusyLoadingError is a ConnectionError
57
65
  # subclass, so it is already handled by branch 1 above)
58
66
  return isinstance(
@@ -832,11 +840,13 @@ if #to_requeue > 0 then
832
840
  redis.call('RPUSH', KEYS[1], unpack(to_requeue))
833
841
  end
834
842
  local dead_lettered_events = {}
843
+ local claim_store_failed_sentinel = string.char(0) .. '__rmq_claim_store_failed__'
835
844
 
836
845
  local function store_claim_and_return(stored)
837
846
  -- pcall guards against OOM mid-write: compensate by returning message to pending
838
847
  local ok, result = pcall(function()
839
- local lease_token = tostring(redis.call('INCR', KEYS[5]))
848
+ redis.call('INCR', KEYS[5])
849
+ local lease_token = redis.call('GET', KEYS[5])
840
850
  local claim_payload = cjson.encode({stored, lease_token})
841
851
  redis.call('ZADD', KEYS[3], now_ms + tonumber(ARGV[1]), stored)
842
852
  redis.call('HSET', KEYS[4], stored, lease_token)
@@ -847,9 +857,10 @@ local function store_claim_and_return(stored)
847
857
  return {stored, lease_token, reclaimed_events, dead_lettered_events}
848
858
  end)
849
859
  if not ok then
860
+ redis.call('HINCRBY', KEYS[6], stored, -1)
850
861
  redis.call('LREM', KEYS[2], 1, stored)
851
862
  redis.pcall('RPUSH', KEYS[1], stored)
852
- return false
863
+ return {claim_store_failed_sentinel, tostring(result), stored}
853
864
  end
854
865
  return result
855
866
  end
@@ -24,6 +24,7 @@ class EventOperation(StrEnum):
24
24
  TRIM_FAILED = "trim_failed"
25
25
  RETRY_ATTEMPT = "retry_attempt"
26
26
  RETRY_EXHAUSTED = "retry_exhausted"
27
+ DRAIN = "drain"
27
28
 
28
29
 
29
30
  class EventOutcome(StrEnum):
@@ -32,6 +33,7 @@ class EventOutcome(StrEnum):
32
33
  SUCCESS = "success"
33
34
  FAILURE = "failure"
34
35
  SKIPPED = "skipped"
36
+ START = "start"
35
37
 
36
38
 
37
39
  @dataclass(frozen=True)
@@ -68,3 +70,7 @@ class QueueEvent:
68
70
  the actual exception object when one was raised; pass to OpenTelemetry
69
71
  `span.record_exception(...)` for full trace attribution
70
72
  """
73
+ timeout_seconds: float | None = None
74
+ """the caller-requested timeout for drain operations, when applicable"""
75
+ pending_claim_ids: int | None = None
76
+ """number of unresolved pending claim ids for drain operations, when applicable"""
@@ -0,0 +1,165 @@
1
+ import redis.exceptions
2
+
3
+
4
+ class RedisMessageQueueError(Exception):
5
+ """Base class for redis-message-queue specific errors."""
6
+
7
+ def __init__(
8
+ self,
9
+ *args: object,
10
+ queue: str | None = None,
11
+ message_id: str | None = None,
12
+ operation: str | None = None,
13
+ ) -> None:
14
+ super().__init__(*args)
15
+ self.queue = queue
16
+ self.message_id = message_id
17
+ self.operation = operation
18
+
19
+
20
+ class ConfigurationError(RedisMessageQueueError, ValueError):
21
+ """Bad parameter values or combinations."""
22
+
23
+
24
+ class GatewayContractError(RedisMessageQueueError, TypeError):
25
+ """Custom gateway returned wrong type or violated contract."""
26
+
27
+
28
+ class LuaScriptError(redis.exceptions.ResponseError, RedisMessageQueueError):
29
+ """A Lua script returned an unexpected error_reply."""
30
+
31
+ def __init__(
32
+ self,
33
+ *args: object,
34
+ queue: str | None = None,
35
+ message_id: str | None = None,
36
+ operation: str | None = None,
37
+ ) -> None:
38
+ RedisMessageQueueError.__init__(
39
+ self,
40
+ *args,
41
+ queue=queue,
42
+ message_id=message_id,
43
+ operation=operation,
44
+ )
45
+
46
+
47
+ class CleanupFailedError(RedisMessageQueueError):
48
+ """Cleanup after handler completion failed."""
49
+
50
+
51
+ class ClaimStoreFailedError(RedisMessageQueueError):
52
+ """Raised when the VT-claim Lua store_claim_and_return pcall failed.
53
+
54
+ The script decremented the speculative delivery_count increment and
55
+ compensated by returning the message to pending before surfacing this error.
56
+ """
57
+
58
+
59
+ class DrainFailedError(RedisMessageQueueError):
60
+ """Wraps a non-RMQ exception caught during drain pending-claim recovery.
61
+
62
+ drain() returns False as the bool result; this exception carries
63
+ F7 context (queue, operation="drain") into the drain/failure event
64
+ payload so users diagnosing drain incidents via on_event see the
65
+ same structured attrs as elsewhere.
66
+ """
67
+
68
+
69
+ class MalformedStoredMessageError(RedisMessageQueueError):
70
+ """Stored value is not a valid RMQ envelope for the configured decode mode."""
71
+
72
+
73
+ class PayloadTooLargeError(RedisMessageQueueError, ValueError):
74
+ """Publish payload exceeds the configured serialized byte limit."""
75
+
76
+
77
+ class PayloadTooDeepError(RedisMessageQueueError, ValueError):
78
+ """Publish payload exceeds the configured nesting-depth limit."""
79
+
80
+
81
+ class QueueBackpressureError(RedisMessageQueueError):
82
+ """Publish rejected because the pending queue is at its configured limit."""
83
+
84
+ _REMEDIATION = (
85
+ "consider increasing `max_pending_length`, switching to "
86
+ "`pending_overload_policy='block'`, or adding consumer capacity."
87
+ )
88
+
89
+ def __init__(
90
+ self,
91
+ *args: object,
92
+ queue: str | None = None,
93
+ message_id: str | None = None,
94
+ operation: str | None = None,
95
+ ) -> None:
96
+ message = "Pending queue reached its configured limit" if not args else args[0]
97
+ if not isinstance(message, str) or len(args) > 1:
98
+ super().__init__(*args, queue=queue, message_id=message_id, operation=operation)
99
+ return
100
+ if self._REMEDIATION not in message:
101
+ message = f"{message}; {self._REMEDIATION}"
102
+ super().__init__(message, queue=queue, message_id=message_id, operation=operation)
103
+
104
+
105
+ class QueueDrainedError(RedisMessageQueueError):
106
+ """Raised when publish() is called after drain() or aclose()."""
107
+
108
+
109
+ class RetryBudgetExhaustedError(redis.exceptions.RedisError, RedisMessageQueueError):
110
+ """Tenacity retry budget exhausted; underlying redis-py exception is .__cause__."""
111
+
112
+ _REMEDIATION = (
113
+ "verify Redis connectivity and consider increasing `retry_budget_seconds` if transient failures are expected."
114
+ )
115
+
116
+ def __init__(
117
+ self,
118
+ *args: object,
119
+ queue: str | None = None,
120
+ message_id: str | None = None,
121
+ operation: str | None = None,
122
+ ) -> None:
123
+ message = "Redis retry budget exhausted" if not args else args[0]
124
+ if not isinstance(message, str) or len(args) > 1:
125
+ RedisMessageQueueError.__init__(
126
+ self,
127
+ *args,
128
+ queue=queue,
129
+ message_id=message_id,
130
+ operation=operation,
131
+ )
132
+ return
133
+ if self._REMEDIATION not in message:
134
+ message = f"{message}; {self._REMEDIATION}"
135
+ RedisMessageQueueError.__init__(
136
+ self,
137
+ message,
138
+ queue=queue,
139
+ message_id=message_id,
140
+ operation=operation,
141
+ )
142
+
143
+
144
+ def _set_exception_context(
145
+ exc: BaseException,
146
+ *,
147
+ queue: str | None = None,
148
+ message_id: str | None = None,
149
+ operation: str | None = None,
150
+ ) -> None:
151
+ if not isinstance(exc, RedisMessageQueueError):
152
+ return
153
+ if queue is not None:
154
+ exc.queue = queue
155
+ if message_id is not None:
156
+ exc.message_id = message_id
157
+ if operation is not None:
158
+ exc.operation = operation
159
+
160
+
161
+ def wrap_lua_response_error(exc: redis.exceptions.ResponseError) -> LuaScriptError | None:
162
+ message = str(exc)
163
+ if message.startswith("WRONGTYPE ") or message.startswith("OOM during publish;"):
164
+ return LuaScriptError(message)
165
+ return None
@@ -0,0 +1,72 @@
1
+ import json
2
+
3
+ from redis_message_queue._exceptions import ConfigurationError, PayloadTooDeepError, PayloadTooLargeError
4
+
5
+
6
+ def validate_payload_limit_parameter(name: str, value: int | None) -> int | None:
7
+ if value is None:
8
+ return None
9
+ if not isinstance(value, int) or isinstance(value, bool):
10
+ bool_hint = " (use a positive int or None, not True/False)" if isinstance(value, bool) else ""
11
+ raise TypeError(f"'{name}' must be an int or None, got {type(value).__name__}{bool_hint}")
12
+ if value <= 0:
13
+ raise ConfigurationError(f"'{name}' must be positive when provided, got {value}")
14
+ return value
15
+
16
+
17
+ def validate_max_payload_depth(message: dict, max_payload_depth: int | None) -> None:
18
+ if max_payload_depth is None:
19
+ return
20
+
21
+ stack: list[tuple[object, str, int]] = [(message, "message", 0)]
22
+ seen: set[int] = set()
23
+ while stack:
24
+ value, path, depth = stack.pop()
25
+ if depth > max_payload_depth:
26
+ raise PayloadTooDeepError(
27
+ f"max_payload_depth={max_payload_depth} exceeded: depth {depth} reached at {path}"
28
+ )
29
+ if isinstance(value, dict):
30
+ current_id = id(value)
31
+ if current_id in seen:
32
+ continue
33
+ seen.add(current_id)
34
+ children = list(value.items())
35
+ for key, child in reversed(children):
36
+ stack.append((child, f"{path}[{key!r}]", depth + 1))
37
+ elif isinstance(value, (list, tuple)):
38
+ current_id = id(value)
39
+ if current_id in seen:
40
+ continue
41
+ seen.add(current_id)
42
+ for index in range(len(value) - 1, -1, -1):
43
+ stack.append((value[index], f"{path}[{index}]", depth + 1))
44
+
45
+
46
+ def serialize_dict_payload_with_limit(message: dict, max_payload_bytes: int | None) -> str:
47
+ message_str = json.dumps(message, sort_keys=True, allow_nan=False)
48
+ if max_payload_bytes is not None:
49
+ validate_max_payload_bytes(
50
+ len(message_str.encode("utf-8")),
51
+ max_payload_bytes,
52
+ payload_type="dict message",
53
+ )
54
+ return message_str
55
+
56
+
57
+ def validate_str_payload_size(message: str, max_payload_bytes: int | None) -> None:
58
+ if max_payload_bytes is None:
59
+ return
60
+ validate_max_payload_bytes(
61
+ len(message.encode("utf-8")),
62
+ max_payload_bytes,
63
+ payload_type="str message",
64
+ )
65
+
66
+
67
+ def validate_max_payload_bytes(size_bytes: int, max_payload_bytes: int | None, *, payload_type: str) -> None:
68
+ if max_payload_bytes is None or size_bytes <= max_payload_bytes:
69
+ return
70
+ raise PayloadTooLargeError(
71
+ f"max_payload_bytes={max_payload_bytes} exceeded: payload is {size_bytes} bytes ({payload_type})"
72
+ )
@@ -1,7 +1,8 @@
1
1
  from redis_message_queue._exceptions import ConfigurationError
2
+ from redis_message_queue._stored_message import MessagePayload
2
3
 
3
4
 
4
- def validate_callable_deduplication_key(dedup_key: object, message: str | dict) -> str:
5
+ def validate_callable_deduplication_key(dedup_key: object, message: MessagePayload) -> str:
5
6
  if dedup_key is None:
6
7
  raise ConfigurationError(
7
8
  f"get_deduplication_key returned None for message {message!r}; the callable must return a non-empty string"