package zarinhub import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "time" "card-to-iban/internal/service" ) 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) type TokenProvider interface { AccessToken(ctx context.Context) (string, error) Invalidate(token string) } 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 { return service.ConversionResult{}, fmt.Errorf("get zarinhub access token: %w", err) } result, err := c.convert(ctx, cardNumber, accessToken) if !errors.Is(err, ErrAuthentication) || result.UpstreamHTTPStatus != http.StatusUnauthorized { return result, err } c.tokenProvider.Invalidate(accessToken) accessToken, err = c.tokenProvider.AccessToken(ctx) if err != nil { return result, fmt.Errorf("refresh zarinhub access token: %w", err) } return c.convert(ctx, cardNumber, accessToken) } func (c *Client) convert( ctx context.Context, cardNumber string, accessToken string, ) (service.ConversionResult, error) { payload, err := json.Marshal(convertRequest{ Card: cardNumber, }) if err != nil { return service.ConversionResult{}, fmt.Errorf("marshal zarinhub request: %w", err) } req, err := http.NewRequestWithContext( ctx, http.MethodPost, c.endpointURL, bytes.NewReader(payload), ) if err != nil { return service.ConversionResult{}, fmt.Errorf("create zarinhub request: %w", err) } req.Header.Set( "Authorization", "Bearer "+accessToken, ) req.Header.Set( "Content-Type", "application/json", ) resp, err := c.httpClient.Do(req) if err != nil { switch { case errors.Is(err, context.DeadlineExceeded): return service.ConversionResult{}, fmt.Errorf("%w: %v", ErrTimeout, err) case errors.Is(err, context.Canceled): return service.ConversionResult{}, fmt.Errorf("zarinhub request canceled: %w", err) } var netErr net.Error if errors.As(err, &netErr) && netErr.Timeout() { return service.ConversionResult{}, fmt.Errorf("%w: %v", ErrTimeout, err) } return service.ConversionResult{}, fmt.Errorf("%w: %v", ErrUnavailable, err) } defer resp.Body.Close() result := service.ConversionResult{ UpstreamHTTPStatus: resp.StatusCode, } body, err := io.ReadAll( io.LimitReader(resp.Body, maxResponseBodySize+1), ) if err != nil { return result, fmt.Errorf( "%w: failed to read response body: %v", ErrInvalidResponse, err, ) } if len(body) > maxResponseBodySize { return result, fmt.Errorf( "%w: response body is too large", ErrInvalidResponse, ) } result.RawResponse = body switch { case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden: return result, ErrAuthentication case resp.StatusCode == http.StatusRequestTimeout: return result, ErrTimeout case resp.StatusCode == http.StatusTooManyRequests: return result, ErrUnavailable case resp.StatusCode >= http.StatusInternalServerError: return result, ErrUnavailable case resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices: return result, ErrRejected } var decoded convertResponse if err := json.Unmarshal(body, &decoded); err != nil { return result, fmt.Errorf("%w: %v", ErrInvalidResponse, err) } if !decoded.Meta.IsSuccess { return result, ErrRejected } if decoded.Data.IBAN == "" { return result, fmt.Errorf( "%w: successful response did not contain iban", ErrInvalidResponse, ) } result.IBAN = decoded.Data.IBAN return result, nil }