All checks were successful
check / check (push) Successful in 2m42s
Refactors the delivery engine so each target TYPE is an implementation of a `Target` interface, dispatched from a registry, with each target owning its full delivery including durable retries. Implements the authoritative design from issue #77 (the corrected "hand the DB + Scheduler to the target" design). ## The new interface ```go type Scheduler interface { ScheduleRetry(task Task, delay time.Duration) } type Target interface { Deliver(ctx context.Context, webhookDB *gorm.DB, d *database.Delivery, task *Task, sched Scheduler) } ``` `Deliver` receives everything a target needs to be autonomous and durable: the request context, the per-webhook `*gorm.DB`, the `*database.Delivery`, the attempt `*Task`, and a `Scheduler` (the engine) for durable re-enqueue. The target makes one attempt, writes 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 injected `Scheduler`. `processDelivery` collapses to a registry lookup (`map[database.TargetType]Target`) and a `Deliver` call; an unknown target type still fails the delivery as before. ## Per-target ownership - `httpTarget` and `slackTarget` share a retry core (`httpCore`) that owns retry, exponential backoff, and the per-target circuit breaker. The core is fire-and-forget when `MaxRetries == 0` and adds breaker-gated backed-off retries when `MaxRetries > 0`. The per-attempt request differs (HTTP forwards the body + filtered headers; Slack posts a formatted message) and is supplied as a closure, so each keeps its exact recording semantics (e.g. HTTP records no error string for a non-2xx, Slack records `HTTP <code>`). - `databaseTarget` and `logTarget` are fire-and-forget: they record a single successful attempt. Moved wholesale into the http/slack targets: `deliverHTTP*`, `handleHTTPRetry`, `circuitBreakerBlock`, `calcBackoff` / `calcRemainingBackoff` / `backoffElapsed`, the circuit-breaker `sync.Map` + `getCircuitBreaker`, `clientForConfig`, `doHTTPRequest`, `applyRequestHeaders`, and the config parsers. The engine keeps `recordResult`, `updateDeliveryStatus`, and `ScheduleRetry`. ## Slack MaxRetries gating Slack is now on the same shared core as HTTP, with retry + breaker gated on `MaxRetries`. A `MaxRetries` of 0 stays single-attempt fire-and-forget, so **every existing Slack target is unchanged**; a Slack target configured with retries gets backoff + circuit breaker. ## Log-target full content `logTarget` now logs the ENTIRE inbound webhook — full request body and full request headers, plus method, content type, and the webhook id and entrypoint id — rather than a summary line. This supersedes the smaller log-summary work (#70). ## `Task.EntrypointID` To carry the entrypoint id to the log target, `Task` gains an `EntrypointID` field, populated in the webhook handler's `buildDeliveryTasks`, the engine's recovery-task builder, and `buildEventFromTask`. ## Durability / recovery The crash-durable async retry model is preserved unchanged: one attempt per worker turn; on failure the status is set `retrying`, backoff is computed, and the task is re-enqueued via `ScheduleRetry` (a `time.AfterFunc` onto the retry channel). On restart, `recoverRetryingDeliveries` and the 60s sweep hand each orphaned `retrying` delivery back to its target to recompute the remaining backoff and reschedule (targets that own retries implement an internal `rescheduler`; fire-and-forget targets, which never produce `retrying` deliveries, are skipped). ## How behaviour is preserved No external behaviour changes except the two called out above (log target full content; Slack gaining `MaxRetries`-gated retries). All existing delivery tests pass with only their `export_test.go` wrappers re-pointed at the new structure — `ExportDeliverHTTP/Slack/Database/Log` now call the targets, `ExportGetCircuitBreaker` / `ExportClient` / `ExportClientForConfig` / `ExportDoHTTPRequest` resolve against the HTTP target's shared client and breaker map, and `ExportParseHTTPConfig` / `ExportParseSlackConfig` call the relocated free functions. Added: a `logTarget` test asserting the log line contains the full body, headers, and ids, and a Slack `MaxRetries`-gated retry test. `docker build .` is green (fmt-check, lint, test, static build all pass). Closes #77 Co-authored-by: sneak <sneak@sneak.berlin> Reviewed-on: #81 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
102 lines
2.9 KiB
Go
102 lines
2.9 KiB
Go
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},
|
|
}
|
|
}
|