feat(client): implement exponential backoff retry for network errors
This commit is contained in:
@@ -9,16 +9,24 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"card-to-iban/internal/service"
|
||||
)
|
||||
|
||||
const maxResponseBodySize = 1 << 20
|
||||
const (
|
||||
maxResponseBodySize = 1 << 20
|
||||
|
||||
retryBaseDelay = 200 * time.Millisecond
|
||||
retryMaxDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
endpointURL string
|
||||
tokenProvider TokenProvider
|
||||
maxRetries int
|
||||
retryWait func(ctx context.Context, delay time.Duration) bool
|
||||
}
|
||||
|
||||
var _ service.CardToIBANClient = (*Client)(nil)
|
||||
@@ -32,17 +40,114 @@ func NewClient(
|
||||
httpClient *http.Client,
|
||||
endpointURL string,
|
||||
tokenProvider TokenProvider,
|
||||
maxRetries int,
|
||||
) *Client {
|
||||
if maxRetries < 0 {
|
||||
maxRetries = 0
|
||||
}
|
||||
|
||||
return &Client{
|
||||
httpClient: httpClient,
|
||||
endpointURL: endpointURL,
|
||||
tokenProvider: tokenProvider,
|
||||
maxRetries: maxRetries,
|
||||
retryWait: waitForRetry,
|
||||
}
|
||||
}
|
||||
|
||||
func isRetryable(err error) bool {
|
||||
return errors.Is(err, ErrTimeout) ||
|
||||
errors.Is(err, ErrUnavailable)
|
||||
}
|
||||
|
||||
func retryDelay(attempt int) time.Duration {
|
||||
delay := retryBaseDelay
|
||||
|
||||
for i := 0; i < attempt; i++ {
|
||||
delay *= 2
|
||||
|
||||
if delay >= retryMaxDelay {
|
||||
return retryMaxDelay
|
||||
}
|
||||
}
|
||||
|
||||
return delay
|
||||
}
|
||||
|
||||
func waitForRetry(
|
||||
ctx context.Context,
|
||||
delay time.Duration,
|
||||
) bool {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Convert(
|
||||
ctx context.Context,
|
||||
cardNumber string,
|
||||
) (service.ConversionResult, error) {
|
||||
var (
|
||||
result service.ConversionResult
|
||||
err error
|
||||
)
|
||||
|
||||
waitFn := c.retryWait
|
||||
if waitFn == nil {
|
||||
waitFn = waitForRetry
|
||||
}
|
||||
|
||||
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
||||
result, err = c.convertOnce(
|
||||
ctx,
|
||||
cardNumber,
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Don't retry permanent/non-transient errors.
|
||||
if !isRetryable(err) {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// We already used all configured retries.
|
||||
if attempt == c.maxRetries {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// If the caller canceled the request, stop immediately.
|
||||
if ctx.Err() != nil {
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
return result, fmt.Errorf("zarinhub request canceled: %w", ctx.Err())
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
delay := retryDelay(attempt)
|
||||
|
||||
if !waitFn(ctx, delay) {
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
return result, fmt.Errorf("zarinhub request canceled: %w", ctx.Err())
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (c *Client) convertOnce(
|
||||
ctx context.Context,
|
||||
cardNumber string,
|
||||
) (service.ConversionResult, error) {
|
||||
accessToken, err := c.tokenProvider.AccessToken(ctx)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user