Refactor delivery targets to a Target interface (closes #77)
All checks were successful
check / check (push) Successful in 5s
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:
299
internal/delivery/target_slack.go
Normal file
299
internal/delivery/target_slack.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// errMissingWebhookURL is returned when a Slack target config
|
||||
// omits its webhook URL.
|
||||
var errMissingWebhookURL = errors.New(
|
||||
"webhook_url is required",
|
||||
)
|
||||
|
||||
// SlackTargetConfig holds configuration for slack target
|
||||
// types.
|
||||
type SlackTargetConfig struct {
|
||||
WebhookURL string `json:"webhookUrl"`
|
||||
}
|
||||
|
||||
// slackTarget delivers events to Slack incoming webhooks. It
|
||||
// formats the event into a Slack message and posts it as
|
||||
// JSON. It shares the retry core with the HTTP target: a
|
||||
// MaxRetries of 0 stays single-attempt fire-and-forget
|
||||
// (preserving existing Slack targets), while a positive
|
||||
// MaxRetries adds backoff and circuit breaking.
|
||||
type slackTarget struct {
|
||||
*httpCore
|
||||
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// Deliver implements Target.
|
||||
func (t *slackTarget) Deliver(
|
||||
ctx context.Context,
|
||||
webhookDB *gorm.DB,
|
||||
d *database.Delivery,
|
||||
task *Task,
|
||||
sched Scheduler,
|
||||
) {
|
||||
cfg, err := parseSlackConfig(d.Target.Config)
|
||||
if err != nil {
|
||||
t.eng.log.Error(
|
||||
"invalid Slack target config",
|
||||
"target_id", d.TargetID,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
t.failConfig(webhookDB, d, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
msg := FormatSlackMessage(&d.Event)
|
||||
|
||||
payload, err := json.Marshal(
|
||||
map[string]string{"text": msg},
|
||||
)
|
||||
if err != nil {
|
||||
t.eng.log.Error(
|
||||
"failed to marshal Slack payload",
|
||||
"target_id", d.TargetID,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
t.failConfig(webhookDB, d, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
attempt := func() attemptResult {
|
||||
return t.attempt(ctx, cfg, payload)
|
||||
}
|
||||
|
||||
t.deliver(
|
||||
webhookDB, d, task, sched,
|
||||
d.Target.MaxRetries, attempt,
|
||||
)
|
||||
}
|
||||
|
||||
// failConfig records a first-attempt failure for a delivery
|
||||
// that could not be prepared (bad config or unmarshalable
|
||||
// payload) and marks it failed.
|
||||
func (t *slackTarget) failConfig(
|
||||
webhookDB *gorm.DB,
|
||||
d *database.Delivery,
|
||||
err error,
|
||||
) {
|
||||
t.eng.recordResult(
|
||||
webhookDB, d, 1,
|
||||
false, 0, "", err.Error(), 0,
|
||||
)
|
||||
|
||||
t.eng.updateDeliveryStatus(
|
||||
webhookDB, d, database.DeliveryStatusFailed,
|
||||
)
|
||||
}
|
||||
|
||||
// attempt performs a single Slack POST and derives its
|
||||
// outcome, preserving the engine's original semantics: a
|
||||
// non-2xx response records an "HTTP <code>" error string and
|
||||
// a transport error records a "sending request" error.
|
||||
func (t *slackTarget) attempt(
|
||||
ctx context.Context,
|
||||
cfg *SlackTargetConfig,
|
||||
payload []byte,
|
||||
) attemptResult {
|
||||
start := time.Now()
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
cfg.WebhookURL,
|
||||
bytes.NewReader(payload),
|
||||
)
|
||||
if err != nil {
|
||||
return attemptResult{
|
||||
success: false,
|
||||
errMsg: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "webhooker/1.0")
|
||||
|
||||
resp, doErr := executeHTTPRequest(t.client, req)
|
||||
durationMs := time.Since(start).Milliseconds()
|
||||
|
||||
if doErr != nil {
|
||||
return attemptResult{
|
||||
success: false,
|
||||
duration: durationMs,
|
||||
errMsg: fmt.Errorf(
|
||||
"sending request: %w", doErr,
|
||||
).Error(),
|
||||
}
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
return t.readSlackResponse(resp, durationMs)
|
||||
}
|
||||
|
||||
func (t *slackTarget) readSlackResponse(
|
||||
resp *http.Response,
|
||||
durationMs int64,
|
||||
) attemptResult {
|
||||
body, readErr := io.ReadAll(
|
||||
io.LimitReader(resp.Body, maxBodyLog),
|
||||
)
|
||||
if readErr != nil {
|
||||
t.eng.log.Error(
|
||||
"failed to read Slack response body",
|
||||
"error", readErr,
|
||||
)
|
||||
}
|
||||
|
||||
success := resp.StatusCode >= httpSuccessMin &&
|
||||
resp.StatusCode < httpSuccessMax
|
||||
|
||||
errMsg := ""
|
||||
if !success {
|
||||
errMsg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return attemptResult{
|
||||
statusCode: resp.StatusCode,
|
||||
respBody: string(body),
|
||||
duration: durationMs,
|
||||
success: success,
|
||||
errMsg: errMsg,
|
||||
}
|
||||
}
|
||||
|
||||
func parseSlackConfig(
|
||||
configJSON string,
|
||||
) (*SlackTargetConfig, error) {
|
||||
if configJSON == "" {
|
||||
return nil, errEmptyTargetConfig
|
||||
}
|
||||
|
||||
var cfg SlackTargetConfig
|
||||
|
||||
err := json.Unmarshal(
|
||||
[]byte(configJSON), &cfg,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"parsing config JSON: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.WebhookURL == "" {
|
||||
return nil, errMissingWebhookURL
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// FormatSlackMessage builds a Slack-compatible message
|
||||
// string from a webhook event.
|
||||
func FormatSlackMessage(
|
||||
event *database.Event,
|
||||
) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("*Webhook Event Received*\n")
|
||||
|
||||
fmt.Fprintf(
|
||||
&b, "*Method:* `%s`\n", event.Method,
|
||||
)
|
||||
|
||||
fmt.Fprintf(
|
||||
&b,
|
||||
"*Content-Type:* `%s`\n",
|
||||
event.ContentType,
|
||||
)
|
||||
|
||||
fmt.Fprintf(
|
||||
&b,
|
||||
"*Timestamp:* `%s`\n",
|
||||
event.CreatedAt.UTC().Format(time.RFC3339),
|
||||
)
|
||||
|
||||
fmt.Fprintf(
|
||||
&b,
|
||||
"*Body Size:* %d bytes\n",
|
||||
len(event.Body),
|
||||
)
|
||||
|
||||
if event.Body == "" {
|
||||
b.WriteString("\n_(empty body)_\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
if formatted := formatJSONBody(event.Body); formatted != "" {
|
||||
b.WriteString(formatted)
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
formatRawBody(&b, event.Body)
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func formatJSONBody(body string) string {
|
||||
var parsed json.RawMessage
|
||||
if json.Unmarshal([]byte(body), &parsed) != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var pretty bytes.Buffer
|
||||
if json.Indent(&pretty, parsed, "", " ") != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("\n```\n")
|
||||
|
||||
prettyStr := pretty.String()
|
||||
|
||||
const maxPayloadDisplay = 3500
|
||||
if len(prettyStr) > maxPayloadDisplay {
|
||||
b.WriteString(prettyStr[:maxPayloadDisplay])
|
||||
b.WriteString("\n... (truncated)")
|
||||
} else {
|
||||
b.WriteString(prettyStr)
|
||||
}
|
||||
|
||||
b.WriteString("\n```\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func formatRawBody(b *strings.Builder, body string) {
|
||||
b.WriteString("\n```\n")
|
||||
|
||||
const maxRawDisplay = 3500
|
||||
if len(body) > maxRawDisplay {
|
||||
b.WriteString(body[:maxRawDisplay])
|
||||
b.WriteString("\n... (truncated)")
|
||||
} else {
|
||||
b.WriteString(body)
|
||||
}
|
||||
|
||||
b.WriteString("\n```\n")
|
||||
}
|
||||
Reference in New Issue
Block a user