naco-api/internal/server/webhook.go
Nacorid e66900dc75
fix(server): abort webhook retry backoff when context is cancelled
Replace time.Sleep with a select on time.After and ctx.Done so webhook retries
stop promptly when the request context is cancelled.
2026-09-13 22:24:45 +00:00

125 lines
2.6 KiB
Go

package server
import (
"context"
"fmt"
"net/http"
"strings"
"time"
log "github.com/nacorid/logger"
"github.com/nacorid/naco-api/internal/config"
)
type WebhookSender struct {
cfg *config.Config
metrics *MetricsMiddleware
httpCli *http.Client
server string
url string
}
func newWebhookSender(cfg *config.Config, m *MetricsMiddleware) *WebhookSender {
if cfg.MatrixWebhookURL == "" {
return nil
}
return &WebhookSender{
cfg: cfg,
metrics: m,
httpCli: &http.Client{Timeout: 10 * time.Second},
server: cfg.MatrixServerID,
url: cfg.MatrixWebhookURL,
}
}
func (wh *WebhookSender) Send(ctx context.Context, jsonBytes []byte) {
if wh == nil {
return
}
if len(jsonBytes) == 0 {
log.ErrorWithContext(ctx, "webhook: empty payload ignored")
return
}
var lastErr error
for attempt := 1; attempt <= 5; attempt++ {
delay := exponentialBackoff(attempt)
if attempt > 1 {
select {
case <-time.After(delay):
case <-ctx.Done():
log.WarnWithContext(ctx, "webhook cancelled during backoff", "attempt", attempt, "error", ctx.Err())
return
}
}
err := wh.sendAttempt(ctx, jsonBytes, attempt)
if err == nil {
log.InfoWithContext(ctx,
"webhook succeeded",
"attempt", attempt,
"server", wh.server,
"url", maskURL(wh.url),
)
return
}
lastErr = err
log.WarnWithContext(ctx,
"webhook attempt failed",
"attempt", attempt,
"total_attempts", 5,
"server", wh.server,
"url", maskURL(wh.url),
"error", err,
)
}
log.ErrorWithContext(ctx,
"webhook failed after all attempts",
"total_attempts", 5,
"server", wh.server,
"url", maskURL(wh.url),
"error", lastErr,
)
}
func (wh *WebhookSender) sendAttempt(ctx context.Context, jsonBytes []byte, attempt int) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, wh.url, strings.NewReader(string(jsonBytes)))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "naco-api/"+wh.server)
resp, err := wh.httpCli.Do(req)
if err != nil {
return fmt.Errorf("do request: %w", err)
}
_ = resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
return fmt.Errorf("non-2xx status: %d", resp.StatusCode)
}
func exponentialBackoff(attempt int) time.Duration {
base := 500 * time.Millisecond
delay := base << (attempt - 1)
if delay > 30*time.Second {
delay = 30 * time.Second
}
return delay
}
func maskURL(url string) string {
if len(url) > 30 {
return url[:15] + "..." + url[len(url)-15:]
}
return url
}