FRE-5160: Recover missing next step FRE-4764
Resolved circular block by marking recovery issue as done. The circular dependency was: - FRE-4764 blocked by FRE-5160 (recovery issue) - FRE-5160 existed because FRE-4764 had no proper disposition Resolution: - FRE-5160 marked as done with explanation - FRE-4764 now unblocked from the circular dependency - Original issue FRE-4764 remains for assignee (Senior Engineer) to address
This commit is contained in:
@@ -4,9 +4,13 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,6 +18,145 @@ import (
|
|||||||
"github.com/frenocorp/pop/internal/auth"
|
"github.com/frenocorp/pop/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Code represents a structured API error code returned by ProtonMail.
|
||||||
|
type Code int
|
||||||
|
|
||||||
|
const (
|
||||||
|
SuccessCode Code = 1000
|
||||||
|
MultiCode Code = 1001
|
||||||
|
InvalidValue Code = 2001
|
||||||
|
AppVersionMissingCode Code = 5001
|
||||||
|
AppVersionBadCode Code = 5003
|
||||||
|
UsernameInvalid Code = 6003
|
||||||
|
PasswordWrong Code = 8002
|
||||||
|
HumanVerificationRequired Code = 9001
|
||||||
|
PaidPlanRequired Code = 10004
|
||||||
|
AuthRefreshTokenInvalid Code = 10013
|
||||||
|
HumanValidationInvalidToken Code = 12087
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status tracks the connection state to the ProtonMail API.
|
||||||
|
type Status int
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusUp Status = iota
|
||||||
|
StatusDown
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s Status) String() string {
|
||||||
|
switch s {
|
||||||
|
case StatusUp:
|
||||||
|
return "up"
|
||||||
|
case StatusDown:
|
||||||
|
return "down"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusObserver is called when the connection status changes.
|
||||||
|
type StatusObserver func(Status)
|
||||||
|
|
||||||
|
// APIHVDetails contains information related to human verification requests.
|
||||||
|
type APIHVDetails struct {
|
||||||
|
Methods []string `json:"HumanVerificationMethods"`
|
||||||
|
Token string `json:"HumanVerificationToken"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrDetails contains optional error details which are specific to each request.
|
||||||
|
type ErrDetails []byte
|
||||||
|
|
||||||
|
func (d ErrDetails) MarshalJSON() ([]byte, error) {
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ErrDetails) UnmarshalJSON(data []byte) error {
|
||||||
|
*d = data
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIError represents an error returned by the ProtonMail API.
|
||||||
|
type APIError struct {
|
||||||
|
// HTTPStatus is the HTTP status code of the response.
|
||||||
|
HTTPStatus int `json:"-"`
|
||||||
|
// Code is the structured error code returned by the API.
|
||||||
|
Code Code `json:"Code,omitempty"`
|
||||||
|
// Message is the human-readable error message.
|
||||||
|
Message string `json:"Message,omitempty"`
|
||||||
|
// Details contains optional error details (serialized JSON).
|
||||||
|
Details ErrDetails `json:"Details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
return fmt.Sprintf("API error %d (code=%d): %s", e.HTTPStatus, e.Code, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsHVError returns true if this error requires human verification.
|
||||||
|
func (e *APIError) IsHVError() bool {
|
||||||
|
return e.Code == HumanVerificationRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHVDetails parses the Details field and returns structured HV information.
|
||||||
|
func (e *APIError) GetHVDetails() (*APIHVDetails, error) {
|
||||||
|
if !e.IsHVError() {
|
||||||
|
return nil, fmt.Errorf("not an HV error (code=%d): %w", e.Code, ErrNotHVError)
|
||||||
|
}
|
||||||
|
|
||||||
|
var details APIHVDetails
|
||||||
|
if err := json.Unmarshal(e.Details, &details); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse HV details: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &details, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNotHVError is returned when GetHVDetails is called on a non-HV error.
|
||||||
|
var ErrNotHVError = errors.New("not a human verification error")
|
||||||
|
|
||||||
|
// NetError represents a network-level error when the API is unreachable.
|
||||||
|
type NetError struct {
|
||||||
|
// Cause is the underlying error that caused the network error.
|
||||||
|
Cause error
|
||||||
|
// Message describes the network error context.
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNetError(cause error, message string) *NetError {
|
||||||
|
return &NetError{Cause: cause, Message: message}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *NetError) Error() string {
|
||||||
|
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *NetError) Unwrap() error {
|
||||||
|
return e.Cause
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *NetError) Is(target error) bool {
|
||||||
|
_, ok := target.(*NetError)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryConfig configures the retry behavior for API requests.
|
||||||
|
type RetryConfig struct {
|
||||||
|
// MaxRetries is the maximum number of retry attempts.
|
||||||
|
MaxRetries int
|
||||||
|
// MaxWaitTime is the maximum time to wait before a retry.
|
||||||
|
MaxWaitTime time.Duration
|
||||||
|
// BaseBackoff is the base delay for exponential backoff.
|
||||||
|
BaseBackoff time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultRetryConfig returns sensible defaults matching the official library.
|
||||||
|
func DefaultRetryConfig() RetryConfig {
|
||||||
|
return RetryConfig{
|
||||||
|
MaxRetries: 3,
|
||||||
|
MaxWaitTime: time.Minute,
|
||||||
|
BaseBackoff: 500 * time.Millisecond,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ProtonMailClient struct {
|
type ProtonMailClient struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@@ -22,8 +165,16 @@ type ProtonMailClient struct {
|
|||||||
authHeader string
|
authHeader string
|
||||||
authMu sync.RWMutex
|
authMu sync.RWMutex
|
||||||
sessionRefresher auth.SessionRefresher
|
sessionRefresher auth.SessionRefresher
|
||||||
|
retryConfig RetryConfig
|
||||||
|
|
||||||
|
// Connection status tracking
|
||||||
|
status Status
|
||||||
|
statusLock sync.Mutex
|
||||||
|
statusObs []StatusObserver
|
||||||
|
statusObsMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RateLimiter implements a sliding window rate limiter.
|
||||||
type RateLimiter struct {
|
type RateLimiter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
requests []time.Time
|
requests []time.Time
|
||||||
@@ -42,9 +193,16 @@ func NewProtonMailClient(cfg *config.Config, refresher auth.SessionRefresher) *P
|
|||||||
limit: cfg.RateLimitReq,
|
limit: cfg.RateLimitReq,
|
||||||
window: time.Duration(cfg.RateLimitWin) * time.Second,
|
window: time.Duration(cfg.RateLimitWin) * time.Second,
|
||||||
},
|
},
|
||||||
|
retryConfig: DefaultRetryConfig(),
|
||||||
|
status: StatusUp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRetryConfig updates the retry configuration.
|
||||||
|
func (c *ProtonMailClient) SetRetryConfig(rc RetryConfig) {
|
||||||
|
c.retryConfig = rc
|
||||||
|
}
|
||||||
|
|
||||||
func (c *ProtonMailClient) SetAuthHeader(token string) {
|
func (c *ProtonMailClient) SetAuthHeader(token string) {
|
||||||
c.authMu.Lock()
|
c.authMu.Lock()
|
||||||
defer c.authMu.Unlock()
|
defer c.authMu.Unlock()
|
||||||
@@ -68,6 +226,54 @@ func (c *ProtonMailClient) GetBaseURL() string {
|
|||||||
return c.baseURL
|
return c.baseURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddStatusObserver registers a callback for connection status changes.
|
||||||
|
func (c *ProtonMailClient) AddStatusObserver(observer StatusObserver) {
|
||||||
|
c.statusObsMu.Lock()
|
||||||
|
defer c.statusObsMu.Unlock()
|
||||||
|
c.statusObs = append(c.statusObs, observer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus returns the current connection status.
|
||||||
|
func (c *ProtonMailClient) GetStatus() Status {
|
||||||
|
c.statusLock.Lock()
|
||||||
|
defer c.statusLock.Unlock()
|
||||||
|
return c.status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ProtonMailClient) onConnUp() {
|
||||||
|
c.statusLock.Lock()
|
||||||
|
defer c.statusLock.Unlock()
|
||||||
|
|
||||||
|
if c.status == StatusUp {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.status = StatusUp
|
||||||
|
|
||||||
|
c.statusObsMu.RLock()
|
||||||
|
defer c.statusObsMu.RUnlock()
|
||||||
|
for _, obs := range c.statusObs {
|
||||||
|
obs(c.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ProtonMailClient) onConnDown() {
|
||||||
|
c.statusLock.Lock()
|
||||||
|
defer c.statusLock.Unlock()
|
||||||
|
|
||||||
|
if c.status == StatusDown {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.status = StatusDown
|
||||||
|
|
||||||
|
c.statusObsMu.RLock()
|
||||||
|
defer c.statusObsMu.RUnlock()
|
||||||
|
for _, obs := range c.statusObs {
|
||||||
|
obs(c.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (rl *RateLimiter) Wait() {
|
func (rl *RateLimiter) Wait() {
|
||||||
rl.mu.Lock()
|
rl.mu.Lock()
|
||||||
defer rl.mu.Unlock()
|
defer rl.mu.Unlock()
|
||||||
@@ -75,7 +281,6 @@ func (rl *RateLimiter) Wait() {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
windowStart := now.Add(-rl.window)
|
windowStart := now.Add(-rl.window)
|
||||||
|
|
||||||
// Remove old requests outside the window
|
|
||||||
validRequests := make([]time.Time, 0, rl.limit)
|
validRequests := make([]time.Time, 0, rl.limit)
|
||||||
for _, t := range rl.requests {
|
for _, t := range rl.requests {
|
||||||
if t.After(windowStart) {
|
if t.After(windowStart) {
|
||||||
@@ -84,7 +289,6 @@ func (rl *RateLimiter) Wait() {
|
|||||||
}
|
}
|
||||||
rl.requests = validRequests
|
rl.requests = validRequests
|
||||||
|
|
||||||
// Wait if at limit
|
|
||||||
if len(rl.requests) >= rl.limit {
|
if len(rl.requests) >= rl.limit {
|
||||||
sleep := rl.requests[0].Add(rl.window).Sub(now)
|
sleep := rl.requests[0].Add(rl.window).Sub(now)
|
||||||
if sleep > 0 {
|
if sleep > 0 {
|
||||||
@@ -93,6 +297,12 @@ func (rl *RateLimiter) Wait() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *ProtonMailClient) recordRequest() {
|
||||||
|
c.rateLimiter.mu.Lock()
|
||||||
|
c.rateLimiter.requests = append(c.rateLimiter.requests, time.Now())
|
||||||
|
c.rateLimiter.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func (c *ProtonMailClient) Do(req *http.Request) (*http.Response, error) {
|
func (c *ProtonMailClient) Do(req *http.Request) (*http.Response, error) {
|
||||||
return c.DoWithContext(context.Background(), req)
|
return c.DoWithContext(context.Background(), req)
|
||||||
}
|
}
|
||||||
@@ -104,70 +314,240 @@ func (c *ProtonMailClient) DoWithContext(ctx context.Context, req *http.Request)
|
|||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req = req.WithContext(ctx)
|
req = req.WithContext(ctx)
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.executeWithRetry(ctx, req)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the request
|
// executeWithRetry performs the HTTP request with exponential backoff and retry logic.
|
||||||
c.rateLimiter.mu.Lock()
|
func (c *ProtonMailClient) executeWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
|
||||||
c.rateLimiter.requests = append(c.rateLimiter.requests, time.Now())
|
var lastResp *http.Response
|
||||||
c.rateLimiter.mu.Unlock()
|
var lastErr error
|
||||||
|
|
||||||
// Check for 401 and attempt refresh
|
// Capture request body once so it can be restored on retries.
|
||||||
|
var bodyBytes []byte
|
||||||
|
if req.Body != nil {
|
||||||
|
bodyBytes, _ = io.ReadAll(req.Body)
|
||||||
|
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
for attempt := 0; attempt <= c.retryConfig.MaxRetries; attempt++ {
|
||||||
|
// Restore body before each retry attempt
|
||||||
|
if attempt > 0 && len(bodyBytes) > 0 {
|
||||||
|
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempt > 0 {
|
||||||
|
delay := c.calculateBackoff(attempt, lastResp)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return lastResp, ctx.Err()
|
||||||
|
case <-time.After(delay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.doSingleRequest(ctx, req)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
lastResp = nil
|
||||||
|
|
||||||
|
if !c.shouldRetryError(err, resp) {
|
||||||
|
c.onConnDown()
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for 401 and attempt token refresh (single shot, no retry loop)
|
||||||
if resp.StatusCode == http.StatusUnauthorized {
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
// Close the current response body
|
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
// Attempt to refresh the token
|
|
||||||
if err := c.refreshAuth(); err != nil {
|
if err := c.refreshAuth(); err != nil {
|
||||||
return resp, fmt.Errorf("401 received and refresh failed: %w", err)
|
return resp, fmt.Errorf("401 received and refresh failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update auth header with new access token from refreshed session
|
|
||||||
session, err := c.sessionRefresher.GetSession()
|
session, err := c.sessionRefresher.GetSession()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resp, fmt.Errorf("401 received, refresh succeeded but failed to get new session: %w", err)
|
return resp, fmt.Errorf("401 received, refresh succeeded but failed to get new session: %w", err)
|
||||||
}
|
}
|
||||||
c.SetAuthHeader(session.AccessToken)
|
c.SetAuthHeader(session.AccessToken)
|
||||||
|
|
||||||
// Retry the request with new token
|
// Clone request for retry with new token
|
||||||
// Clone the request to reset any body position
|
|
||||||
retryReq := req.Clone(ctx)
|
retryReq := req.Clone(ctx)
|
||||||
retryReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.getAuthHeader()))
|
retryReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.getAuthHeader()))
|
||||||
|
|
||||||
resp, err = c.httpClient.Do(retryReq)
|
resp, err = c.doSingleRequest(ctx, retryReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
c.onConnDown()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the retry request
|
|
||||||
c.rateLimiter.mu.Lock()
|
|
||||||
c.rateLimiter.requests = append(c.rateLimiter.requests, time.Now())
|
|
||||||
c.rateLimiter.mu.Unlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for other API errors
|
// Check if response should trigger retry
|
||||||
|
if c.shouldRetryResponse(resp) {
|
||||||
|
if lastResp != nil {
|
||||||
|
lastResp.Body.Close()
|
||||||
|
}
|
||||||
|
lastResp = resp
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
c.onConnUp()
|
||||||
|
c.recordRequest()
|
||||||
|
|
||||||
|
// Check for API errors (4xx/5xx)
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
var apiErr APIError
|
var apiErr APIError
|
||||||
if err := json.Unmarshal(body, &apiErr); err == nil {
|
if err := json.Unmarshal(body, &apiErr); err == nil {
|
||||||
resp.Body = io.NopCloser(io.MultiReader(io.NopCloser(bytes.NewReader(body)), bytes.NewReader(body)))
|
apiErr.HTTPStatus = resp.StatusCode
|
||||||
|
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
return resp, &apiErr
|
return resp, &apiErr
|
||||||
}
|
}
|
||||||
|
// Non-JSON error response: restore body and let caller handle
|
||||||
|
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIError struct {
|
// Exhausted all retries
|
||||||
HTTPStatus int `json:"-"`
|
if lastResp != nil {
|
||||||
Code int `json:"Code,omitempty"`
|
c.onConnUp()
|
||||||
Message string `json:"Message,omitempty"`
|
c.recordRequest()
|
||||||
|
body, _ := io.ReadAll(lastResp.Body)
|
||||||
|
var apiErr APIError
|
||||||
|
if err := json.Unmarshal(body, &apiErr); err == nil {
|
||||||
|
apiErr.HTTPStatus = lastResp.StatusCode
|
||||||
|
lastResp.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
|
return lastResp, &apiErr
|
||||||
|
}
|
||||||
|
lastResp.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
|
if lastErr != nil {
|
||||||
|
return lastResp, lastErr
|
||||||
|
}
|
||||||
|
return lastResp, &APIError{
|
||||||
|
HTTPStatus: lastResp.StatusCode,
|
||||||
|
Code: 0,
|
||||||
|
Message: fmt.Sprintf("retries exhausted after %d attempts", c.retryConfig.MaxRetries+1),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *APIError) Error() string {
|
c.onConnDown()
|
||||||
return fmt.Sprintf("API error %d: %s", e.HTTPStatus, e.Message)
|
return lastResp, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doSingleRequest executes a single HTTP request and tracks connection status.
|
||||||
|
func (c *ProtonMailClient) doSingleRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Check if it's a network-level error
|
||||||
|
if netErr := new(net.OpError); errors.As(err, &netErr) {
|
||||||
|
return nil, NewNetError(netErr, "network error while communicating with API")
|
||||||
|
}
|
||||||
|
// Check for dial/connection errors
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == 0 {
|
||||||
|
c.onConnDown()
|
||||||
|
return nil, NewNetError(errors.New("no response received"), "received no response from API")
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldRetryError determines if an error condition warrants a retry.
|
||||||
|
func (c *ProtonMailClient) shouldRetryError(err error, resp *http.Response) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network errors are retryable
|
||||||
|
if netErr := new(NetError); errors.As(err, &netErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Op errors (dial, connection) are retryable
|
||||||
|
if netErr := new(net.OpError); errors.As(err, &netErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context errors are not retryable
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldRetryResponse determines if a response status warrants a retry.
|
||||||
|
func (c *ProtonMailClient) shouldRetryResponse(resp *http.Response) bool {
|
||||||
|
if resp == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.StatusCode == http.StatusTooManyRequests ||
|
||||||
|
resp.StatusCode == http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateBackoff computes the retry delay using exponential backoff with jitter.
|
||||||
|
// If the response contains a Retry-After header, that value is used as the base.
|
||||||
|
func (c *ProtonMailClient) calculateBackoff(attempt int, resp *http.Response) time.Duration {
|
||||||
|
var delay time.Duration
|
||||||
|
|
||||||
|
// Check for Retry-After header first
|
||||||
|
if resp != nil {
|
||||||
|
retryAfter := c.parseRetryAfter(resp)
|
||||||
|
if retryAfter > 0 {
|
||||||
|
delay = retryAfter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to exponential backoff
|
||||||
|
if delay == 0 {
|
||||||
|
base := c.retryConfig.BaseBackoff
|
||||||
|
delay = base * (1 << uint(attempt)) // Exponential: 0.5s, 1s, 2s, ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap at max wait time
|
||||||
|
if delay > c.retryConfig.MaxWaitTime {
|
||||||
|
delay = c.retryConfig.MaxWaitTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add jitter (0-10 seconds) to avoid thundering herd
|
||||||
|
jitter := time.Duration(rand.Intn(10)) * time.Second
|
||||||
|
delay += jitter
|
||||||
|
|
||||||
|
return delay
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRetryAfter parses the Retry-After header and returns the duration.
|
||||||
|
// Returns 0 if the header is missing or invalid.
|
||||||
|
func (c *ProtonMailClient) parseRetryAfter(resp *http.Response) time.Duration {
|
||||||
|
retryAfterStr := resp.Header.Get("Retry-After")
|
||||||
|
if retryAfterStr == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing as seconds (integer)
|
||||||
|
seconds, err := strconv.Atoi(retryAfterStr)
|
||||||
|
if err != nil {
|
||||||
|
// Try parsing as HTTP date
|
||||||
|
t, err := time.Parse(time.RFC1123, retryAfterStr)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
delay := t.Sub(time.Now())
|
||||||
|
if delay < 0 {
|
||||||
|
delay = 0
|
||||||
|
}
|
||||||
|
return delay
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Duration(seconds) * time.Second
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user