diff --git a/src/utils/index.ts b/src/utils/index.ts index 137df29..764a5b1 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -34,3 +34,5 @@ export { } from 'utils/typeGuards' export { redactToken } from 'utils/redactToken' export type { RedactTokenOptions } from 'utils/redactToken' +export { withRetry } from 'utils/retry' +export type { RetryOptions } from 'utils/retry' diff --git a/src/utils/retry.ts b/src/utils/retry.ts new file mode 100644 index 0000000..a2c4366 --- /dev/null +++ b/src/utils/retry.ts @@ -0,0 +1,136 @@ +import { isFigmaApiError, getRetryAfter } from 'utils/errorHelpers' + +/** + * Options for configuring retry behavior. + * + * @public + */ +export interface RetryOptions { + /** + * Maximum number of retry attempts. + * @defaultValue 3 + */ + maxRetries?: number + /** + * Initial delay in milliseconds before the first retry. + * @defaultValue 1000 + */ + initialDelayMs?: number + /** + * Multiplier for exponential backoff between retries. + * @defaultValue 2 + */ + backoffMultiplier?: number + /** + * Maximum delay in milliseconds between retries. + * @defaultValue 30000 + */ + maxDelayMs?: number + /** + * Only retry on rate limit errors (429). When false, retries on any error. + * @defaultValue true + */ + retryOnlyRateLimits?: boolean + /** + * Callback invoked before each retry attempt. + * Useful for logging or updating UI state. + */ + onRetry?: (attempt: number, delayMs: number, error: Error) => void +} + +/** + * Wraps an async function with automatic retry logic and exponential backoff. + * + * @remarks + * By default, only retries on rate limit errors (HTTP 429) from the Figma API. + * Respects the Retry-After header when present. Uses exponential backoff + * with configurable initial delay and multiplier. + * + * @param fn - The async function to wrap with retry logic + * @param options - Configuration for retry behavior + * @returns A new function that will retry on failure + * + * @example + * ```ts + * import { withRetry, fetcher } from '@figma-vars/hooks'; + * + * const fetchWithRetry = withRetry( + * () => fetcher(url, token), + * { maxRetries: 3, onRetry: (attempt, delay) => console.log(`Retry ${attempt} in ${delay}ms`) } + * ); + * + * const data = await fetchWithRetry(); + * ``` + * + * @public + */ +export function withRetry( + fn: () => Promise, + options?: RetryOptions +): () => Promise { + const { + maxRetries = 3, + initialDelayMs = 1000, + backoffMultiplier = 2, + maxDelayMs = 30000, + retryOnlyRateLimits = true, + onRetry, + } = options ?? {} + + return async (): Promise => { + let lastError: Error | undefined + let currentDelay = initialDelayMs + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn() + } catch (err) { + const error = err as Error + lastError = error + + // Check if we should retry + const isRateLimit = isFigmaApiError(error) && error.statusCode === 429 + const shouldRetry = retryOnlyRateLimits ? isRateLimit : true + + // Don't retry if we've exhausted attempts or shouldn't retry this error + if (attempt >= maxRetries || !shouldRetry) { + throw error + } + + // Calculate delay: use Retry-After header if available, otherwise use backoff + let delayMs = currentDelay + const retryAfter = getRetryAfter(error) + if (retryAfter !== null) { + // Retry-After is in seconds, convert to milliseconds + delayMs = retryAfter * 1000 + } + + // Cap delay at maxDelayMs + delayMs = Math.min(delayMs, maxDelayMs) + + // Invoke callback if provided + if (onRetry) { + onRetry(attempt + 1, delayMs, error) + } + + // Wait before retrying + await sleep(delayMs) + + // Increase delay for next attempt (exponential backoff) + currentDelay = Math.min(currentDelay * backoffMultiplier, maxDelayMs) + } + } + + // This should never be reached, but TypeScript needs it + /* istanbul ignore next */ + throw lastError ?? new Error('Retry failed') + } +} + +/** + * Sleep for a specified number of milliseconds. + * @internal + */ +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} diff --git a/tests/utils/retry.test.ts b/tests/utils/retry.test.ts new file mode 100644 index 0000000..6264e93 --- /dev/null +++ b/tests/utils/retry.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { withRetry } from '../../src/utils/retry' +import { FigmaApiError } from '../../src/types/figma' + +describe('withRetry', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('successful execution', () => { + it('should return result on first success', async () => { + const fn = vi.fn().mockResolvedValue('success') + const wrapped = withRetry(fn) + + const result = await wrapped() + + expect(result).toBe('success') + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('should return result after retry succeeds', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new FigmaApiError('Rate limited', 429)) + .mockResolvedValueOnce('success') + + const wrapped = withRetry(fn) + const promise = wrapped() + + // Advance timer to trigger retry + await vi.advanceTimersByTimeAsync(1000) + + const result = await promise + + expect(result).toBe('success') + expect(fn).toHaveBeenCalledTimes(2) + }) + }) + + describe('rate limit handling', () => { + it('should retry on 429 errors by default', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new FigmaApiError('Rate limited', 429)) + .mockResolvedValueOnce('success') + + const wrapped = withRetry(fn) + const promise = wrapped() + + await vi.advanceTimersByTimeAsync(1000) + + const result = await promise + expect(result).toBe('success') + expect(fn).toHaveBeenCalledTimes(2) + }) + + it('should not retry non-429 errors by default', async () => { + const fn = vi.fn().mockRejectedValue(new FigmaApiError('Not found', 404)) + + const wrapped = withRetry(fn) + + await expect(wrapped()).rejects.toThrow('Not found') + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('should use Retry-After header when present', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new FigmaApiError('Rate limited', 429, 5)) + .mockResolvedValueOnce('success') + + const onRetry = vi.fn() + const wrapped = withRetry(fn, { onRetry }) + const promise = wrapped() + + // Should wait 5 seconds (from Retry-After) + await vi.advanceTimersByTimeAsync(5000) + + await promise + expect(onRetry).toHaveBeenCalledWith(1, 5000, expect.any(Error)) + }) + }) + + describe('retry options', () => { + it('should respect maxRetries option', async () => { + const fn = vi + .fn() + .mockRejectedValue(new FigmaApiError('Rate limited', 429)) + + const wrapped = withRetry(fn, { maxRetries: 2 }) + + // Use Promise.allSettled to handle both timer advancement and promise rejection + const resultPromise = wrapped().catch((e: Error) => e) + + // Advance through all retries + await vi.advanceTimersByTimeAsync(1000) // retry 1 + await vi.advanceTimersByTimeAsync(2000) // retry 2 + + const result = await resultPromise + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe('Rate limited') + expect(fn).toHaveBeenCalledTimes(3) // initial + 2 retries + }) + + it('should use custom initialDelayMs', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new FigmaApiError('Rate limited', 429)) + .mockResolvedValueOnce('success') + + const onRetry = vi.fn() + const wrapped = withRetry(fn, { initialDelayMs: 500, onRetry }) + const promise = wrapped() + + await vi.advanceTimersByTimeAsync(500) + + await promise + expect(onRetry).toHaveBeenCalledWith(1, 500, expect.any(Error)) + }) + + it('should apply exponential backoff', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new FigmaApiError('Rate limited', 429)) + .mockRejectedValueOnce(new FigmaApiError('Rate limited', 429)) + .mockResolvedValueOnce('success') + + const onRetry = vi.fn() + const wrapped = withRetry(fn, { + initialDelayMs: 1000, + backoffMultiplier: 2, + onRetry, + }) + const promise = wrapped() + + await vi.advanceTimersByTimeAsync(1000) // first retry: 1000ms + await vi.advanceTimersByTimeAsync(2000) // second retry: 2000ms + + await promise + expect(onRetry).toHaveBeenNthCalledWith(1, 1, 1000, expect.any(Error)) + expect(onRetry).toHaveBeenNthCalledWith(2, 2, 2000, expect.any(Error)) + }) + + it('should cap delay at maxDelayMs', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new FigmaApiError('Rate limited', 429)) + .mockResolvedValueOnce('success') + + const onRetry = vi.fn() + const wrapped = withRetry(fn, { + initialDelayMs: 50000, + maxDelayMs: 5000, + onRetry, + }) + const promise = wrapped() + + await vi.advanceTimersByTimeAsync(5000) + + await promise + expect(onRetry).toHaveBeenCalledWith(1, 5000, expect.any(Error)) + }) + + it('should retry all errors when retryOnlyRateLimits is false', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new FigmaApiError('Server error', 500)) + .mockResolvedValueOnce('success') + + const wrapped = withRetry(fn, { retryOnlyRateLimits: false }) + const promise = wrapped() + + await vi.advanceTimersByTimeAsync(1000) + + const result = await promise + expect(result).toBe('success') + expect(fn).toHaveBeenCalledTimes(2) + }) + }) + + describe('onRetry callback', () => { + it('should call onRetry with attempt, delay, and error', async () => { + const rateLimitError = new FigmaApiError('Rate limited', 429) + const fn = vi + .fn() + .mockRejectedValueOnce(rateLimitError) + .mockResolvedValueOnce('success') + + const onRetry = vi.fn() + const wrapped = withRetry(fn, { onRetry }) + const promise = wrapped() + + await vi.advanceTimersByTimeAsync(1000) + + await promise + expect(onRetry).toHaveBeenCalledWith(1, 1000, rateLimitError) + }) + }) + + describe('edge cases', () => { + it('should handle non-FigmaApiError errors', async () => { + const fn = vi.fn().mockRejectedValue(new Error('Network error')) + + const wrapped = withRetry(fn) + + // Should not retry since it's not a rate limit error + await expect(wrapped()).rejects.toThrow('Network error') + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('should retry non-FigmaApiError when retryOnlyRateLimits is false', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce('success') + + const wrapped = withRetry(fn, { retryOnlyRateLimits: false }) + const promise = wrapped() + + await vi.advanceTimersByTimeAsync(1000) + + const result = await promise + expect(result).toBe('success') + }) + + it('should handle zero maxRetries', async () => { + const fn = vi + .fn() + .mockRejectedValue(new FigmaApiError('Rate limited', 429)) + + const wrapped = withRetry(fn, { maxRetries: 0 }) + + await expect(wrapped()).rejects.toThrow('Rate limited') + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('should cap exponential backoff at maxDelayMs after multiple retries', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new FigmaApiError('Rate limited', 429)) + .mockRejectedValueOnce(new FigmaApiError('Rate limited', 429)) + .mockRejectedValueOnce(new FigmaApiError('Rate limited', 429)) + .mockResolvedValueOnce('success') + + const onRetry = vi.fn() + const wrapped = withRetry(fn, { + initialDelayMs: 1000, + backoffMultiplier: 10, // Would grow to 10000, then 100000 + maxDelayMs: 5000, + maxRetries: 3, + onRetry, + }) + const promise = wrapped() + + await vi.advanceTimersByTimeAsync(1000) // retry 1: 1000ms + await vi.advanceTimersByTimeAsync(5000) // retry 2: capped at 5000ms (not 10000) + await vi.advanceTimersByTimeAsync(5000) // retry 3: still capped at 5000ms + + await promise + expect(onRetry).toHaveBeenNthCalledWith(1, 1, 1000, expect.any(Error)) + expect(onRetry).toHaveBeenNthCalledWith(2, 2, 5000, expect.any(Error)) // capped + expect(onRetry).toHaveBeenNthCalledWith(3, 3, 5000, expect.any(Error)) // capped + }) + + it('should work without onRetry callback', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new FigmaApiError('Rate limited', 429)) + .mockResolvedValueOnce('success') + + // No onRetry provided + const wrapped = withRetry(fn) + const promise = wrapped() + + await vi.advanceTimersByTimeAsync(1000) + + const result = await promise + expect(result).toBe('success') + expect(fn).toHaveBeenCalledTimes(2) + }) + }) +})