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 }