Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions billing/errors/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package errors

import (
"context"
"errors"
"net"
"net/http"
"net/url"

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,
// RequestID the provider's id for the failed request (for support tickets).
type ProviderError struct {
Kind error
Message string
RequestID 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 {
errs := []error{e.Kind}
if e.cause != nil {
errs = append(errs, e.cause)
}
return errs
}

// 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 !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
}

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,
Comment thread
whoAbhishekSah marked this conversation as resolved.
stripeErr.Type == stripe.ErrorTypeAPI,
stripeErr.HTTPStatusCode == http.StatusTooManyRequests,
stripeErr.HTTPStatusCode >= http.StatusInternalServerError:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
kind = ErrProviderUnavailable
default:
return err
}
return &ProviderError{
Kind: kind,
Message: stripeErr.Msg,
RequestID: stripeErr.RequestID,
cause: err,
}
}
137 changes: 137 additions & 0 deletions billing/errors/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package errors

import (
"context"
"errors"
"fmt"
"net/url"
"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: "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",
},
Comment thread
whoAbhishekSah marked this conversation as resolved.
{
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."},
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 {
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())
}

var inputStripeErr *stripe.Error
if errors.As(tt.err, &inputStripeErr) {
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)
}

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)
}
Loading