Files

92 lines
2.8 KiB
Go
Raw Permalink Normal View History

2026-08-26 06:41:07 +08:00
package outbox
import (
"context"
"errors"
"net"
"time"
2026-08-26 06:41:07 +08:00
"github.com/jackc/pgx/v5/pgconn"
)
// retryableError is the explicit boundary between a temporary dependency
// failure and a permanent job failure. Handlers must opt in by wrapping an
// error with Retryable unless the infrastructure error is independently
// recognizable as a timeout, network interruption or transient PostgreSQL
// condition.
type retryableError struct{ cause error }
func (e retryableError) Error() string { return e.cause.Error() }
func (e retryableError) Unwrap() error { return e.cause }
type retryAfterError struct {
cause error
delay time.Duration
}
func (e retryAfterError) Error() string { return e.cause.Error() }
func (e retryAfterError) Unwrap() error { return e.cause }
func (e retryAfterError) RetryAfter() time.Duration { return e.delay }
// RetryAfter requests a minimum delay for a retryable dependency failure.
// The worker still enforces its normal attempt limit and exponential backoff.
func RetryAfter(err error, delay time.Duration) error {
if err == nil {
return nil
}
return retryAfterError{cause: Retryable(err), delay: max(delay, RetryDelay(err))}
}
// RetryDelay finds the optional dependency backoff through wrapped errors.
func RetryDelay(err error) time.Duration {
var delayed interface{ RetryAfter() time.Duration }
if errors.As(err, &delayed) {
return max(delayed.RetryAfter(), 0)
}
return 0
}
2026-08-26 06:41:07 +08:00
// Retryable marks an error as safe to execute again with the same idempotency
// boundary. A nil error remains nil so callers can wrap return values directly.
func Retryable(err error) error {
if err == nil || IsRetryable(err) {
return err
}
return retryableError{cause: err}
}
// IsRetryable deliberately keeps the automatic allow-list narrow. Validation,
// unsupported state and malformed payload errors are permanent by default.
func IsRetryable(err error) bool {
if err == nil {
return false
}
var marked retryableError
if errors.As(err, &marked) {
return true
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return true
}
var networkError net.Error
if errors.As(err, &networkError) && (networkError.Timeout() || networkError.Temporary()) {
return true
}
if pgconn.SafeToRetry(err) {
return true
}
var postgresError *pgconn.PgError
if !errors.As(err, &postgresError) {
return false
}
// Connection, transaction rollback, resource exhaustion, lock-not-available
// and administrator restart conditions can succeed on a later attempt.
class := ""
if len(postgresError.Code) >= 2 {
class = postgresError.Code[:2]
}
return class == "08" || class == "40" || class == "53" ||
postgresError.Code == "55P03" || postgresError.Code == "57P01" ||
postgresError.Code == "57P02" || postgresError.Code == "57P03"
}