Repository navigation
feat: durable hitl webhook delivery on restate with a 24h retry window and dead letter - #379
Conversation
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
fuse | b25c84c | Commit Preview URL Branch Preview URL |
Nov 15 2026, 07:38 AM |
| go fm.webhooks.deliver(fm, t, n) | ||
| return | ||
| } | ||
| fm.webhookPending(d, true) |
There was a problem hiding this comment.
Finished deliveries remain counted
With load-balanced Restate Cloud replicas, notifyWebhook adds a delivery to the sending process's backlog, but another replica can finish it. WebhookPost and WebhookDeadLetter only remove entries from their own process, so the sender keeps counting that delivery forever. Later retries can leave entries on several replicas.
Count pending deliveries in shared state, or use a measure that does not require the same process to see both ends.
| n := len(ids) | ||
| fm.webhookMu.Unlock() | ||
| if m, ok := fm.metrics.(WebhookMetrics); ok { | ||
| m.WebhookBacklog(d.Endpoint, n) |
There was a problem hiding this comment.
Older counts overwrite newer ones
webhookPending releases webhookMu before publishing the count. Concurrent updates can publish in reverse order: an addition computes 1, a completion publishes 0, then the addition publishes its stale 1. The gauge stays wrong until another update, even with one process.
Keep the count change and its publication in the same locked section.
| if err := hooks.SendWebhook(ctx, d); err != nil { | ||
| fm.logger.Error("send durable hitl webhook failed, delivering in process", "endpoint", d.Endpoint, "event", n.Event, "intervention", n.InterventionID, "err", err) | ||
| fm.webhookPending(d, false) | ||
| fm.webhooks.deliver(fm, t, n) |
There was a problem hiding this comment.
Fallback retries block other notifications
A failed SendWebhook keeps its send slot while fm.webhooks.deliver runs all four fallback attempts. With a down receiver, that can take about 61 seconds beyond the ingress timeout. Eight such failures block every later notification from trying Restate or its fallback, including notifications for healthy receivers.
Release the send slot before running the fallback.
7e52aee to
b05cdb8
Compare
…dpoint sealed, a durable hook and a dead letter
…retrying by delayed self-sends within a 24h window
…letter metrics by endpoint host
…eceiver down and a journal read for the url and secret
…ournal and metrics
…r lands after a newer one
…s retries do not hold up other notifications
b05cdb8 to
b25c84c
Compare
Runs hitl webhook deliveries on restate, so a notification survives an orchestrator restart and a receiver that is down for hours, and delivery state is visible. #4 of the restate stack (#346 -> #345 -> #349 -> #348 -> #342).
Webhooks.Deliver(a plain service, not the Environment object): one invocation is one attempt at one delivery to one endpoint.recordAuditenqueues it with a one-way ingress send instead ofgo deliver(sends run off the request goroutine, at most 8 in flight, keeping the request id).WithDelay(1s, then x4, capped at 1h) and ends, so a delivery failing for a day is a chain of short invocations, each on the deployment current when it starts (epic invariant 4). once the next attempt would land more than 24h after the event, it writes ahitl_webhookdead letter keyed by delivery id and ends. a receiver's non-2xx is a journaled attempt result, not a step error, so theWEBHOOKSretry policy only covers restate failing to run an attempt.TOKEN_ENCRYPTION_KEY. the url is sealed too, since a webhook url can carry a token in its path or query (slack-style). the post step unseals, signs at send time and posts; its journaled result is status + error with the url stripped (*url.Errorreduced to its cause). an endpoint that no longer decrypts is dead-lettered at once.RotatedCredentials.FUSE_RESTATE_MODE=offkeeps the in-process 0/1/4/16s goroutine (removal is [restate] make restate required and remove the in-process op paths #353). with restate on but noTOKEN_ENCRYPTION_KEY, or a send restate does not take, the delivery also goes in process.orchestrator_hitl_webhook_attempts_total,_failures_total,_last_status(0 = no answer) in both modes;_backlogand_dead_letters_totalwith restate on. the backlog is per process: after a restart a delivery waiting out its backoff rejoins it on its next attempt. the legacy path's failure log now names the host, not the full url.intervention_id+event(documented).No api surface change (fields, states, status codes, messages), so no sdk/openapi changes. Docs:
docs/content/docs/guides/human-in-the-loop.mdx(delivery semantics in both modes),docs/content/docs/cloud/monitoring.mdx(webhook metrics + dead-letter alert),docs/content/docs/cloud/orchestrator-deploy.mdx(WEBHOOKSretry service, what runs on restate),docs/runbooks/restate.mdx(handler, chain, journal and pending queries). Checked and left alone: openapi and the api reference (no change),cloud/security.mdx.verified locally
gofmt -l .,go build ./...,go vet ./..., golangci-lint v2.12.2 (0 issues),go test ./... -count=1,-raceon durable/orchestrator/metrics.sys_journaljoined tosys_invocation, narrowed to invocations carrying the delivery id), asserts it holds the delivery id, intervention id and the sealed endpoint (not vacuous), and that it holds neither the url's path/query token nor the signing secret; putting the url in a journaled field makes it fail.bun run build;bun run lintfails only on pre-existing biome formatting in files this PR does not touch.not run
kill -9of the orchestrator with a receiver down, then the receiver back, andrestate sqlover a real delivery's journal.WEBHOOKSinvocation policy (10 attempts) while a dead-letter write is failing pauses that invocation, as for the other services.Stacked on #378.
Closes #348
The PR appears safe to merge, with non-blocking fixes recommended for backlog metrics and delays during fallback.
Summary
Moves HITL webhook delivery onto Restate with sealed endpoints, delayed retries, and dead letters.
Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Audit event] --> B{Restate and encryption key?} B -->|No| C[In-process retries] B -->|Yes| D[Seal endpoint and enqueue] D -->|Send fails| C D --> E[Webhooks.Deliver] E --> F[Unseal, sign, and post] F -->|Delivered| G[Finish] F -->|Failed within retry window| H[Send delayed next attempt] H --> E F -->|Window ended or endpoint unreadable| I[Write dead letter]Reviews (1) · Last reviewed commit: "docs: durable hitl webhook delivery, its..." · Reviewed by Greptile