package outbox import ( "context" "errors" "net" "time" "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 } // 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" }