feat(webhooks): durable retrying delivery for final webhooks (#478)

* feat(webhooks): durable retrying delivery for final webhooks

Final webhook nodes were fired inline with a single best-effort httpx POST
(run_integrations._execute_webhook_node). On a transient error the failure was
swallowed at three levels, so ARQ never retried and the final call report was
permanently lost -- leaving downstream receivers stuck (e.g. a CRM showing a
call as still "in conversation").

Replace the one-shot POST with a durable, idempotent delivery pipeline modelled
on the campaign retry pattern (persisted row + scheduled_for + bounded attempts):

- New webhook_deliveries table (WebhookDeliveryModel) is the source of truth.
  Payload is rendered once and frozen so retries are deterministic; secrets are
  not stored -- the credential is referenced by uuid and re-resolved at send time.
- run_integrations now persists a delivery row and enqueues deliver_webhook with
  a deterministic ARQ job id instead of sending inline.
- deliver_webhook (new ARQ task) sends the request and:
    * 2xx            -> succeeded
    * transient      -> retry with capped exponential backoff (RequestError /
                        5xx / 408 / 425 / 429), up to max_attempts then dead_letter
    * permanent 4xx  -> dead_letter immediately (no pointless looping)
  It is idempotent: a non-pending delivery is a no-op, so a duplicate enqueue or
  sweeper re-injection can't double-send.
- sweep_webhook_deliveries cron (every 5 min) re-enqueues overdue pending
  deliveries so nothing is lost to a worker restart / Redis flush.
- Stable X-Dograh-Delivery-Id / Workflow-Run-Id / Attempt headers let receivers
  dedupe retried deliveries.
- enqueue_job now forwards ARQ job options (_job_id, _defer_by); failures log
  repr(e) so empty-message errors like ConnectTimeout are diagnosable.

Config via DEFAULT_WEBHOOK_DELIVERY_CONFIG (env-overridable): max_attempts=5,
base_delay=30s, max_delay=600s, timeout=30s.

Tests cover payload rendering, persist+enqueue, success, transient retry,
retryable 5xx, permanent 4xx dead-letter, attempt exhaustion, and idempotency.
Migration verified to apply/rollback against Postgres; table/enum/indexes confirmed.

* fix(webhooks): atomic claim, safe success-recording, sweep paging, migration cleanup

Address review feedback on the webhook delivery pipeline:

- deliver_webhook now atomically claims a delivery (conditional UPDATE that
  leases scheduled_for) before sending, so concurrent ARQ executions can't
  double-send (the prior status=='pending' read was non-atomic).
- Recording success is moved out of the dead-letter try-block: if the receiver
  accepted the payload (2xx) but the success DB-write fails, the row is left
  pending for the sweeper to reconcile instead of being dead-lettered.
- The sweep keyset-paginates by id so a backlog over the page size is fully
  drained, and logs the true re-enqueued total.
- Migration downgrade drops the enum via op.execute(DROP TYPE IF EXISTS ...)
  instead of the deprecated op.get_bind().

* fix(webhooks): idempotent delivery creation and drop secret custom headers

Address the remaining review feedback:

- Add a (workflow_run_id, webhook_node_id) unique constraint and make
  create_webhook_delivery a get-or-create returning (delivery, created). A
  retried run_integrations now reuses the existing row instead of creating and
  sending a duplicate final webhook; only a freshly-created row is enqueued.
- Stop persisting secret-looking custom headers (Authorization, X-API-Key,
  Cookie, ...) in plaintext on the delivery row: they are dropped with a warning
  pointing at the credential store (which is re-resolved securely at send time).
  Non-secret custom headers are unaffected.

* fix(webhooks): harden idempotency key, secret-header match, sweep reclaim id

Address follow-up review feedback:

- webhook_node_id is now NOT NULL so a NULL can't slip past the
  (workflow_run_id, webhook_node_id) unique constraint and create duplicates.
- Secret-header filtering matches normalized markers (auth/token/secret/cookie/
  api-key/...) instead of an exact name list, catching variants like
  X-Custom-Auth-Token while leaving benign headers (e.g. X-Idempotency-Key).
- The sweeper re-enqueues with a reclaim-specific job id (the lease timestamp)
  so reconciling a delivered-but-unrecorded row isn't deduped against the
  original attempt's already-completed ARQ job. The atomic claim still ensures
  at most one send.

* fix(webhooks): scope delivery rows to workflow org

---------

Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
Tararais 2026-07-02 17:14:14 +01:00 committed by GitHub
parent 3a770a6538
commit fd0d144b08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1360 additions and 141 deletions

View file

@ -1022,6 +1022,103 @@ class ExternalCredentialModel(Base):
)
class WebhookDeliveryModel(Base):
"""Durable record of an outbound webhook delivery attempt.
Final webhooks (e.g. a workflow's "Final Webhook" node) must not be lost to a
single transient network error. Instead of firing the HTTP request inline and
forgetting it, we persist one row per webhook node per workflow run and let an
ARQ task drive delivery with bounded, backed-off retries. The row is the source
of truth: it survives worker restarts and a periodic sweeper re-enqueues any
``pending`` delivery whose ``scheduled_for`` is overdue. After ``max_attempts``
transient failures (or on a permanent 4xx) the row is parked as ``dead_letter``
for inspection rather than retried forever.
Mirrors the campaign retry pattern (``QueuedRunModel``): persisted state,
``scheduled_for`` gating, a hard attempt ceiling, and a terminal failure state.
"""
__tablename__ = "webhook_deliveries"
id = Column(Integer, primary_key=True, index=True)
# Stable idempotency key sent to the receiver so it can dedupe retries.
delivery_uuid = Column(
String(36),
unique=True,
nullable=False,
index=True,
default=lambda: str(uuid.uuid4()),
)
workflow_run_id = Column(
Integer,
ForeignKey("workflow_runs.id", ondelete="CASCADE"),
nullable=False,
)
organization_id = Column(
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
)
# Frozen request definition. The payload is rendered once at enqueue time so
# retries are deterministic. Secrets are NOT stored here: the auth header is
# re-resolved from ``credential_uuid`` at send time (honours rotation/revocation).
webhook_name = Column(String, nullable=True)
endpoint_url = Column(String, nullable=False)
http_method = Column(String, nullable=False, default="POST")
payload = Column(JSON, nullable=False, default=dict)
custom_headers = Column(JSON, nullable=True)
credential_uuid = Column(String(36), nullable=True)
# Workflow node that produced this delivery. Combined with workflow_run_id it
# is the per-run/per-node idempotency key, so a retried run_integrations does
# not create (and send) a duplicate delivery for the same node. Non-nullable:
# a NULL would be distinct under the unique constraint and defeat the dedupe.
webhook_node_id = Column(String, nullable=False)
status = Column(
Enum(
"pending",
"succeeded",
"dead_letter",
name="webhook_delivery_status",
),
nullable=False,
default="pending",
server_default="pending",
)
attempt_count = Column(Integer, nullable=False, default=0, server_default=text("0"))
max_attempts = Column(Integer, nullable=False, default=5, server_default=text("5"))
# When the next attempt becomes due. NULL once terminal (succeeded/dead_letter).
scheduled_for = Column(DateTime(timezone=True), nullable=True)
last_status_code = Column(Integer, nullable=True)
last_error = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
__table_args__ = (
# Sweeper lookup: due pending deliveries.
Index(
"idx_webhook_deliveries_pending_scheduled",
"scheduled_for",
postgresql_where=text("status = 'pending'"),
),
Index("idx_webhook_deliveries_run", "workflow_run_id"),
# Per-run/per-node idempotency: one delivery per webhook node per run.
UniqueConstraint(
"workflow_run_id",
"webhook_node_id",
name="uq_webhook_deliveries_run_node",
),
)
class ToolModel(Base):
"""Model for storing reusable tools that can be invoked during workflows.