From cc98b51859c6e8ee0e24c167c2170ea7d39706a8 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Wed, 5 Aug 2026 09:29:05 +0530 Subject: [PATCH 1/3] feat(billing): add stripe error translator Add a billing/errors package that classifies stripe errors into typed provider errors: resource_missing means the record is gone on the provider, card errors mean the payment failed, and rate limits or stripe outages mean the provider is unavailable. The translated error keeps stripe's human-readable message and the original error chain. Part of #1836. Co-Authored-By: Claude Fable 5 --- billing/errors/errors.go | 59 +++++++++++++++++++++ billing/errors/errors_test.go | 99 +++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 billing/errors/errors.go create mode 100644 billing/errors/errors_test.go diff --git a/billing/errors/errors.go b/billing/errors/errors.go new file mode 100644 index 0000000000..ec9e50b8ac --- /dev/null +++ b/billing/errors/errors.go @@ -0,0 +1,59 @@ +package errors + +import ( + "errors" + "net/http" + + stripe "github.com/stripe/stripe-go/v79" +) + +var ( + ErrProviderResourceMissing = errors.New("record no longer exists on the billing provider") + ErrPaymentFailed = errors.New("payment failed") + ErrProviderUnavailable = errors.New("billing provider is unavailable") +) + +// ProviderError is a billing provider failure classified as one of the +// Err* kinds above. Message carries the provider's human-readable message. +type ProviderError struct { + Kind error + Message string + cause error +} + +func (e *ProviderError) Error() string { + if e.Message == "" { + return e.Kind.Error() + } + return e.Kind.Error() + ": " + e.Message +} + +func (e *ProviderError) Unwrap() []error { + return []error{e.Kind, e.cause} +} + +// TranslateStripeError converts a stripe error into a *ProviderError so +// callers can match it with errors.Is against the Err* kinds. Errors that +// don't match a known kind are returned unchanged. +func TranslateStripeError(err error) error { + var stripeErr *stripe.Error + if err == nil || !errors.As(err, &stripeErr) { + return err + } + + var kind error + switch { + case stripeErr.Code == stripe.ErrorCodeResourceMissing: + kind = ErrProviderResourceMissing + case stripeErr.Type == stripe.ErrorTypeCard || stripeErr.DeclineCode != "": + kind = ErrPaymentFailed + case stripeErr.Code == stripe.ErrorCodeRateLimit, + stripeErr.Type == stripe.ErrorTypeAPI, + stripeErr.HTTPStatusCode == http.StatusTooManyRequests, + stripeErr.HTTPStatusCode >= http.StatusInternalServerError: + kind = ErrProviderUnavailable + default: + return err + } + return &ProviderError{Kind: kind, Message: stripeErr.Msg, cause: err} +} diff --git a/billing/errors/errors_test.go b/billing/errors/errors_test.go new file mode 100644 index 0000000000..9123c430ed --- /dev/null +++ b/billing/errors/errors_test.go @@ -0,0 +1,99 @@ +package errors + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + stripe "github.com/stripe/stripe-go/v79" +) + +func TestTranslateStripeError(t *testing.T) { + tests := []struct { + name string + err error + wantKind error + wantMsg string + }{ + { + name: "nil stays nil", + err: nil, + }, + { + name: "non stripe error stays unchanged", + err: errors.New("db down"), + }, + { + name: "unknown stripe error stays unchanged", + err: &stripe.Error{Code: stripe.ErrorCodeParameterMissing, Type: stripe.ErrorTypeInvalidRequest}, + }, + { + name: "resource missing", + err: &stripe.Error{Code: stripe.ErrorCodeResourceMissing, Msg: "No such customer: 'cus_123'"}, + wantKind: ErrProviderResourceMissing, + wantMsg: "record no longer exists on the billing provider: No such customer: 'cus_123'", + }, + { + name: "wrapped resource missing", + err: fmt.Errorf("get customer: %w", &stripe.Error{Code: stripe.ErrorCodeResourceMissing}), + wantKind: ErrProviderResourceMissing, + wantMsg: "record no longer exists on the billing provider", + }, + { + name: "card error", + err: &stripe.Error{Type: stripe.ErrorTypeCard, Code: stripe.ErrorCodeCardDeclined, Msg: "Your card was declined."}, + wantKind: ErrPaymentFailed, + wantMsg: "payment failed: Your card was declined.", + }, + { + name: "decline code without card type", + err: &stripe.Error{Type: stripe.ErrorTypeInvalidRequest, DeclineCode: stripe.DeclineCodeInsufficientFunds, Msg: "Insufficient funds."}, + wantKind: ErrPaymentFailed, + wantMsg: "payment failed: Insufficient funds.", + }, + { + name: "rate limited", + err: &stripe.Error{Code: stripe.ErrorCodeRateLimit, Type: stripe.ErrorTypeInvalidRequest}, + wantKind: ErrProviderUnavailable, + }, + { + name: "stripe server error", + err: &stripe.Error{Type: stripe.ErrorTypeAPI, Msg: "An unknown error occurred."}, + wantKind: ErrProviderUnavailable, + wantMsg: "billing provider is unavailable: An unknown error occurred.", + }, + { + name: "http 500 without api type", + err: &stripe.Error{Type: stripe.ErrorTypeInvalidRequest, HTTPStatusCode: 500}, + wantKind: ErrProviderUnavailable, + }, + { + name: "http 429 without rate limit code", + err: &stripe.Error{Type: stripe.ErrorTypeInvalidRequest, HTTPStatusCode: 429}, + wantKind: ErrProviderUnavailable, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := TranslateStripeError(tt.err) + if tt.wantKind == nil { + assert.Equal(t, tt.err, got) + return + } + assert.ErrorIs(t, got, tt.wantKind) + if tt.wantMsg != "" { + assert.Equal(t, tt.wantMsg, got.Error()) + } + + var stripeErr *stripe.Error + assert.ErrorAs(t, got, &stripeErr) + }) + } +} + +func TestTranslateStripeErrorKeepsOtherKindsApart(t *testing.T) { + got := TranslateStripeError(&stripe.Error{Code: stripe.ErrorCodeResourceMissing}) + assert.NotErrorIs(t, got, ErrPaymentFailed) + assert.NotErrorIs(t, got, ErrProviderUnavailable) +} From db4c2fea4b2a3c5d01981b8186725dccd757daa5 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Wed, 5 Aug 2026 11:50:25 +0530 Subject: [PATCH 2/3] test(billing): assert passthrough identity and original error chain Co-Authored-By: Claude Fable 5 --- billing/errors/errors_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/billing/errors/errors_test.go b/billing/errors/errors_test.go index 9123c430ed..a65211db6a 100644 --- a/billing/errors/errors_test.go +++ b/billing/errors/errors_test.go @@ -78,10 +78,15 @@ func TestTranslateStripeError(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got := TranslateStripeError(tt.err) if tt.wantKind == nil { - assert.Equal(t, tt.err, got) + if tt.err == nil { + assert.Nil(t, got) + } else { + assert.Same(t, tt.err, got) + } return } assert.ErrorIs(t, got, tt.wantKind) + assert.ErrorIs(t, got, tt.err) if tt.wantMsg != "" { assert.Equal(t, tt.wantMsg, got.Error()) } From eaeb3657de19552496ffbc9b714393873ee2d0be Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Wed, 5 Aug 2026 16:59:56 +0530 Subject: [PATCH 3/3] fix(billing): classify network failures, keep the stripe request id Review fixes: connection failures and timeouts reaching stripe now translate to provider-unavailable (a canceled request stays as is), the stripe request id is kept on ProviderError for support lookups, and Unwrap no longer returns a slice with a nil entry when the error was built without a cause. Co-Authored-By: Claude Fable 5 --- billing/errors/errors.go | 51 ++++++++++++++++++++++++++++------- billing/errors/errors_test.go | 37 +++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/billing/errors/errors.go b/billing/errors/errors.go index ec9e50b8ac..8d3515e679 100644 --- a/billing/errors/errors.go +++ b/billing/errors/errors.go @@ -1,8 +1,11 @@ package errors import ( + "context" "errors" + "net" "net/http" + "net/url" stripe "github.com/stripe/stripe-go/v79" ) @@ -14,11 +17,13 @@ var ( ) // ProviderError is a billing provider failure classified as one of the -// Err* kinds above. Message carries the provider's human-readable message. +// Err* kinds above. Message carries the provider's human-readable message, +// RequestID the provider's id for the failed request (for support tickets). type ProviderError struct { - Kind error - Message string - cause error + Kind error + Message string + RequestID string + cause error } func (e *ProviderError) Error() string { @@ -29,15 +34,36 @@ func (e *ProviderError) Error() string { } func (e *ProviderError) Unwrap() []error { - return []error{e.Kind, e.cause} + errs := []error{e.Kind} + if e.cause != nil { + errs = append(errs, e.cause) + } + return errs } -// TranslateStripeError converts a stripe error into a *ProviderError so -// callers can match it with errors.Is against the Err* kinds. Errors that -// don't match a known kind are returned unchanged. +// TranslateStripeError converts a stripe or network error into a +// *ProviderError so callers can match it with errors.Is against the Err* +// kinds. Errors that don't match a known kind are returned unchanged. func TranslateStripeError(err error) error { + if err == nil { + return nil + } + var stripeErr *stripe.Error - if err == nil || !errors.As(err, &stripeErr) { + if !errors.As(err, &stripeErr) { + // a canceled request is the caller's doing, not a provider outage + if errors.Is(err, context.Canceled) { + return err + } + var urlErr *url.Error + var netErr net.Error + if errors.As(err, &urlErr) || errors.As(err, &netErr) { + return &ProviderError{ + Kind: ErrProviderUnavailable, + Message: "could not reach the billing provider", + cause: err, + } + } return err } @@ -55,5 +81,10 @@ func TranslateStripeError(err error) error { default: return err } - return &ProviderError{Kind: kind, Message: stripeErr.Msg, cause: err} + return &ProviderError{ + Kind: kind, + Message: stripeErr.Msg, + RequestID: stripeErr.RequestID, + cause: err, + } } diff --git a/billing/errors/errors_test.go b/billing/errors/errors_test.go index a65211db6a..d87f548ff7 100644 --- a/billing/errors/errors_test.go +++ b/billing/errors/errors_test.go @@ -1,8 +1,10 @@ package errors import ( + "context" "errors" "fmt" + "net/url" "testing" "github.com/stretchr/testify/assert" @@ -57,6 +59,16 @@ func TestTranslateStripeError(t *testing.T) { err: &stripe.Error{Code: stripe.ErrorCodeRateLimit, Type: stripe.ErrorTypeInvalidRequest}, wantKind: ErrProviderUnavailable, }, + { + name: "connection failure", + err: &url.Error{Op: "Post", URL: "https://api.stripe.com/v1/customers", Err: errors.New("connection refused")}, + wantKind: ErrProviderUnavailable, + wantMsg: "billing provider is unavailable: could not reach the billing provider", + }, + { + name: "canceled request stays unchanged", + err: fmt.Errorf("get customer: %w", context.Canceled), + }, { name: "stripe server error", err: &stripe.Error{Type: stripe.ErrorTypeAPI, Msg: "An unknown error occurred."}, @@ -91,8 +103,11 @@ func TestTranslateStripeError(t *testing.T) { assert.Equal(t, tt.wantMsg, got.Error()) } - var stripeErr *stripe.Error - assert.ErrorAs(t, got, &stripeErr) + var inputStripeErr *stripe.Error + if errors.As(tt.err, &inputStripeErr) { + var stripeErr *stripe.Error + assert.ErrorAs(t, got, &stripeErr) + } }) } } @@ -102,3 +117,21 @@ func TestTranslateStripeErrorKeepsOtherKindsApart(t *testing.T) { assert.NotErrorIs(t, got, ErrPaymentFailed) assert.NotErrorIs(t, got, ErrProviderUnavailable) } + +func TestTranslateStripeErrorKeepsRequestID(t *testing.T) { + got := TranslateStripeError(&stripe.Error{ + Code: stripe.ErrorCodeResourceMissing, + RequestID: "req_AbCdEf123", + }) + var providerErr *ProviderError + assert.ErrorAs(t, got, &providerErr) + assert.Equal(t, "req_AbCdEf123", providerErr.RequestID) +} + +func TestProviderErrorUnwrapWithoutCause(t *testing.T) { + err := &ProviderError{Kind: ErrPaymentFailed} + for _, unwrapped := range err.Unwrap() { + assert.NotNil(t, unwrapped) + } + assert.ErrorIs(t, err, ErrPaymentFailed) +}