Replace time.Sleep with a select on time.After and ctx.Done so webhook retries stop promptly when the request context is cancelled.
125 lines
2.6 KiB
Go
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
|
|
}
|