feat(client): implement exponential backoff retry for network errors

This commit is contained in:
2026-09-21 21:44:12 +03:30
parent 099ec2150e
commit c6e2ce7fd5
6 changed files with 335 additions and 2 deletions

View File

@@ -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 {