Resubmit a stored event as a new undelivered event, so backends can be tested against real captured traffic #250
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Testing a backend is a primary function of webhooker: capture real webhook traffic, then fire it at a backend under development and iterate. That workflow is not possible today.
Per-delivery replay (#203, landed in
3b0ed82) is built as recovery, not testing.replayTarget(webhook.ID, original.TargetID)only ever resolves the delivery's own original target, so a target created for a dev backend has no prior delivery and nothing can be replayed to it at all. Replay is also refused while an earlier replay to that target is in flight, and refused to an inactive target.Owner's ruling on the semantic wanted (2026-08-23): "submit the same hook event as a new undelivered event as if it just came in again."
That is event-level, not delivery-level: re-inject the stored event at the top of the receiver path and let it fan out to whatever targets are active now. No target picker is needed, because a newly added dev target is simply one of the active targets.
Definition of done
A Resubmit action on an event in the event log creates a NEW event that is byte-identical to the stored one and delivers it exactly as a freshly received webhook would be, to all currently active targets of that webhook.
method,headers,bodyandcontent_typefrom the stored event verbatim. The new event is a first-class event in the log, not a marker on the old one.pendingand handed to the engine through the sameNotifierthe receiver uses, so they are retried, SSRF-guarded and circuit-broken identically to a first delivery.Implementation requirements
POST /source/{id}/events/{eventID}/resubmit. Confirm the group placement againstinternal/server/routes.gorather than assuming.internal/handlers/webhook.go—buildDeliveryTasks, the transaction helper, and the active-target lookup. FactorbuildEventso the receiver and the resubmit path share one construction site. Do NOT fork the fan-out logic; two copies of it will diverge.inlineBodyonly inlines bodies underdelivery.MaxInlineBodySize; a larger stored body must still be loaded and resubmitted intact. Prove this with a test using a body over that limit.ReplayRateLimit()ininternal/middleware/ratelimit.go. It queues outbound work from a page action.internal/metrics/metrics.go. Label it with the route pattern, never a concrete path.Also fix in this unit, same doc section
The README currently states in four places that replay is unimplemented, which is false since
3b0ed82and is why this gap went unreported. Correct all four and document resubmit alongside replay:README.md:906-909"Replay (not yet implemented) ... No redelivery exists today, in the web UI or the API"README.md:919"Replay of webhook events ... (planned; not yet implemented)"README.md:1227"for the planned replay capability"README.md:1544tells the operator a delivery stranded by a target type change cannot be redelivered and that "manual redelivery is planned, not implemented". This one is actively harmful: that delivery is markedfailed, andREADME.md:1269says afaileddelivery IS replayable.Verification
make checkgreen.delivery.MaxInlineBodySizesurvives resubmission byte-identically.Implementation plan.
Shared construction and fan-out (no fork of the receiver logic): introduce an
eventSourcevalue ininternal/handlers/webhook.gocarrying webhook id, entrypoint id, method, headers JSON, content type, body bytes and the optional source event id. The receiver fills it from the live request; resubmit fills it from the stored event. Both then call onecreateAndFanOut(src, targets)that opens the per-webhook transaction, creates the event, runs the existingbuildDeliveryTasks, commits and returns the tasks.buildDeliveryTasksloses itshttp.ResponseWriterand returns an error instead, so both callers can map failures to their own response shape.Targets:
loadActiveTargets(webhook.ID)— the receiver's own query, resolved fresh. The old event's deliveries are never consulted. No in-flight refusal is ported, so repeated resubmits all queue.Body: the stored event is read once, before the transaction, with
cast(body as blob)so the bytes come back as bytes (same technique as the event log projection and the body download). Inline-vs-not is decided by the existinginlineBody; overdelivery.MaxInlineBodySizethe task carries a nil body and the engine loads it from the NEW event row, which holds the copied bytes.Provenance: nullable
resubmitted_from_idonEventviaAutoMigrate. The event log projection selects it, and one grouped query per page counts events resubmitted FROM each listed event, so the page shows both directions.Route:
POST /source/{sourceID}/events/{eventID}/resubmitin the/source/{sourceID}group ininternal/server/routes.go(auth, CSRF, NoCache, body cap), attached withr.With(m.ResubmitRateLimit())mirroring the replay route. New rate limiter and a new metrics counter alongside the replay one. Per-delivery replay is untouched.Docs: all four README claims corrected, with replay (one finished delivery, its own target, recovery) and resubmit (the event, all currently active targets, testing) documented side by side.
Built in #251, branch
issue-250-event-resubmit, basenext.POST /source/{sourceID}/events/{eventID}/resubmitstores a new event copying the stored one'smethod,headers,bodyandcontent_typeverbatim and fans it out to the currently active targets, resolved fresh by the receiver's query. The receiver and this path share one construction and one fan-out site:eventSourcesays where the fields came from,createAndFanOutwrites the event and its pending deliveries in one transaction and notifies the engine. No in-flight refusal. Nullableresubmitted_from_idcarries provenance and the event log shows it in both directions.Verified:
make checkgreen in 70s withGOFLAGS=-count=1, so nothing came from the test cache (internal/handlers20.1s, lint0 issues). Seven new tests, including a target created AFTER the event receiving it, five back-to-back resubmits all queueing, and a 40 KB body — over the inline limit, containing a NUL and an invalid UTF-8 byte — surviving byte-identically.End to end against a running instance: a data dir populated by a binary built from
nextata83e8femigrated cleanly (column and index added, existing event intact, sha256 unchanged); a target created after the capture then received two resubmits, and the sink's received bodies matched the sent body's sha256 exactly. The original event had zero deliveries throughout, which is the case replay cannot serve.One deviation, noted in the PR: the new counter is unlabelled rather than labelled with the route pattern, since that label would have exactly one value.