package resilience import ( "context" "errors" "fmt" "log/slog" "net" "strings" "time" "github.com/aws/smithy-go" "github.com/sony/gobreaker/v2" "wucher/internal/config" ) type Classification int const ( ClassificationFailure Classification = iota ClassificationExcluded ) type ErrorClassifier func(error) Classification type Executor struct { name string breaker *gobreaker.CircuitBreaker[any] classifier ErrorClassifier } type OpenError struct { Dependency string Err error } func (e *OpenError) Error() string { if e == nil { return "" } return fmt.Sprintf("%s circuit breaker: %v", e.Dependency, e.Err) } func (e *OpenError) Unwrap() error { if e == nil { return nil } return e.Err } type HTTPStatusError struct { Operation string StatusCode int Body string } func (e *HTTPStatusError) Error() string { if e == nil { return "" } body := strings.TrimSpace(e.Body) if len(body) > 256 { body = body[:256] } if body == "" { return fmt.Sprintf("%s: status=%d", e.Operation, e.StatusCode) } return fmt.Sprintf("%s: status=%d body=%s", e.Operation, e.StatusCode, body) } type classifiedError struct { err error excluded bool } func (e *classifiedError) Error() string { if e == nil || e.err == nil { return "" } return e.err.Error() } func (e *classifiedError) Unwrap() error { if e == nil { return nil } return e.err } func NewExecutor(name string, policy config.CircuitBreakerPolicy, logger *slog.Logger, classifier ErrorClassifier) *Executor { policy = normalizePolicy(policy) if strings.TrimSpace(name) == "" { name = "dependency" } executor := &Executor{ name: name, classifier: classifier, } if !policy.Enabled { return executor } if logger == nil { logger = slog.Default() } cbLogger := logger.With("component", "circuit_breaker", "dependency", name) settings := gobreaker.Settings{ Name: name, MaxRequests: policy.MaxRequests, Interval: policy.Interval, BucketPeriod: policy.BucketPeriod, Timeout: policy.Timeout, ReadyToTrip: func(counts gobreaker.Counts) bool { if policy.ConsecutiveFailures > 0 && counts.ConsecutiveFailures >= policy.ConsecutiveFailures { return true } total := counts.TotalSuccesses + counts.TotalFailures if total < policy.MinRequests || policy.FailureRatio <= 0 { return false } return float64(counts.TotalFailures)/float64(total) >= policy.FailureRatio }, OnStateChange: func(name string, from, to gobreaker.State) { level := slog.LevelInfo if to == gobreaker.StateOpen { level = slog.LevelWarn } cbLogger.Log(context.Background(), level, "state changed", "from", from.String(), "to", to.String(), ) }, IsSuccessful: func(err error) bool { return err == nil }, IsExcluded: func(err error) bool { var classified *classifiedError return errors.As(err, &classified) && classified.excluded }, } executor.breaker = gobreaker.NewCircuitBreaker[any](settings) return executor } func (e *Executor) Enabled() bool { return e != nil && e.breaker != nil } func (e *Executor) Counts() gobreaker.Counts { if e == nil || e.breaker == nil { return gobreaker.Counts{} } return e.breaker.Counts() } func (e *Executor) Execute(ctx context.Context, fn func(context.Context) error) error { _, err := Do(ctx, e, func(ctx context.Context) (struct{}, error) { return struct{}{}, fn(ctx) }) return err } func Do[T any](ctx context.Context, executor *Executor, fn func(context.Context) (T, error)) (T, error) { var zero T if executor == nil || executor.breaker == nil { return fn(ctx) } value, err := executor.breaker.Execute(func() (any, error) { result, err := fn(ctx) if err != nil { if executor.classifier != nil && executor.classifier(err) == ClassificationExcluded { return nil, &classifiedError{err: err, excluded: true} } return nil, &classifiedError{err: err} } return any(result), nil }) if err != nil { if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) { return zero, &OpenError{Dependency: executor.name, Err: err} } var classified *classifiedError if errors.As(err, &classified) { return zero, classified.err } return zero, err } if value == nil { return zero, nil } typed, ok := value.(T) if !ok { return zero, fmt.Errorf("%s circuit breaker returned unexpected type %T", executor.name, value) } return typed, nil } func ClassifyMicrosoftSSOError(err error) Classification { switch { case err == nil: return ClassificationExcluded case errors.Is(err, context.Canceled): return ClassificationExcluded case errors.Is(err, context.DeadlineExceeded): return ClassificationFailure } var netErr net.Error if errors.As(err, &netErr) { return ClassificationFailure } var statusErr *HTTPStatusError if errors.As(err, &statusErr) { if statusErr.StatusCode == 429 || statusErr.StatusCode >= 500 { return ClassificationFailure } return ClassificationExcluded } return ClassificationFailure } func ClassifySESError(err error) Classification { switch { case err == nil: return ClassificationExcluded case errors.Is(err, context.Canceled): return ClassificationExcluded case errors.Is(err, context.DeadlineExceeded): return ClassificationFailure } var netErr net.Error if errors.As(err, &netErr) { return ClassificationFailure } var apiErr smithy.APIError if errors.As(err, &apiErr) { code := strings.ToLower(strings.TrimSpace(apiErr.ErrorCode())) if apiErr.ErrorFault() == smithy.FaultServer || isThrottleCode(code) { return ClassificationFailure } return ClassificationExcluded } message := strings.ToLower(strings.TrimSpace(err.Error())) if strings.Contains(message, "ses is not configured") || strings.Contains(message, "recipient is required") || strings.Contains(message, "invalid email job") { return ClassificationExcluded } if strings.Contains(message, "eof") { return ClassificationFailure } return ClassificationExcluded } func ClassifySQSError(err error) Classification { switch { case err == nil: return ClassificationExcluded case errors.Is(err, context.Canceled): return ClassificationExcluded case errors.Is(err, context.DeadlineExceeded): return ClassificationFailure } var netErr net.Error if errors.As(err, &netErr) { return ClassificationFailure } var apiErr smithy.APIError if errors.As(err, &apiErr) { code := strings.ToLower(strings.TrimSpace(apiErr.ErrorCode())) if apiErr.ErrorFault() == smithy.FaultServer || isThrottleCode(code) { return ClassificationFailure } return ClassificationExcluded } return ClassificationFailure } func normalizePolicy(policy config.CircuitBreakerPolicy) config.CircuitBreakerPolicy { if policy.MaxRequests == 0 { policy.MaxRequests = 1 } if policy.Interval <= 0 { policy.Interval = time.Minute } if policy.BucketPeriod <= 0 { policy.BucketPeriod = 10 * time.Second } if policy.Timeout <= 0 { policy.Timeout = 30 * time.Second } if policy.MinRequests == 0 { policy.MinRequests = 5 } if policy.FailureRatio <= 0 || policy.FailureRatio > 1 { policy.FailureRatio = 0.6 } if policy.ConsecutiveFailures == 0 { policy.ConsecutiveFailures = 5 } return policy } func isThrottleCode(code string) bool { switch code { case "throttling", "throttlingexception", "requestthrottled", "toomanyrequestsexception", "slowdown": return true default: return false } }