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.
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/PKG-INFO +32 -1
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/README.md +31 -0
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/pyproject.toml +1 -1
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/__init__.py +12 -1
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/_abstract_redis_gateway.py +14 -7
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/_config.py +13 -2
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/_event.py +6 -0
- redis_message_queue-8.2.0/redis_message_queue/_exceptions.py +165 -0
- redis_message_queue-8.2.0/redis_message_queue/_payload_limits.py +72 -0
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/_queue_key_manager.py +2 -1
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/_redis_gateway.py +110 -16
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/_stored_message.py +17 -7
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/asyncio/__init__.py +16 -2
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/asyncio/_abstract_redis_gateway.py +14 -7
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/asyncio/_redis_gateway.py +110 -16
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/asyncio/redis_message_queue.py +242 -22
- redis_message_queue-8.2.0/redis_message_queue/interrupt_handler/__init__.py +9 -0
- redis_message_queue-8.2.0/redis_message_queue/interrupt_handler/_event_driven.py +24 -0
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/redis_message_queue.py +256 -24
- redis_message_queue-8.0.3/redis_message_queue/_exceptions.py +0 -71
- redis_message_queue-8.0.3/redis_message_queue/interrupt_handler/__init__.py +0 -4
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/LICENSE +0 -0
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/_callable_utils.py +0 -0
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/_redis_cluster.py +0 -0
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/interrupt_handler/_implementation.py +0 -0
- {redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/interrupt_handler/_interface.py +0 -0
- {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
|
+
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,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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
)
|
{redis_message_queue-8.0.3 → redis_message_queue-8.2.0}/redis_message_queue/_queue_key_manager.py
RENAMED
|
@@ -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:
|
|
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"
|