Refactor delivery targets to a Target interface (closes #77)
All checks were successful
check / check (push) Successful in 5s

Each target TYPE is now an implementation of a Target interface,
dispatched from a registry in processDelivery instead of a type
switch on TargetType. Every target owns its full delivery,
including durable retries.

- Target.Deliver receives the context, the per-webhook DB, the
  Delivery, the attempt Task, and a Scheduler for durable
  re-enqueue (the existing timer + retry queue). The target makes
  one attempt, records the DeliveryResult, updates DeliveryStatus,
  and — for retry targets — decides whether to retry, computes its
  own backoff, gates with its own circuit breaker, and reschedules
  via the Scheduler.
- httpTarget and slackTarget share a retry core (retry, backoff,
  circuit breaker). database and log targets are fire-and-forget.
- Slack retry/breaker is gated on MaxRetries: 0 stays
  fire-and-forget (existing Slack targets unchanged), >0 gets
  retry + backoff + breaker on the shared core.
- The engine keeps only the worker pool, queue/channels, restart
  recovery/sweep, the recordResult/updateDeliveryStatus helpers,
  and ScheduleRetry. Recovery/sweep hand each orphaned retrying
  delivery back to its target to recompute the backoff.
- The log target logs the entire inbound webhook: full body and
  headers, method, content type, and the webhook and entrypoint
  ids (supersedes the smaller log-summary work).
- Task gains EntrypointID, populated in the webhook handler, the
  recovery-task builder, and buildEventFromTask.

Behaviour is preserved: existing delivery tests pass with their
export_test wrappers re-pointed at the new targets; new pure
Deliver tests cover the log full-content output and the gated
Slack retry path.
This commit is contained in:
2026-08-07 21:43:21 +07:00
parent 752d6beead
commit 7b1f997194
9 changed files with 1313 additions and 856 deletions

101
internal/delivery/target.go Normal file
View File

@@ -0,0 +1,101 @@
package delivery
import (
"context"
"net/http"
"time"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// Scheduler re-enqueues a task for a future delivery attempt.
// The engine provides one to each target so a target can own
// its retries durably: it records the attempt, marks the
// delivery retrying, and asks the Scheduler to deliver the
// next attempt after delay — exactly what the engine does for
// its own restart recovery.
type Scheduler interface {
ScheduleRetry(task Task, delay time.Duration)
}
// Target delivers an event to one target type. Each type is
// an implementation. A Target owns its whole delivery: it
// makes the attempt, records the DeliveryResult and updates
// the DeliveryStatus, and — for targets that retry — decides
// whether to retry, computes its own backoff, gates with its
// own circuit breaker, and reschedules via the injected
// Scheduler. Fire-and-forget targets simply record a single
// attempt.
type Target interface {
Deliver(
ctx context.Context,
webhookDB *gorm.DB,
d *database.Delivery,
task *Task,
sched Scheduler,
)
}
// rescheduler is implemented by targets that own durable
// retries. The engine's restart recovery and periodic sweep
// use it to let the target recompute the schedule for an
// orphaned retrying delivery, keeping the retry schedule
// target-owned. Fire-and-forget targets do not implement it
// and their (never-occurring) retrying deliveries are
// skipped.
type rescheduler interface {
// remainingBackoff returns how long to wait before the
// next attempt of a recovered retrying delivery.
remainingBackoff(
webhookDB *gorm.DB,
deliveryID string,
attemptNum int,
) time.Duration
// backoffElapsed reports whether the backoff window for
// the last attempt has already passed, so the periodic
// sweep can re-enqueue the delivery now.
backoffElapsed(
webhookDB *gorm.DB,
deliveryID string,
attemptNum int,
) bool
}
// attemptResult is the outcome of a single delivery attempt,
// as reported by a target's per-attempt function to the
// shared retry core.
type attemptResult struct {
statusCode int
respBody string
duration int64
success bool
errMsg string
}
// initTargets builds the target registry, wiring each target
// to the engine's persistence helpers and giving the HTTP and
// Slack targets the shared SSRF-safe client. It is called by
// both New and the test constructors so the registry is
// always populated.
func (e *Engine) initTargets(client *http.Client) {
httpT := &httpTarget{
httpCore: &httpCore{eng: e},
client: client,
}
slackT := &slackTarget{
httpCore: &httpCore{eng: e},
client: client,
}
e.httpTarget = httpT
e.targets = map[database.TargetType]Target{
database.TargetTypeHTTP: httpT,
database.TargetTypeSlack: slackT,
database.TargetTypeDatabase: &databaseTarget{eng: e},
database.TargetTypeLog: &logTarget{eng: e},
}
}