check / check (push) Waiting to run
Stopping the engine cancels the context every http and slack request is sent with. An attempt cut off that way was recorded as failed, which for a target with no retries, or on a last retry, ended the delivery failed and nothing sent it again. The shared retry core now records nothing for a failed attempt when the engine's context is cancelled: no result, no circuit breaker outcome, no status change, no count in the attempt metrics. The delivery stays pending or retrying and startup recovery sends it again. A target's own timeout is still a failed attempt. The README says what happens to an attempt in flight at a stop and no longer claims every attempt is recorded. Model: opus-5-5
311 lines
5.8 KiB
Go
311 lines
5.8 KiB
Go
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(
|
|
ctx, 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,
|
|
) {
|
|
recErr := t.eng.recordResult(
|
|
webhookDB, d, 1,
|
|
false, 0, "", err.Error(), 0,
|
|
)
|
|
if recErr != nil {
|
|
t.eng.bookkeepingFailed(d, recErr)
|
|
|
|
return
|
|
}
|
|
|
|
t.eng.settleStatus(
|
|
webhookDB, d, d.Target.Type,
|
|
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: maskURLError(err).Error(),
|
|
}
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", t.eng.userAgent())
|
|
|
|
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,
|
|
)
|
|
|
|
timestamp := "unknown"
|
|
if !event.CreatedAt.IsZero() {
|
|
timestamp = event.CreatedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
fmt.Fprintf(
|
|
&b,
|
|
"*Timestamp:* `%s`\n",
|
|
timestamp,
|
|
)
|
|
|
|
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")
|
|
}
|