Resubmit a stored event as a new undelivered event, so backends can be tested against real captured traffic #250

Closed
opened 2026-08-24 00:23:54 +02:00 by clawbot · 2 comments
Collaborator

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.

  • New event row, copying method, headers, body and content_type from the stored event verbatim. The new event is a first-class event in the log, not a marker on the old one.
  • Fan-out resolves the webhook's currently ACTIVE targets fresh, by the same query the receiver uses. A target created after the original event arrived MUST receive the resubmitted event. The original event's deliveries and their targets are irrelevant to which targets are selected.
  • New deliveries are created pending and handed to the engine through the same Notifier the receiver uses, so they are retried, SSRF-guarded and circuit-broken identically to a first delivery.
  • Repeatable: resubmitting the same event many times in a row MUST work. Do not port replay's in-flight refusal. Firing the same captured event repeatedly while iterating on a backend is the point of the feature.
  • Inactive targets are skipped, exactly as the receiver skips them. Not an error.
  • Provenance is recorded and shown: the new event stores the id of the event it was resubmitted from (nullable column; a normally received event has it empty), and the event log shows both that an event was resubmitted from another and that an event has been resubmitted. Without this the log becomes untraceable once an operator fires the same event twenty times.
  • Inbound signature verification is NOT re-run. There is no inbound signature to check; the action is authenticated and CSRF-protected as an operator action. State this in a comment so it is not later mistaken for a bypass.
  • The response body and headers of the ORIGINAL delivery are never re-sent. What is re-injected is the stored EVENT.

Implementation requirements

  • Route under the existing owned-source group so auth, CSRF and the body cap already apply: POST /source/{id}/events/{eventID}/resubmit. Confirm the group placement against internal/server/routes.go rather than assuming.
  • Reuse the receiver's own helpers in internal/handlers/webhook.gobuildDeliveryTasks, the transaction helper, and the active-target lookup. Factor buildEvent so the receiver and the resubmit path share one construction site. Do NOT fork the fan-out logic; two copies of it will diverge.
  • Handle the non-inline body case. inlineBody only inlines bodies under delivery.MaxInlineBodySize; a larger stored body must still be loaded and resubmitted intact. Prove this with a test using a body over that limit.
  • Rate limit the route, mirroring ReplayRateLimit() in internal/middleware/ratelimit.go. It queues outbound work from a page action.
  • Add a metrics counter alongside the existing replay counter in internal/metrics/metrics.go. Label it with the route pattern, never a concrete path.
  • Keep per-delivery replay exactly as it is. It serves recovery — re-sending one failed delivery without disturbing its siblings — which resubmit does not replace.

Also fix in this unit, same doc section

The README currently states in four places that replay is unimplemented, which is false since 3b0ed82 and 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:1544 tells 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 marked failed, and README.md:1269 says a failed delivery IS replayable.

Verification

  • make check green.
  • A test proving a target created AFTER the original event receives the resubmitted event. This is the core of the feature and the thing per-delivery replay cannot do.
  • A test proving repeated resubmits of one event all succeed and each produces its own event and deliveries.
  • A test proving a body over delivery.MaxInlineBodySize survives resubmission byte-identically.
  • A test proving an inactive target is skipped rather than erroring.
  • Evidence in the PR body that resubmit was exercised against a running instance with a real sink, showing the sink received the re-injected body byte-identically.
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 (https://git.eeqj.de/sneak/webhooker/issues/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. - New event row, copying `method`, `headers`, `body` and `content_type` from the stored event verbatim. The new event is a first-class event in the log, not a marker on the old one. - Fan-out resolves the webhook's currently ACTIVE targets fresh, by the same query the receiver uses. A target created after the original event arrived MUST receive the resubmitted event. The original event's deliveries and their targets are irrelevant to which targets are selected. - New deliveries are created `pending` and handed to the engine through the same `Notifier` the receiver uses, so they are retried, SSRF-guarded and circuit-broken identically to a first delivery. - Repeatable: resubmitting the same event many times in a row MUST work. Do not port replay's in-flight refusal. Firing the same captured event repeatedly while iterating on a backend is the point of the feature. - Inactive targets are skipped, exactly as the receiver skips them. Not an error. - Provenance is recorded and shown: the new event stores the id of the event it was resubmitted from (nullable column; a normally received event has it empty), and the event log shows both that an event was resubmitted from another and that an event has been resubmitted. Without this the log becomes untraceable once an operator fires the same event twenty times. - Inbound signature verification is NOT re-run. There is no inbound signature to check; the action is authenticated and CSRF-protected as an operator action. State this in a comment so it is not later mistaken for a bypass. - The response body and headers of the ORIGINAL delivery are never re-sent. What is re-injected is the stored EVENT. ## Implementation requirements - Route under the existing owned-source group so auth, CSRF and the body cap already apply: `POST /source/{id}/events/{eventID}/resubmit`. Confirm the group placement against `internal/server/routes.go` rather than assuming. - Reuse the receiver's own helpers in `internal/handlers/webhook.go` — `buildDeliveryTasks`, the transaction helper, and the active-target lookup. Factor `buildEvent` so the receiver and the resubmit path share one construction site. Do NOT fork the fan-out logic; two copies of it will diverge. - Handle the non-inline body case. `inlineBody` only inlines bodies under `delivery.MaxInlineBodySize`; a larger stored body must still be loaded and resubmitted intact. Prove this with a test using a body over that limit. - Rate limit the route, mirroring `ReplayRateLimit()` in `internal/middleware/ratelimit.go`. It queues outbound work from a page action. - Add a metrics counter alongside the existing replay counter in `internal/metrics/metrics.go`. Label it with the route pattern, never a concrete path. - Keep per-delivery replay exactly as it is. It serves recovery — re-sending one failed delivery without disturbing its siblings — which resubmit does not replace. ## Also fix in this unit, same doc section The README currently states in four places that replay is unimplemented, which is false since `3b0ed82` and 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:1544` tells 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 marked `failed`, and `README.md:1269` says a `failed` delivery IS replayable. ## Verification - `make check` green. - A test proving a target created AFTER the original event receives the resubmitted event. This is the core of the feature and the thing per-delivery replay cannot do. - A test proving repeated resubmits of one event all succeed and each produces its own event and deliveries. - A test proving a body over `delivery.MaxInlineBodySize` survives resubmission byte-identically. - A test proving an inactive target is skipped rather than erroring. - Evidence in the PR body that resubmit was exercised against a running instance with a real sink, showing the sink received the re-injected body byte-identically.
clawbot added this to the 1.0.0 milestone 2026-08-24 00:23:57 +02:00
Author
Collaborator

Implementation plan.

Shared construction and fan-out (no fork of the receiver logic): introduce an eventSource value in internal/handlers/webhook.go carrying 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 one createAndFanOut(src, targets) that opens the per-webhook transaction, creates the event, runs the existing buildDeliveryTasks, commits and returns the tasks. buildDeliveryTasks loses its http.ResponseWriter and 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 existing inlineBody; over delivery.MaxInlineBodySize the task carries a nil body and the engine loads it from the NEW event row, which holds the copied bytes.

Provenance: nullable resubmitted_from_id on Event via AutoMigrate. 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}/resubmit in the /source/{sourceID} group in internal/server/routes.go (auth, CSRF, NoCache, body cap), attached with r.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.

Implementation plan. Shared construction and fan-out (no fork of the receiver logic): introduce an `eventSource` value in `internal/handlers/webhook.go` carrying 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 one `createAndFanOut(src, targets)` that opens the per-webhook transaction, creates the event, runs the existing `buildDeliveryTasks`, commits and returns the tasks. `buildDeliveryTasks` loses its `http.ResponseWriter` and 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 existing `inlineBody`; over `delivery.MaxInlineBodySize` the task carries a nil body and the engine loads it from the NEW event row, which holds the copied bytes. Provenance: nullable `resubmitted_from_id` on `Event` via `AutoMigrate`. 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}/resubmit` in the `/source/{sourceID}` group in `internal/server/routes.go` (auth, CSRF, NoCache, body cap), attached with `r.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.
Author
Collaborator

Built in #251, branch issue-250-event-resubmit, base next.

POST /source/{sourceID}/events/{eventID}/resubmit stores a new event copying the stored one's method, headers, body and content_type verbatim 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: eventSource says where the fields came from, createAndFanOut writes the event and its pending deliveries in one transaction and notifies the engine. No in-flight refusal. Nullable resubmitted_from_id carries provenance and the event log shows it in both directions.

Verified: make check green in 70s with GOFLAGS=-count=1, so nothing came from the test cache (internal/handlers 20.1s, lint 0 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 next at a83e8fe migrated 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.

Built in https://git.eeqj.de/sneak/webhooker/pulls/251, branch `issue-250-event-resubmit`, base `next`. `POST /source/{sourceID}/events/{eventID}/resubmit` stores a new event copying the stored one's `method`, `headers`, `body` and `content_type` verbatim 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: `eventSource` says where the fields came from, `createAndFanOut` writes the event and its pending deliveries in one transaction and notifies the engine. No in-flight refusal. Nullable `resubmitted_from_id` carries provenance and the event log shows it in both directions. Verified: `make check` green in 70s with `GOFLAGS=-count=1`, so nothing came from the test cache (`internal/handlers` 20.1s, lint `0 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 `next` at `a83e8fe` migrated 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.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/webhooker#250