storj/satellite/payments/stripecoinpayments/client.go
Jeremy Wharton e0bb410192 satellite/payments/stripecoinpayments: parallelize invoice methods
Invoicing-related payment service methods have been modified to send
Stripe API requests in parallel.

Additionally, randomness has been added to the Stripe backend wrapper's
exponential backoff strategy in order to reduce the effects of the
thundering herd problem, which arises when executing many simultaneous
API calls.

Resolves #5156

Change-Id: I568f933284f4229ef41c155377ca0cc33f0eb5a4
2023-03-31 17:14:22 +00:00

279 lines
8.9 KiB
Go

// Copyright (C) 2020 Storj Labs, Inc.
// See LICENSE for copying information.
package stripecoinpayments
import (
"bytes"
"context"
"errors"
"math"
"math/rand"
"net/http"
"time"
"github.com/stripe/stripe-go/v72"
"github.com/stripe/stripe-go/v72/charge"
"github.com/stripe/stripe-go/v72/client"
"github.com/stripe/stripe-go/v72/customerbalancetransaction"
"github.com/stripe/stripe-go/v72/form"
"github.com/stripe/stripe-go/v72/invoice"
"github.com/stripe/stripe-go/v72/invoiceitem"
"github.com/stripe/stripe-go/v72/paymentmethod"
"github.com/stripe/stripe-go/v72/promotioncode"
"go.uber.org/zap"
"storj.io/common/time2"
)
// StripeClient Stripe client interface.
type StripeClient interface {
Customers() StripeCustomers
PaymentMethods() StripePaymentMethods
Invoices() StripeInvoices
InvoiceItems() StripeInvoiceItems
CustomerBalanceTransactions() StripeCustomerBalanceTransactions
Charges() StripeCharges
PromoCodes() StripePromoCodes
CreditNotes() StripeCreditNotes
}
// StripeCustomers Stripe Customers interface.
type StripeCustomers interface {
New(params *stripe.CustomerParams) (*stripe.Customer, error)
Get(id string, params *stripe.CustomerParams) (*stripe.Customer, error)
Update(id string, params *stripe.CustomerParams) (*stripe.Customer, error)
}
// StripePaymentMethods Stripe PaymentMethods interface.
type StripePaymentMethods interface {
List(listParams *stripe.PaymentMethodListParams) *paymentmethod.Iter
New(params *stripe.PaymentMethodParams) (*stripe.PaymentMethod, error)
Attach(id string, params *stripe.PaymentMethodAttachParams) (*stripe.PaymentMethod, error)
Detach(id string, params *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error)
}
// StripeInvoices Stripe Invoices interface.
type StripeInvoices interface {
New(params *stripe.InvoiceParams) (*stripe.Invoice, error)
List(listParams *stripe.InvoiceListParams) *invoice.Iter
Update(id string, params *stripe.InvoiceParams) (*stripe.Invoice, error)
FinalizeInvoice(id string, params *stripe.InvoiceFinalizeParams) (*stripe.Invoice, error)
Pay(id string, params *stripe.InvoicePayParams) (*stripe.Invoice, error)
Del(id string, params *stripe.InvoiceParams) (*stripe.Invoice, error)
}
// StripeInvoiceItems Stripe InvoiceItems interface.
type StripeInvoiceItems interface {
New(params *stripe.InvoiceItemParams) (*stripe.InvoiceItem, error)
Update(id string, params *stripe.InvoiceItemParams) (*stripe.InvoiceItem, error)
List(listParams *stripe.InvoiceItemListParams) *invoiceitem.Iter
Del(id string, params *stripe.InvoiceItemParams) (*stripe.InvoiceItem, error)
}
// StripeCharges Stripe Charges interface.
type StripeCharges interface {
List(listParams *stripe.ChargeListParams) *charge.Iter
}
// StripePromoCodes is the Stripe PromoCodes interface.
type StripePromoCodes interface {
List(params *stripe.PromotionCodeListParams) *promotioncode.Iter
}
// StripeCustomerBalanceTransactions Stripe CustomerBalanceTransactions interface.
type StripeCustomerBalanceTransactions interface {
New(params *stripe.CustomerBalanceTransactionParams) (*stripe.CustomerBalanceTransaction, error)
List(listParams *stripe.CustomerBalanceTransactionListParams) *customerbalancetransaction.Iter
}
// StripeCreditNotes Stripe CreditNotes interface.
type StripeCreditNotes interface {
New(params *stripe.CreditNoteParams) (*stripe.CreditNote, error)
}
type stripeClient struct {
client *client.API
}
func (s *stripeClient) Customers() StripeCustomers {
return s.client.Customers
}
func (s *stripeClient) PaymentMethods() StripePaymentMethods {
return s.client.PaymentMethods
}
func (s *stripeClient) Invoices() StripeInvoices {
return s.client.Invoices
}
func (s *stripeClient) InvoiceItems() StripeInvoiceItems {
return s.client.InvoiceItems
}
func (s *stripeClient) CustomerBalanceTransactions() StripeCustomerBalanceTransactions {
return s.client.CustomerBalanceTransactions
}
func (s *stripeClient) Charges() StripeCharges {
return s.client.Charges
}
func (s *stripeClient) PromoCodes() StripePromoCodes {
return s.client.PromotionCodes
}
func (s *stripeClient) CreditNotes() StripeCreditNotes {
return s.client.CreditNotes
}
// NewStripeClient creates Stripe client from configuration.
func NewStripeClient(log *zap.Logger, config Config) StripeClient {
sClient := client.New(config.StripeSecretKey,
&stripe.Backends{
API: NewBackendWrapper(log, stripe.APIBackend, config.Retries),
Connect: NewBackendWrapper(log, stripe.ConnectBackend, config.Retries),
Uploads: NewBackendWrapper(log, stripe.UploadsBackend, config.Retries),
},
)
return &stripeClient{client: sClient}
}
// RetryConfig contains the configuration for an exponential backoff strategy when retrying Stripe API calls.
type RetryConfig struct {
InitialBackoff time.Duration `help:"the duration of the first retry interval" default:"20ms"`
MaxBackoff time.Duration `help:"the maximum duration of any retry interval" default:"5s"`
Multiplier float64 `help:"the factor by which the retry interval will be multiplied on each iteration" default:"2"`
MaxRetries int64 `help:"the maximum number of times to retry a request" default:"10"`
}
// BackendWrapper is a wrapper for the Stripe backend that uses an exponential backoff strategy for retrying Stripe API calls.
type BackendWrapper struct {
backend stripe.Backend
retryCfg RetryConfig
clock time2.Clock
}
// NewBackendWrapper creates a new wrapper for a Stripe backend.
func NewBackendWrapper(log *zap.Logger, backendType stripe.SupportedBackend, retryCfg RetryConfig) *BackendWrapper {
backendConfig := &stripe.BackendConfig{
LeveledLogger: log.Sugar(),
// Disable internal retries since we have our own retry+backoff strategy.
MaxNetworkRetries: stripe.Int64(0),
}
return &BackendWrapper{
retryCfg: retryCfg,
backend: stripe.GetBackendWithConfig(backendType, backendConfig),
}
}
// TestSwapBackend replaces the wrapped backend with the one specified for use in testing.
func (w *BackendWrapper) TestSwapBackend(backend stripe.Backend) {
w.backend = backend
}
// TestSwapClock replaces the internal clock with the one specified for use in testing.
func (w *BackendWrapper) TestSwapClock(clock time2.Clock) {
w.clock = clock
}
// Call implements the stripe.Backend interface.
func (w *BackendWrapper) Call(method, path, key string, params stripe.ParamsContainer, v stripe.LastResponseSetter) error {
return w.withRetries(params, func() error {
return w.backend.Call(method, path, key, params, v)
})
}
// CallStreaming implements the stripe.Backend interface.
func (w *BackendWrapper) CallStreaming(method, path, key string, params stripe.ParamsContainer, v stripe.StreamingLastResponseSetter) error {
return w.withRetries(params, func() error {
return w.backend.CallStreaming(method, path, key, params, v)
})
}
// CallRaw implements the stripe.Backend interface.
func (w *BackendWrapper) CallRaw(method, path, key string, body *form.Values, params *stripe.Params, v stripe.LastResponseSetter) error {
return w.withRetries(params, func() error {
return w.backend.CallRaw(method, path, key, body, params, v)
})
}
// CallMultipart implements the stripe.Backend interface.
func (w *BackendWrapper) CallMultipart(method, path, key, boundary string, body *bytes.Buffer, params *stripe.Params, v stripe.LastResponseSetter) error {
return w.withRetries(params, func() error {
return w.backend.CallMultipart(method, path, key, boundary, body, params, v)
})
}
// SetMaxNetworkRetries sets the maximum number of times to retry failed requests.
func (w *BackendWrapper) SetMaxNetworkRetries(max int64) {
w.retryCfg.MaxRetries = max
}
// withRetries executes the provided Stripe API call using an exponential backoff strategy
// for retrying in the case of failure.
func (w *BackendWrapper) withRetries(params stripe.ParamsContainer, call func() error) error {
ctx := context.Background()
if params != nil {
innerParams := params.GetParams()
if innerParams != nil && innerParams.Context != nil {
ctx = innerParams.Context
}
}
if err := ctx.Err(); err != nil {
return err
}
for retry := int64(0); ; retry++ {
err := call()
if err == nil {
return nil
}
if !w.shouldRetry(retry, err) {
return err
}
minBackoff := float64(w.retryCfg.InitialBackoff)
maxBackoff := math.Min(
float64(w.retryCfg.MaxBackoff),
minBackoff*math.Pow(w.retryCfg.Multiplier, float64(retry)),
)
backoff := minBackoff + rand.Float64()*(maxBackoff-minBackoff)
if !w.clock.Sleep(ctx, time.Duration(backoff)) {
return ctx.Err()
}
}
}
// shouldRetry returns whether a Stripe API call should be retried.
func (w *BackendWrapper) shouldRetry(retry int64, err error) bool {
if retry >= w.retryCfg.MaxRetries {
return false
}
var stripeErr *stripe.Error
if !errors.As(err, &stripeErr) {
return false
}
resp := stripeErr.LastResponse
if resp == nil {
return false
}
switch resp.Header.Get("Stripe-Should-Retry") {
case "true":
return true
case "false":
return false
}
return resp.StatusCode == http.StatusTooManyRequests
}