feat: enrich DuplicateTaskError with structured attributes (#66)

This commit is contained in:
d3vyce
2026-06-27 13:59:50 +02:00
committed by GitHub
parent 2c2a3c89f1
commit 1fe1b72451
3 changed files with 78 additions and 3 deletions
+19
View File
@@ -103,6 +103,25 @@ except DuplicateTaskError:
pass # task is already queued or running pass # task is already queued or running
``` ```
`DuplicateTaskError` carries structured attributes describing the collision:
```python
try:
await my_task.kiq(user_id=42)
except DuplicateTaskError as err:
logger.info(
"Skipped %s; already held by %s (key=%s)",
err.task_name,
err.holder_task_id,
err.key,
)
```
- `task_name` — name of the task that was rejected.
- `key` — Redis lock key whose owner caused the rejection.
- `holder_task_id``task_id` of the task currently holding the lock, or `None`
if it could not be retrieved.
## Per-task label overrides ## Per-task label overrides
Labels can be set at the task level (applied to every call) or at call time. Labels can be set at the task level (applied to every call) or at call time.
+31 -3
View File
@@ -31,7 +31,29 @@ _CACHED_KEY_LABEL = "__taskiq_dedup_cached_key"
class DuplicateTaskError(Exception): class DuplicateTaskError(Exception):
"""Raised when a task with identical name and kwargs is already queued or running.""" """Raised when a task with identical name and kwargs is already queued or running.
Attributes:
task_name: Name of the task that was rejected.
key: Redis lock key whose owner caused the rejection.
holder_task_id: ``task_id`` of the task currently holding the lock, or
``None`` if it could not be retrieved (e.g. the lock was released
between the failed acquisition and the lookup).
"""
def __init__(
self,
task_name: str,
key: str,
holder_task_id: str | None = None,
) -> None:
self.task_name = task_name
self.key = key
self.holder_task_id = holder_task_id
super().__init__(
f"Task {task_name!r} with the same arguments is already queued or "
f"running (key={key!r}, holder_task_id={holder_task_id!r})."
)
class RedisDeduplicationMiddleware(TaskiqMiddleware): class RedisDeduplicationMiddleware(TaskiqMiddleware):
@@ -202,13 +224,19 @@ class RedisDeduplicationMiddleware(TaskiqMiddleware):
logger.debug("Acquiring lock %s for task %s", key, message.task_name) logger.debug("Acquiring lock %s for task %s", key, message.task_name)
acquired = await self._redis.set(key, message.task_id, ex=ttl, nx=True) acquired = await self._redis.set(key, message.task_id, ex=ttl, nx=True)
if not acquired: if not acquired:
holder_task_id = await self._redis.get(key)
if isinstance(holder_task_id, bytes):
holder_task_id = holder_task_id.decode()
logger.warning( logger.warning(
"Duplicate task %s dropped (key=%s).", "Duplicate task %s dropped (key=%s, holder_task_id=%s).",
message.task_name, message.task_name,
key, key,
holder_task_id,
) )
raise DuplicateTaskError( raise DuplicateTaskError(
f"Task {message.task_name!r} with the same arguments is already queued or running." task_name=message.task_name,
key=key,
holder_task_id=holder_task_id,
) )
logger.debug("Lock %s acquired for task %s", key, message.task_name) logger.debug("Lock %s acquired for task %s", key, message.task_name)
+28
View File
@@ -146,6 +146,34 @@ class TestPreSend:
with pytest.raises(DuplicateTaskError): with pytest.raises(DuplicateTaskError):
await middleware.pre_send(make_message()) await middleware.pre_send(make_message())
async def test_duplicate_error_carries_structured_attributes(
self, middleware, make_message
):
holder = make_message(task_id="holder-task")
await middleware.pre_send(holder)
key = middleware._build_deduplication_key(holder)
with pytest.raises(DuplicateTaskError) as exc_info:
await middleware.pre_send(make_message(task_id="loser-task"))
err = exc_info.value
assert err.task_name == "my_task"
assert err.key == key
assert err.holder_task_id == "holder-task"
assert key in str(err)
async def test_duplicate_error_holder_none_when_lock_released_in_race(
self, make_message
):
# The lock is released between the failed SET NX and the GET lookup, so
# GET returns None and holder_task_id is left unset.
redis = AsyncMock()
redis.set.return_value = False
redis.get.return_value = None
mw = RedisDeduplicationMiddleware(redis_url="redis://localhost")
mw._redis = redis
with pytest.raises(DuplicateTaskError) as exc_info:
await mw.pre_send(make_message())
assert exc_info.value.holder_task_id is None
async def test_deduplication_disabled_label(self, middleware, make_message): async def test_deduplication_disabled_label(self, middleware, make_message):
msg1 = make_message(labels={DEDUP_LABEL: False}) msg1 = make_message(labels={DEDUP_LABEL: False})
msg2 = make_message(labels={DEDUP_LABEL: False}) msg2 = make_message(labels={DEDUP_LABEL: False})