2026-02-14 06:21:50 +00:00
|
|
|
"""Webhook queue persistence and delivery helpers."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
2026-02-15 12:30:15 +05:30
|
|
|
from datetime import UTC, datetime
|
2026-02-14 06:21:50 +00:00
|
|
|
from typing import Any
|
|
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
|
|
|
from app.core.config import settings
|
|
|
|
|
from app.core.logging import get_logger
|
2026-02-15 12:30:15 +05:30
|
|
|
from app.services.queue import QueuedTask, dequeue_task, enqueue_task, requeue_if_failed as generic_requeue_if_failed
|
2026-02-14 06:21:50 +00:00
|
|
|
|
|
|
|
|
logger = get_logger(__name__)
|
2026-02-15 12:30:15 +05:30
|
|
|
TASK_TYPE = "webhook_delivery"
|
2026-02-14 06:21:50 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
2026-02-15 12:30:15 +05:30
|
|
|
class QueuedInboundDelivery:
|
2026-02-14 06:21:50 +00:00
|
|
|
"""Payload metadata stored for deferred webhook lead dispatch."""
|
|
|
|
|
|
|
|
|
|
board_id: UUID
|
|
|
|
|
webhook_id: UUID
|
|
|
|
|
payload_id: UUID
|
|
|
|
|
received_at: datetime
|
|
|
|
|
attempts: int = 0
|
|
|
|
|
|
|
|
|
|
|
2026-02-15 12:30:15 +05:30
|
|
|
def _task_from_payload(payload: QueuedInboundDelivery) -> QueuedTask:
|
|
|
|
|
return QueuedTask(
|
|
|
|
|
task_type=TASK_TYPE,
|
|
|
|
|
payload={
|
|
|
|
|
"board_id": str(payload.board_id),
|
|
|
|
|
"webhook_id": str(payload.webhook_id),
|
|
|
|
|
"payload_id": str(payload.payload_id),
|
|
|
|
|
"received_at": payload.received_at.isoformat(),
|
|
|
|
|
},
|
|
|
|
|
created_at=payload.received_at,
|
|
|
|
|
attempts=payload.attempts,
|
|
|
|
|
)
|
2026-02-14 06:21:50 +00:00
|
|
|
|
|
|
|
|
|
2026-02-15 13:07:32 +05:30
|
|
|
def decode_webhook_task(task: QueuedTask) -> QueuedInboundDelivery:
|
2026-02-15 12:30:15 +05:30
|
|
|
if task.task_type not in {TASK_TYPE, "legacy"}:
|
|
|
|
|
raise ValueError(f"Unexpected task_type={task.task_type!r}; expected {TASK_TYPE!r}")
|
2026-02-14 06:21:50 +00:00
|
|
|
|
2026-02-15 12:30:15 +05:30
|
|
|
payload: dict[str, Any] = task.payload
|
|
|
|
|
if task.task_type == "legacy":
|
|
|
|
|
received_at = payload.get("received_at") or payload.get("created_at")
|
|
|
|
|
return QueuedInboundDelivery(
|
|
|
|
|
board_id=UUID(payload["board_id"]),
|
|
|
|
|
webhook_id=UUID(payload["webhook_id"]),
|
|
|
|
|
payload_id=UUID(payload["payload_id"]),
|
|
|
|
|
received_at=datetime.fromisoformat(received_at) if isinstance(received_at, str) else datetime.now(UTC),
|
|
|
|
|
attempts=int(payload.get("attempts", task.attempts)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return QueuedInboundDelivery(
|
|
|
|
|
board_id=UUID(payload["board_id"]),
|
|
|
|
|
webhook_id=UUID(payload["webhook_id"]),
|
|
|
|
|
payload_id=UUID(payload["payload_id"]),
|
|
|
|
|
received_at=datetime.fromisoformat(payload["received_at"]),
|
|
|
|
|
attempts=int(payload.get("attempts", task.attempts)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def enqueue_webhook_delivery(payload: QueuedInboundDelivery) -> bool:
|
2026-02-14 06:21:50 +00:00
|
|
|
"""Persist webhook metadata in a Redis queue for batch dispatch."""
|
|
|
|
|
try:
|
2026-02-15 12:30:15 +05:30
|
|
|
queued = _task_from_payload(payload)
|
|
|
|
|
enqueue_task(queued, settings.rq_queue_name, redis_url=settings.rq_redis_url)
|
2026-02-14 06:21:50 +00:00
|
|
|
logger.info(
|
|
|
|
|
"webhook.queue.enqueued",
|
|
|
|
|
extra={
|
|
|
|
|
"board_id": str(payload.board_id),
|
|
|
|
|
"webhook_id": str(payload.webhook_id),
|
|
|
|
|
"payload_id": str(payload.payload_id),
|
|
|
|
|
"attempt": payload.attempts,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
return True
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"webhook.queue.enqueue_failed",
|
|
|
|
|
extra={
|
|
|
|
|
"board_id": str(payload.board_id),
|
|
|
|
|
"webhook_id": str(payload.webhook_id),
|
|
|
|
|
"payload_id": str(payload.payload_id),
|
|
|
|
|
"error": str(exc),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
2026-02-15 12:48:08 +05:30
|
|
|
def dequeue_webhook_delivery(
|
|
|
|
|
*,
|
|
|
|
|
block: bool = False,
|
|
|
|
|
block_timeout: float = 0,
|
|
|
|
|
) -> QueuedInboundDelivery | None:
|
2026-02-14 06:21:50 +00:00
|
|
|
"""Pop one queued webhook delivery payload."""
|
|
|
|
|
try:
|
2026-02-15 12:48:08 +05:30
|
|
|
task = dequeue_task(
|
|
|
|
|
settings.rq_queue_name,
|
|
|
|
|
redis_url=settings.rq_redis_url,
|
|
|
|
|
block=block,
|
|
|
|
|
block_timeout=block_timeout,
|
|
|
|
|
)
|
2026-02-15 12:30:15 +05:30
|
|
|
if task is None:
|
|
|
|
|
return None
|
2026-02-15 13:07:32 +05:30
|
|
|
return decode_webhook_task(task)
|
2026-02-14 06:21:50 +00:00
|
|
|
except Exception as exc:
|
|
|
|
|
logger.error(
|
|
|
|
|
"webhook.queue.dequeue_failed",
|
2026-02-15 12:30:15 +05:30
|
|
|
extra={
|
|
|
|
|
"queue_name": settings.rq_queue_name,
|
|
|
|
|
"error": str(exc),
|
|
|
|
|
},
|
2026-02-14 06:21:50 +00:00
|
|
|
)
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
2026-02-15 13:01:33 +05:30
|
|
|
def requeue_if_failed(
|
|
|
|
|
payload: QueuedInboundDelivery,
|
|
|
|
|
*,
|
|
|
|
|
delay_seconds: float = 0,
|
|
|
|
|
) -> bool:
|
2026-02-14 06:21:50 +00:00
|
|
|
"""Requeue payload delivery with capped retries.
|
|
|
|
|
|
|
|
|
|
Returns True if requeued.
|
|
|
|
|
"""
|
2026-02-15 12:30:15 +05:30
|
|
|
try:
|
|
|
|
|
return generic_requeue_if_failed(
|
|
|
|
|
_task_from_payload(payload),
|
|
|
|
|
settings.rq_queue_name,
|
|
|
|
|
max_retries=settings.rq_dispatch_max_retries,
|
|
|
|
|
redis_url=settings.rq_redis_url,
|
2026-02-15 13:01:33 +05:30
|
|
|
delay_seconds=delay_seconds,
|
2026-02-15 12:30:15 +05:30
|
|
|
)
|
|
|
|
|
except Exception as exc:
|
2026-02-14 06:21:50 +00:00
|
|
|
logger.warning(
|
2026-02-15 12:30:15 +05:30
|
|
|
"webhook.queue.requeue_failed",
|
2026-02-14 06:21:50 +00:00
|
|
|
extra={
|
|
|
|
|
"board_id": str(payload.board_id),
|
|
|
|
|
"webhook_id": str(payload.webhook_id),
|
|
|
|
|
"payload_id": str(payload.payload_id),
|
2026-02-15 12:30:15 +05:30
|
|
|
"error": str(exc),
|
2026-02-14 06:21:50 +00:00
|
|
|
},
|
|
|
|
|
)
|
2026-02-15 12:30:15 +05:30
|
|
|
raise
|