mirror of
https://github.com/hanzo-fi/webhooks.git
synced 2026-10-30 20:32:55 +00:00
251 lines
8.2 KiB
Go
251 lines
8.2 KiB
Go
package webhooks
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzo-fi/go-libs/v2/logging"
|
|
"github.com/hanzo-fi/webhooks/pkg/metrics"
|
|
"github.com/hanzo-fi/webhooks/pkg/security"
|
|
"github.com/pkg/errors"
|
|
"github.com/uptrace/bun"
|
|
)
|
|
|
|
// maxResponseBody caps how much of an endpoint's response we read. The body is
|
|
// only used for debug logging / diagnostics, so there is no reason to let a
|
|
// malicious or buggy endpoint stream an unbounded payload into worker memory.
|
|
const maxResponseBody = 64 << 10 // 64 KiB
|
|
|
|
// isPermanentClientError reports whether an HTTP status code is a client error
|
|
// that will never succeed on retry. Retrying these (wrong credentials, gone,
|
|
// method not allowed, ...) only amplifies load — the root cause of the observed
|
|
// retry storms. 408 (Request Timeout) and 429 (Too Many Requests) are explicitly
|
|
// excluded: they are transient and worth retrying.
|
|
func isPermanentClientError(statusCode int) bool {
|
|
if statusCode < http.StatusBadRequest || statusCode >= http.StatusInternalServerError {
|
|
return false
|
|
}
|
|
switch statusCode {
|
|
case http.StatusRequestTimeout, http.StatusTooManyRequests:
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
// maxRetryAfterDelay clamps how far a Retry-After header can push the next
|
|
// retry: the endpoint controls this value, so an absurd one must not park a
|
|
// delivery years in the future.
|
|
const maxRetryAfterDelay = 6 * time.Hour
|
|
|
|
// parseRetryAfter interprets a Retry-After header (delay-seconds or HTTP-date)
|
|
// relative to now, clamped to maxRetryAfterDelay. It returns false when the
|
|
// header is absent or unparseable.
|
|
func parseRetryAfter(header string, now time.Time) (time.Duration, bool) {
|
|
header = strings.TrimSpace(header)
|
|
if header == "" {
|
|
return 0, false
|
|
}
|
|
if secs, err := strconv.Atoi(header); err == nil {
|
|
if secs < 0 {
|
|
return 0, false
|
|
}
|
|
return min(time.Duration(secs)*time.Second, maxRetryAfterDelay), true
|
|
}
|
|
if t, err := http.ParseTime(header); err == nil {
|
|
if d := t.Sub(now); d > 0 {
|
|
return min(d, maxRetryAfterDelay), true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
const (
|
|
StatusAttemptSuccess = "success"
|
|
StatusAttemptToRetry = "to retry"
|
|
StatusAttemptRetrying = "retrying"
|
|
StatusAttemptFailed = "failed"
|
|
)
|
|
|
|
type Attempt struct {
|
|
bun.BaseModel `bun:"table:attempts"`
|
|
|
|
ID string `json:"id" bun:",pk"`
|
|
WebhookID string `json:"webhookID" bun:"webhook_id"`
|
|
CreatedAt time.Time `json:"createdAt" bun:"created_at,nullzero,notnull,default:current_timestamp"`
|
|
UpdatedAt time.Time `json:"updatedAt" bun:"updated_at,nullzero,notnull,default:current_timestamp"`
|
|
Config Config `json:"config" bun:"type:jsonb"`
|
|
Payload string `json:"payload"`
|
|
StatusCode int `json:"statusCode" bun:"status_code"`
|
|
RetryAttempt int `json:"retryAttempt" bun:"retry_attempt"`
|
|
Status string `json:"status"`
|
|
NextRetryAfter time.Time `json:"nextRetryAfter,omitempty" bun:"next_retry_after,nullzero"`
|
|
}
|
|
|
|
type attemptOptions struct {
|
|
firstAttemptAt time.Time
|
|
}
|
|
|
|
type AttemptOption func(*attemptOptions)
|
|
|
|
// WithFirstAttemptAt lets retry callers enforce abort-after against the real
|
|
// elapsed time since the first delivery attempt, including prior Retry-After
|
|
// delays.
|
|
func WithFirstAttemptAt(firstAttemptAt time.Time) AttemptOption {
|
|
return func(opts *attemptOptions) {
|
|
if !firstAttemptAt.IsZero() {
|
|
opts.firstAttemptAt = firstAttemptAt.UTC()
|
|
}
|
|
}
|
|
}
|
|
|
|
func MakeAttempt(ctx context.Context, httpClient *http.Client, retryPolicy BackoffPolicy, id, webhookID string, attemptNb int, cfg Config, idempotencyKey string, payload []byte, isTest bool, opts ...AttemptOption) (Attempt, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.Endpoint, bytes.NewBuffer(payload))
|
|
if err != nil {
|
|
return Attempt{}, errors.Wrap(err, "http.NewRequestWithContext")
|
|
}
|
|
|
|
options := attemptOptions{}
|
|
for _, opt := range opts {
|
|
opt(&options)
|
|
}
|
|
|
|
requestTime := time.Now().UTC()
|
|
if options.firstAttemptAt.IsZero() {
|
|
options.firstAttemptAt = requestTime
|
|
}
|
|
|
|
timestamp := requestTime.Unix()
|
|
signature, err := security.Sign(webhookID, timestamp, cfg.Secret, payload)
|
|
if err != nil {
|
|
return Attempt{}, errors.Wrap(err, "security.Sign")
|
|
}
|
|
|
|
req.Header.Set("content-type", "application/json")
|
|
req.Header.Set("user-agent", "formance-webhooks/v0")
|
|
req.Header.Set("formance-webhook-id", webhookID)
|
|
req.Header.Set("formance-webhook-timestamp", fmt.Sprintf("%d", timestamp))
|
|
req.Header.Set("formance-webhook-signature", signature)
|
|
req.Header.Set("formance-webhook-test", fmt.Sprintf("%v", isTest))
|
|
if idempotencyKey != "" {
|
|
req.Header.Set("formance-webhook-idempotency-key", idempotencyKey)
|
|
}
|
|
|
|
start := time.Now()
|
|
resp, doErr := httpClient.Do(req)
|
|
decisionTime := time.Now().UTC()
|
|
|
|
attempt, err := classifyResponse(ctx, resp, doErr, retryPolicy, attemptNb, decisionTime, options.firstAttemptAt, Attempt{
|
|
ID: id,
|
|
WebhookID: webhookID,
|
|
Config: cfg,
|
|
Payload: string(payload),
|
|
RetryAttempt: attemptNb,
|
|
})
|
|
if err != nil {
|
|
return Attempt{}, err
|
|
}
|
|
|
|
metrics.RecordDelivery(ctx, attempt.Status, attempt.StatusCode, time.Since(start))
|
|
return attempt, nil
|
|
}
|
|
|
|
// classifyResponse turns the result of the delivery HTTP call into a persisted
|
|
// Attempt with its final status. base carries the identity fields already set by
|
|
// the caller; doErr is the transport error (if any).
|
|
func classifyResponse(ctx context.Context, resp *http.Response, doErr error, retryPolicy BackoffPolicy, attemptNb int, now, firstAttemptAt time.Time, base Attempt) (Attempt, error) {
|
|
attempt := base
|
|
|
|
if doErr != nil {
|
|
// Transport/timeout failure — return a retryable attempt so the record is
|
|
// persisted and the retry loop picks it up instead of losing the event.
|
|
attempt.StatusCode = 0
|
|
delay, delayErr := retryPolicy.GetRetryDelay(attemptNb)
|
|
if delayErr != nil {
|
|
attempt.Status = StatusAttemptFailed
|
|
return attempt, nil
|
|
}
|
|
nextRetryAfter := now.Add(delay)
|
|
if err := limitRetryWindow(retryPolicy, firstAttemptAt, nextRetryAfter); err != nil {
|
|
attempt.Status = StatusAttemptFailed
|
|
return attempt, nil
|
|
}
|
|
attempt.Status = StatusAttemptToRetry
|
|
attempt.NextRetryAfter = nextRetryAfter
|
|
return attempt, nil
|
|
}
|
|
|
|
defer func() {
|
|
if err := resp.Body.Close(); err != nil {
|
|
logging.FromContext(ctx).Error(
|
|
errors.Wrap(err, "http.Response.Body.Close"))
|
|
}
|
|
}()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
|
if err != nil {
|
|
return Attempt{}, errors.Wrap(err, "io.ReadAll")
|
|
}
|
|
logging.FromContext(ctx).Debugf("webhooks.MakeAttempt: server response body: %s", string(body))
|
|
|
|
attempt.StatusCode = resp.StatusCode
|
|
|
|
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
|
|
attempt.Status = StatusAttemptSuccess
|
|
return attempt, nil
|
|
}
|
|
|
|
// Permanent client errors will never succeed on retry — fail fast instead of
|
|
// retrying for the whole abort-after window.
|
|
if isPermanentClientError(resp.StatusCode) {
|
|
attempt.Status = StatusAttemptFailed
|
|
return attempt, nil
|
|
}
|
|
|
|
delay, err := retryPolicy.GetRetryDelay(attemptNb)
|
|
if err != nil {
|
|
attempt.Status = StatusAttemptFailed
|
|
return attempt, nil
|
|
}
|
|
|
|
// Respect an explicit Retry-After (429/503) when it asks us to wait longer
|
|
// than our computed backoff, but never let endpoint-controlled delays bypass
|
|
// the retry policy window.
|
|
if retryAfter, ok := parseRetryAfter(resp.Header.Get("Retry-After"), now); ok && retryAfter > delay {
|
|
if limiter, ok := retryPolicy.(RetryDelayLimiter); ok {
|
|
var limitErr error
|
|
retryAfter, limitErr = limiter.LimitRetryDelay(attemptNb, retryAfter)
|
|
if limitErr != nil {
|
|
attempt.Status = StatusAttemptFailed
|
|
return attempt, nil
|
|
}
|
|
}
|
|
delay = retryAfter
|
|
}
|
|
|
|
nextRetryAfter := now.Add(delay)
|
|
if err := limitRetryWindow(retryPolicy, firstAttemptAt, nextRetryAfter); err != nil {
|
|
attempt.Status = StatusAttemptFailed
|
|
return attempt, nil
|
|
}
|
|
attempt.Status = StatusAttemptToRetry
|
|
attempt.NextRetryAfter = nextRetryAfter
|
|
return attempt, nil
|
|
}
|
|
|
|
func limitRetryWindow(retryPolicy BackoffPolicy, firstAttemptAt, nextRetryAfter time.Time) error {
|
|
if firstAttemptAt.IsZero() {
|
|
return nil
|
|
}
|
|
if limiter, ok := retryPolicy.(RetryWindowLimiter); ok {
|
|
return limiter.LimitRetryWindow(nextRetryAfter.Sub(firstAttemptAt))
|
|
}
|
|
return nil
|
|
}
|