UNPKG

@segment/analytics-react-native

Version:

The hassle-free way to add Segment analytics to your React-Native app.

359 lines (336 loc) 13.3 kB
import { createStore } from '@segment/sovran-react-native'; export let RetryState = /*#__PURE__*/function (RetryState) { RetryState["READY"] = "READY"; RetryState["RATE_LIMITED"] = "RATE_LIMITED"; RetryState["BACKING_OFF"] = "BACKING_OFF"; return RetryState; }({}); export let RetryResult = /*#__PURE__*/function (RetryResult) { RetryResult["RATE_LIMITED"] = "rate_limited"; RetryResult["BACKED_OFF"] = "backed_off"; RetryResult["LIMIT_EXCEEDED"] = "limit_exceeded"; return RetryResult; }({}); const INITIAL_STATE = { state: RetryState.READY, waitUntilTime: 0, retryCount: 0, firstFailureTime: null }; /** * Manages retry state for rate limiting (429) and transient errors (5xx). * * State machine: READY → RATE_LIMITED (429) or BACKING_OFF (5xx) → READY * - READY: uploads proceed normally * - RATE_LIMITED: server returned 429; uploads blocked until Retry-After expires * - BACKING_OFF: transient error; exponential backoff until wait expires * * Designed for concurrent batch uploads (Promise.all). Multiple batches can * fail simultaneously with different errors or partially succeed. When * consolidating concurrent wait times, takes the shorter wait (eager retry). * * Uses a global retry counter since batches are re-chunked from the event * queue on each flush and have no stable identities. */ export class RetryManager { constructor(storeId, persistor, rateLimitConfig, backoffConfig, logger) { this.rateLimitConfig = rateLimitConfig; this.backoffConfig = backoffConfig; this.logger = logger; this.store = this.createStore(storeId, persistor); } /** * Create sovran store with persistence fallback. * Tries persisted store first, falls back to in-memory on failure. */ createStore(storeId, persistor) { // Try persisted store first if (persistor) { try { return createStore(INITIAL_STATE, { persist: { storeId: `${storeId}-retryState`, persistor } }); } catch (e) { this.logger?.error(`[RetryManager] Persistence failed, falling back to in-memory: ${this.getErrorMessage(e)}`); } } // Fall back to in-memory store try { return createStore(INITIAL_STATE); } catch (e) { this.logger?.error(`[RetryManager] CRITICAL: In-memory store creation failed: ${this.getErrorMessage(e)}`); throw e; } } /** Extract error message from unknown error type. */ getErrorMessage(error) { return error instanceof Error ? error.message : String(error); } /** * Check if uploads can proceed. Transitions to READY if wait time has passed. * Validates persisted state to handle clock changes or corruption. */ async canRetry() { const state = await this.store.getState(true); const now = Date.now(); if (state.state === RetryState.READY) { return true; } if (!this.isPersistedStateValid(state, now)) { this.logger?.warn('Persisted retry state failed validation, resetting to READY'); await this.reset(); return true; } if (now >= state.waitUntilTime) { await this.transitionToReady(); return true; } const waitSeconds = Math.ceil((state.waitUntilTime - now) / 1000); const stateType = this.getStateDisplayName(state.state); this.logger?.info(`Upload blocked: ${stateType}, retry in ${waitSeconds}s (retry ${state.retryCount})`); return false; } /** * Clamp retry-after seconds to valid range [0, maxRetryInterval]. */ clampRetryAfter(retryAfterSeconds, maxInterval) { if (retryAfterSeconds < 0) { this.logger?.warn(`Invalid retryAfterSeconds ${retryAfterSeconds}, using 0`); return 0; } if (retryAfterSeconds > maxInterval) { this.logger?.warn(`retryAfterSeconds ${retryAfterSeconds}s exceeds maxRetryInterval, clamping to ${maxInterval}s`); return maxInterval; } return retryAfterSeconds; } /** * Handle a response that carries a server-directed wait via the Retry-After * header. Originally added for 429 rate limiting, this is the authoritative * "the server told us exactly how long to wait" path and is reused for any * retryable status code (429, 529, 503, 408, …) that includes Retry-After. * * Uses the server-specified wait time (clamped to maxRetryInterval), pauses * all uploads, and enforces the same maxRetryCount / maxRateLimitDuration * safety valves as the 429 path. */ async handleRetryAfter(retryAfterSeconds) { if (this.rateLimitConfig?.enabled !== true) { return undefined; } retryAfterSeconds = this.clampRetryAfter(retryAfterSeconds, this.rateLimitConfig.maxRetryInterval); const now = Date.now(); const waitUntilTime = now + retryAfterSeconds * 1000; return this.handleError(RetryState.RATE_LIMITED, _state => waitUntilTime, this.rateLimitConfig.maxRetryCount, this.rateLimitConfig.maxRateLimitDuration, now); } /** * Handle a 429 rate limit response. * Delegates to the shared Retry-After (server-directed wait) path. */ async handle429(retryAfterSeconds) { return this.handleRetryAfter(retryAfterSeconds); } /** * Handle a transient error (5xx, network failure). * Uses exponential backoff to calculate wait time. */ async handleTransientError() { if (this.backoffConfig?.enabled !== true) { return undefined; } const now = Date.now(); const random = Math.random(); return this.handleError(RetryState.BACKING_OFF, state => { const backoffSeconds = this.calculateBackoff(state.retryCount, random); return now + backoffSeconds * 1000; }, this.backoffConfig.maxRetryCount, this.backoffConfig.maxTotalBackoffDuration, now); } /** Reset the state machine to READY with retry count 0. */ async reset() { await this.store.dispatch(() => INITIAL_STATE); } /** Get the current retry count (used for X-Retry-Count header). */ async getRetryCount() { const state = await this.store.getState(true); return state.retryCount; } /** * Compute new retry state based on current state and error type. * Returns the new state and whether retry limits were exceeded. */ computeNewState(state, newState, computeWaitUntilTime, maxRetryCount, maxRetryDuration, now) { const newRetryCount = state.retryCount + 1; const firstFailureTime = state.firstFailureTime ?? now; const totalDuration = (now - firstFailureTime) / 1000; if (newRetryCount > maxRetryCount || totalDuration > maxRetryDuration) { return { newState: INITIAL_STATE, limitExceeded: true }; } const waitUntilTime = computeWaitUntilTime(state); const resolvedState = this.resolveStatePrecedence(state.state, newState); const finalWaitUntilTime = this.consolidateWaitTime(state.state, newState, state.waitUntilTime, waitUntilTime); return { newState: { state: resolvedState, waitUntilTime: finalWaitUntilTime, retryCount: newRetryCount, firstFailureTime }, limitExceeded: false }; } /** * Map retry state to result enum. */ stateToResult(state) { switch (state) { case RetryState.RATE_LIMITED: return RetryResult.RATE_LIMITED; case RetryState.BACKING_OFF: return RetryResult.BACKED_OFF; } } /** * Unified error handler for both 429 and transient errors. * Dispatches atomically to handle concurrent batch failures. * * @param newState - The target state (RATE_LIMITED or BACKING_OFF) * @param computeWaitUntilTime - Function to compute wait time from current state. * For 429: returns server-specified Retry-After time (ignores state). * For transient: computes exponential backoff from state.retryCount. * @param maxRetryCount - Maximum allowed retry count before reset * @param maxRetryDuration - Maximum allowed total retry duration (seconds) * @param now - Current timestamp */ async handleError(newState, computeWaitUntilTime, maxRetryCount, maxRetryDuration, now) { let limitExceeded = false; const newStateData = await this.store.dispatch(state => { const result = this.computeNewState(state, newState, computeWaitUntilTime, maxRetryCount, maxRetryDuration, now); limitExceeded = result.limitExceeded; return result.newState; }); if (limitExceeded) { this.logger?.warn(`Max retry limit exceeded (count: ${maxRetryCount}, duration: ${maxRetryDuration}s), resetting retry manager`); return RetryResult.LIMIT_EXCEEDED; } const stateType = this.getStateDisplayName(newStateData.state); this.logger?.info(`${stateType}: waiting ${Math.ceil((newStateData.waitUntilTime - now) / 1000)}s before retry ${newStateData.retryCount}`); return this.stateToResult(newState); } /** * Resolve state precedence when multiple errors occur concurrently. * Rule: 429 rate limiting takes precedence over transient backoff. */ resolveStatePrecedence(currentState, newState) { // If currently rate limited and a transient error occurs, stay rate limited if (currentState === RetryState.RATE_LIMITED && newState === RetryState.BACKING_OFF) { return RetryState.RATE_LIMITED; } return newState; } /** * Consolidate wait times when multiple errors occur. * Uses eager strategy (shorter wait) except when transitioning states. */ consolidateWaitTime(currentState, newState, currentWaitUntil, newWaitUntil) { switch (currentState) { case RetryState.READY: // First error: use the new wait time return newWaitUntil; case RetryState.BACKING_OFF: if (newState === RetryState.RATE_LIMITED) { // 429 overrides backoff: use new wait time return newWaitUntil; } // Both backing off: take shorter wait (eager strategy) return Math.min(currentWaitUntil, newWaitUntil); case RetryState.RATE_LIMITED: // Both rate limited: take shorter wait (eager strategy) return Math.min(currentWaitUntil, newWaitUntil); } } /** Get display name for logging based on retry state. */ getStateDisplayName(state) { switch (state) { case RetryState.RATE_LIMITED: return 'Rate limited (429)'; case RetryState.BACKING_OFF: return 'Transient error'; case RetryState.READY: return 'Ready'; } } calculateBackoff(retryCount, random) { if (!this.backoffConfig) { return 0; } const { baseBackoffInterval, maxBackoffInterval, jitterPercent } = this.backoffConfig; const exponentialBackoff = baseBackoffInterval * Math.pow(2, retryCount); const clampedBackoff = Math.min(exponentialBackoff, maxBackoffInterval); const jitterRange = clampedBackoff * (jitterPercent / 100); const jitter = random * jitterRange; return clampedBackoff + jitter; } async transitionToReady() { const state = await this.store.getState(true); const stateType = state.state === RetryState.RATE_LIMITED ? 'Rate limit' : 'Backoff'; this.logger?.info(`${stateType} period expired, resuming uploads`); await this.store.dispatch(s => ({ ...s, state: RetryState.READY })); } /** Check if state enum is valid. */ isValidStateEnum(state) { return Object.values(RetryState).includes(state); } /** Check if firstFailureTime is in the past or null. */ isValidFirstFailureTime(firstFailureTime, now) { return firstFailureTime === null || firstFailureTime <= now; } /** Check if waitUntilTime is within reasonable bounds. */ isValidWaitUntilTime(state, now) { const maxWaitMs = state.state === RetryState.RATE_LIMITED ? (this.rateLimitConfig?.maxRetryInterval ?? 300) * 1000 : (this.backoffConfig?.maxBackoffInterval ?? 300) * 1000; // Allow up to maxWait + 10% jitter headroom const maxReasonableWait = now + maxWaitMs * 1.1; return state.waitUntilTime <= maxReasonableWait; } /** Check if retryCount is non-negative. */ isValidRetryCount(retryCount) { return retryCount >= 0; } /** * Validate persisted state loaded from storage on app restart. * Detects clock changes, corruption, or impossibly stale data. */ isPersistedStateValid(state, now) { if (!this.isValidStateEnum(state.state)) { this.logger?.warn(`Invalid persisted state: ${state.state}`); return false; } if (!this.isValidFirstFailureTime(state.firstFailureTime, now)) { this.logger?.warn(`firstFailureTime ${state.firstFailureTime} is in the future`); return false; } if (!this.isValidWaitUntilTime(state, now)) { const maxWaitMs = state.state === RetryState.RATE_LIMITED ? (this.rateLimitConfig?.maxRetryInterval ?? 300) * 1000 : (this.backoffConfig?.maxBackoffInterval ?? 300) * 1000; this.logger?.warn('waitUntilTime is unreasonably far in the future ' + `(${Math.ceil((state.waitUntilTime - now) / 1000)}s from now, ` + `max expected ~${Math.ceil(maxWaitMs / 1000)}s)`); return false; } if (!this.isValidRetryCount(state.retryCount)) { this.logger?.warn(`retryCount is negative: ${state.retryCount}`); return false; } return true; } } //# sourceMappingURL=RetryManager.js.map