2026-08-26 06:41:07 +08:00
|
|
|
package outbox
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"net"
|
2026-10-08 10:06:44 +08:00
|
|
|
"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 }
|
|
|
|
|
|
2026-10-08 10:06:44 +08:00
|
|
|
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"
|
|
|
|
|
}
|