0f2402da58
Audit follow-up. After verifying the codebase, three exports of retry-utils.ts have ZERO production callers: - `withRetry` decorator (lines 419-440): unused - `retryable` wrapper (lines 442-453): unused (1 self-test only) - `RetryManager` class (lines 455-549): unused (1 self-test only) Note: `MessageRetryManager` in messages-send.ts is a separate class, not the same thing. The kept exports are still used: - `retry()` function: used by decode-wa-message.ts for Bad MAC recovery - `RetryExhaustedError`, `RetryOptions`, `RetryContext`: used same place - `RETRY_BACKOFF_DELAYS`, `RETRY_JITTER_FACTOR`: re-exported via Defaults - `retryPredicates`, `retryConfigs`: helper presets (kept; small) Removed `EventEmitter` import (was only used by the deleted RetryManager). Removed `retryable` and `RetryManager` from test imports + their describe blocks (5 tests removed, all targeting the deleted code). Net: -148 lines from retry-utils.ts, -90 lines from its test file. 803 tests pass (was 809; 6 deleted tests covered the removed code). Now the InfiniteAPI retry surface is: - `retry-utils.ts:retry()` for Bad MAC recovery (decryption layer) - `bounded-retry.ts:withBoundedRetry()` for socket operation retry (uploadPreKeys), with WhatsApp Android empirical defaults No redundancy: each helper handles a distinct concern.
570 lines
15 KiB
TypeScript
570 lines
15 KiB
TypeScript
/**
|
|
* Testes unitários para retry-utils.ts
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
|
|
import {
|
|
calculateDelay,
|
|
createRetrier,
|
|
getAllRetryDelaysWithJitter,
|
|
getRetryDelayWithJitter,
|
|
retry,
|
|
RETRY_BACKOFF_DELAYS,
|
|
RETRY_JITTER_FACTOR,
|
|
RetryAbortedError,
|
|
retryConfigs,
|
|
type RetryContext,
|
|
RetryExhaustedError,
|
|
retryPredicates,
|
|
retryWithResult
|
|
} from '../../Utils/retry-utils.js'
|
|
|
|
describe('retry', () => {
|
|
describe('successful operations', () => {
|
|
it('should return result on first success', async () => {
|
|
const result = await retry(() => 'success', { collectMetrics: false })
|
|
expect(result).toBe('success')
|
|
})
|
|
|
|
it('should return result from async operation', async () => {
|
|
const result = await retry(
|
|
async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
return 'async success'
|
|
},
|
|
{ collectMetrics: false }
|
|
)
|
|
|
|
expect(result).toBe('async success')
|
|
})
|
|
})
|
|
|
|
describe('retry behavior', () => {
|
|
it('should retry on failure', async () => {
|
|
let attempts = 0
|
|
|
|
const result = await retry(
|
|
() => {
|
|
attempts++
|
|
if (attempts < 3) {
|
|
throw new Error('Failing')
|
|
}
|
|
|
|
return 'success after retries'
|
|
},
|
|
{
|
|
maxAttempts: 5,
|
|
baseDelay: 10,
|
|
collectMetrics: false
|
|
}
|
|
)
|
|
|
|
expect(result).toBe('success after retries')
|
|
expect(attempts).toBe(3)
|
|
})
|
|
|
|
it('should exhaust retries and throw', async () => {
|
|
await expect(
|
|
retry(
|
|
() => {
|
|
throw new Error('Always fails')
|
|
},
|
|
{
|
|
maxAttempts: 3,
|
|
baseDelay: 10,
|
|
collectMetrics: false
|
|
}
|
|
)
|
|
).rejects.toThrow(RetryExhaustedError)
|
|
})
|
|
|
|
it('should pass context to operation', async () => {
|
|
let receivedContext: RetryContext | null = null
|
|
|
|
await retry(
|
|
context => {
|
|
receivedContext = context
|
|
return 'success'
|
|
},
|
|
{ collectMetrics: false }
|
|
)
|
|
|
|
expect(receivedContext).not.toBeNull()
|
|
expect(receivedContext!.attempt).toBe(1)
|
|
expect(receivedContext!.maxAttempts).toBe(3) // default
|
|
})
|
|
})
|
|
|
|
describe('shouldRetry predicate', () => {
|
|
it('should use custom shouldRetry', async () => {
|
|
let attempts = 0
|
|
|
|
await expect(
|
|
retry(
|
|
() => {
|
|
attempts++
|
|
throw new Error('Non-retryable')
|
|
},
|
|
{
|
|
maxAttempts: 5,
|
|
baseDelay: 10,
|
|
shouldRetry: () => false,
|
|
collectMetrics: false
|
|
}
|
|
)
|
|
).rejects.toThrow(RetryExhaustedError)
|
|
|
|
expect(attempts).toBe(1) // Should not retry
|
|
})
|
|
})
|
|
|
|
describe('callbacks', () => {
|
|
it('should call onRetry callback', async () => {
|
|
const onRetry = jest.fn<(error: Error, attempt: number, delay: number) => void>()
|
|
let attempts = 0
|
|
|
|
await retry(
|
|
() => {
|
|
attempts++
|
|
if (attempts < 3) {
|
|
throw new Error('Failing')
|
|
}
|
|
|
|
return 'success'
|
|
},
|
|
{
|
|
maxAttempts: 5,
|
|
baseDelay: 10,
|
|
onRetry,
|
|
collectMetrics: false
|
|
}
|
|
)
|
|
|
|
expect(onRetry).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('should call onSuccess callback', async () => {
|
|
const onSuccess = jest.fn()
|
|
|
|
await retry(() => 'result', {
|
|
onSuccess,
|
|
collectMetrics: false
|
|
})
|
|
|
|
expect(onSuccess).toHaveBeenCalledWith('result', 1)
|
|
})
|
|
|
|
it('should call onFailure callback on exhaustion', async () => {
|
|
const onFailure = jest.fn()
|
|
|
|
await expect(
|
|
retry(
|
|
() => {
|
|
throw new Error('Always fails')
|
|
},
|
|
{
|
|
maxAttempts: 2,
|
|
baseDelay: 10,
|
|
onFailure,
|
|
collectMetrics: false
|
|
}
|
|
)
|
|
).rejects.toThrow()
|
|
|
|
expect(onFailure).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('abort signal', () => {
|
|
it('should abort with signal', async () => {
|
|
const controller = new AbortController()
|
|
let attempt = 0
|
|
|
|
const promise = retry(
|
|
async () => {
|
|
attempt++
|
|
if (attempt === 1) {
|
|
// First attempt fails to trigger retry
|
|
throw new Error('Temporary failure')
|
|
}
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
return 'success'
|
|
},
|
|
{
|
|
maxAttempts: 5,
|
|
baseDelay: 100,
|
|
abortSignal: controller.signal,
|
|
collectMetrics: false
|
|
}
|
|
)
|
|
|
|
// Abort during retry delay (after first failure, before second attempt)
|
|
setTimeout(() => controller.abort(), 50)
|
|
|
|
await expect(promise).rejects.toThrow(RetryAbortedError)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('retryWithResult', () => {
|
|
it('should return success result', async () => {
|
|
const result = await retryWithResult(() => 'success', { collectMetrics: false })
|
|
|
|
expect(result.success).toBe(true)
|
|
expect(result.result).toBe('success')
|
|
expect(result.attempts).toBe(1)
|
|
expect(result.totalDuration).toBeGreaterThanOrEqual(0)
|
|
})
|
|
|
|
it('should return failure result', async () => {
|
|
const result = await retryWithResult(
|
|
() => {
|
|
throw new Error('Failure')
|
|
},
|
|
{
|
|
maxAttempts: 2,
|
|
baseDelay: 10,
|
|
collectMetrics: false
|
|
}
|
|
)
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toBeInstanceOf(RetryExhaustedError)
|
|
expect(result.attempts).toBe(2)
|
|
})
|
|
})
|
|
|
|
describe('calculateDelay', () => {
|
|
describe('exponential backoff', () => {
|
|
it('should calculate exponential delays', () => {
|
|
const delay1 = calculateDelay(1, 100, 10000, 'exponential', 2, 0)
|
|
const delay2 = calculateDelay(2, 100, 10000, 'exponential', 2, 0)
|
|
const delay3 = calculateDelay(3, 100, 10000, 'exponential', 2, 0)
|
|
|
|
expect(delay1).toBe(100)
|
|
expect(delay2).toBe(200)
|
|
expect(delay3).toBe(400)
|
|
})
|
|
})
|
|
|
|
describe('linear backoff', () => {
|
|
it('should calculate linear delays', () => {
|
|
const delay1 = calculateDelay(1, 100, 10000, 'linear', 2, 0)
|
|
const delay2 = calculateDelay(2, 100, 10000, 'linear', 2, 0)
|
|
const delay3 = calculateDelay(3, 100, 10000, 'linear', 2, 0)
|
|
|
|
expect(delay1).toBe(100)
|
|
expect(delay2).toBe(200)
|
|
expect(delay3).toBe(300)
|
|
})
|
|
})
|
|
|
|
describe('constant backoff', () => {
|
|
it('should return constant delays', () => {
|
|
const delay1 = calculateDelay(1, 100, 10000, 'constant', 2, 0)
|
|
const delay2 = calculateDelay(2, 100, 10000, 'constant', 2, 0)
|
|
const delay3 = calculateDelay(3, 100, 10000, 'constant', 2, 0)
|
|
|
|
expect(delay1).toBe(100)
|
|
expect(delay2).toBe(100)
|
|
expect(delay3).toBe(100)
|
|
})
|
|
})
|
|
|
|
describe('max delay cap', () => {
|
|
it('should cap at max delay', () => {
|
|
const delay = calculateDelay(10, 100, 500, 'exponential', 2, 0)
|
|
expect(delay).toBeLessThanOrEqual(500)
|
|
})
|
|
})
|
|
|
|
describe('jitter', () => {
|
|
it('should add jitter to delay', () => {
|
|
const delays = new Set()
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
delays.add(calculateDelay(1, 100, 10000, 'constant', 2, 0.5))
|
|
}
|
|
|
|
// With jitter, we should get varying delays
|
|
expect(delays.size).toBeGreaterThan(1)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('createRetrier', () => {
|
|
it('should create preconfigured retrier', async () => {
|
|
const myRetrier = createRetrier({
|
|
maxAttempts: 5,
|
|
baseDelay: 10,
|
|
collectMetrics: false
|
|
})
|
|
|
|
let attempts = 0
|
|
const result = await myRetrier(() => {
|
|
attempts++
|
|
if (attempts < 3) throw new Error('Failing')
|
|
return 'success'
|
|
})
|
|
|
|
expect(result).toBe('success')
|
|
expect(attempts).toBe(3)
|
|
})
|
|
})
|
|
|
|
describe('retryPredicates', () => {
|
|
describe('always', () => {
|
|
it('should always return true', () => {
|
|
expect(retryPredicates.always()).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('never', () => {
|
|
it('should always return false', () => {
|
|
expect(retryPredicates.never()).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('onNetworkError', () => {
|
|
it('should return true for network errors', () => {
|
|
expect(retryPredicates.onNetworkError(new Error('ECONNREFUSED'))).toBe(true)
|
|
expect(retryPredicates.onNetworkError(new Error('ETIMEDOUT'))).toBe(true)
|
|
})
|
|
|
|
it('should return false for other errors', () => {
|
|
expect(retryPredicates.onNetworkError(new Error('Other error'))).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('onErrorCodes', () => {
|
|
it('should match specified codes', () => {
|
|
const predicate = retryPredicates.onErrorCodes(['ECODE1', 'ECODE2'])
|
|
|
|
expect(predicate(new Error('ECODE1 occurred'))).toBe(true)
|
|
expect(predicate(new Error('ECODE3 occurred'))).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('or', () => {
|
|
it('should combine predicates with OR', () => {
|
|
const combined = retryPredicates.or(
|
|
e => e.message.includes('A'),
|
|
e => e.message.includes('B')
|
|
)
|
|
|
|
expect(combined(new Error('A'), 1)).toBe(true)
|
|
expect(combined(new Error('B'), 1)).toBe(true)
|
|
expect(combined(new Error('C'), 1)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('and', () => {
|
|
it('should combine predicates with AND', () => {
|
|
const combined = retryPredicates.and(
|
|
e => e.message.includes('A'),
|
|
e => e.message.includes('B')
|
|
)
|
|
|
|
expect(combined(new Error('A and B'), 1)).toBe(true)
|
|
expect(combined(new Error('A only'), 1)).toBe(false)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('retryConfigs', () => {
|
|
it('should have aggressive config', () => {
|
|
expect(retryConfigs.aggressive.maxAttempts).toBe(10)
|
|
expect(retryConfigs.aggressive.baseDelay).toBe(100)
|
|
})
|
|
|
|
it('should have conservative config', () => {
|
|
expect(retryConfigs.conservative.maxAttempts).toBe(3)
|
|
expect(retryConfigs.conservative.baseDelay).toBe(2000)
|
|
})
|
|
|
|
it('should have fast config', () => {
|
|
expect(retryConfigs.fast.maxAttempts).toBe(5)
|
|
expect(retryConfigs.fast.baseDelay).toBe(50)
|
|
})
|
|
|
|
it('should have network config with predicate', () => {
|
|
expect(retryConfigs.network.shouldRetry).toBe(retryPredicates.onNetworkError)
|
|
})
|
|
|
|
it('should have rsocket config with stepped strategy', () => {
|
|
expect(retryConfigs.rsocket.backoffStrategy).toBe('stepped')
|
|
expect(retryConfigs.rsocket.maxAttempts).toBe(5)
|
|
expect(retryConfigs.rsocket.baseDelay).toBe(1000)
|
|
expect(retryConfigs.rsocket.maxDelay).toBe(20000)
|
|
})
|
|
})
|
|
|
|
describe('calculateDelay - stepped strategy', () => {
|
|
it('should use exact delays from RETRY_BACKOFF_DELAYS array', () => {
|
|
// Expected delays: [1000, 2000, 5000, 10000, 20000]
|
|
const delay1 = calculateDelay(1, 100, 50000, 'stepped', 2, 0)
|
|
const delay2 = calculateDelay(2, 100, 50000, 'stepped', 2, 0)
|
|
const delay3 = calculateDelay(3, 100, 50000, 'stepped', 2, 0)
|
|
const delay4 = calculateDelay(4, 100, 50000, 'stepped', 2, 0)
|
|
const delay5 = calculateDelay(5, 100, 50000, 'stepped', 2, 0)
|
|
|
|
expect(delay1).toBe(1000)
|
|
expect(delay2).toBe(2000)
|
|
expect(delay3).toBe(5000)
|
|
expect(delay4).toBe(10000)
|
|
expect(delay5).toBe(20000)
|
|
})
|
|
|
|
it('should use last delay for attempts beyond array length', () => {
|
|
const delay6 = calculateDelay(6, 100, 50000, 'stepped', 2, 0)
|
|
const delay10 = calculateDelay(10, 100, 50000, 'stepped', 2, 0)
|
|
|
|
expect(delay6).toBe(20000)
|
|
expect(delay10).toBe(20000)
|
|
})
|
|
|
|
it('should ignore baseDelay and multiplier parameters', () => {
|
|
// baseDelay=9999 and multiplier=99 should be ignored
|
|
const delay = calculateDelay(1, 9999, 50000, 'stepped', 99, 0)
|
|
expect(delay).toBe(1000) // First step from RETRY_BACKOFF_DELAYS
|
|
})
|
|
|
|
it('should still apply jitter to stepped delays', () => {
|
|
const delays = new Set<number>()
|
|
|
|
for (let i = 0; i < 20; i++) {
|
|
delays.add(calculateDelay(1, 100, 50000, 'stepped', 2, 0.15))
|
|
}
|
|
|
|
// With 15% jitter on 1000ms, delays should vary between ~850ms and ~1150ms
|
|
expect(delays.size).toBeGreaterThan(1)
|
|
})
|
|
})
|
|
|
|
describe('getRetryDelayWithJitter', () => {
|
|
it('should return delay for each attempt with jitter applied', () => {
|
|
// Test multiple times to verify jitter creates variation
|
|
const delays: number[] = []
|
|
for (let i = 0; i < 10; i++) {
|
|
delays.push(getRetryDelayWithJitter(1))
|
|
}
|
|
|
|
// All delays should be within ±15% of 1000ms (850-1150ms)
|
|
delays.forEach(delay => {
|
|
expect(delay).toBeGreaterThanOrEqual(850)
|
|
expect(delay).toBeLessThanOrEqual(1150)
|
|
})
|
|
|
|
// With jitter, we should have some variation
|
|
const uniqueDelays = new Set(delays)
|
|
expect(uniqueDelays.size).toBeGreaterThan(1)
|
|
})
|
|
|
|
it('should return correct base delays for each attempt', () => {
|
|
// Test without considering jitter - just verify rough ranges
|
|
// Attempt 1: 1000ms ±15% = 850-1150ms
|
|
// Attempt 2: 2000ms ±15% = 1700-2300ms
|
|
// Attempt 3: 5000ms ±15% = 4250-5750ms
|
|
// Attempt 4: 10000ms ±15% = 8500-11500ms
|
|
// Attempt 5: 20000ms ±15% = 17000-23000ms
|
|
|
|
const d1 = getRetryDelayWithJitter(1)
|
|
const d2 = getRetryDelayWithJitter(2)
|
|
const d3 = getRetryDelayWithJitter(3)
|
|
const d4 = getRetryDelayWithJitter(4)
|
|
const d5 = getRetryDelayWithJitter(5)
|
|
|
|
expect(d1).toBeGreaterThanOrEqual(850)
|
|
expect(d1).toBeLessThanOrEqual(1150)
|
|
|
|
expect(d2).toBeGreaterThanOrEqual(1700)
|
|
expect(d2).toBeLessThanOrEqual(2300)
|
|
|
|
expect(d3).toBeGreaterThanOrEqual(4250)
|
|
expect(d3).toBeLessThanOrEqual(5750)
|
|
|
|
expect(d4).toBeGreaterThanOrEqual(8500)
|
|
expect(d4).toBeLessThanOrEqual(11500)
|
|
|
|
expect(d5).toBeGreaterThanOrEqual(17000)
|
|
expect(d5).toBeLessThanOrEqual(23000)
|
|
})
|
|
|
|
it('should handle attempt 0 by using first delay', () => {
|
|
const delay = getRetryDelayWithJitter(0)
|
|
expect(delay).toBeGreaterThanOrEqual(850)
|
|
expect(delay).toBeLessThanOrEqual(1150)
|
|
})
|
|
|
|
it('should handle negative attempts by using first delay', () => {
|
|
const delay = getRetryDelayWithJitter(-5)
|
|
expect(delay).toBeGreaterThanOrEqual(850)
|
|
expect(delay).toBeLessThanOrEqual(1150)
|
|
})
|
|
|
|
it('should use last delay for attempts beyond array length', () => {
|
|
const delay6 = getRetryDelayWithJitter(6)
|
|
const delay100 = getRetryDelayWithJitter(100)
|
|
|
|
// Both should use 20000ms ±15% = 17000-23000ms
|
|
expect(delay6).toBeGreaterThanOrEqual(17000)
|
|
expect(delay6).toBeLessThanOrEqual(23000)
|
|
|
|
expect(delay100).toBeGreaterThanOrEqual(17000)
|
|
expect(delay100).toBeLessThanOrEqual(23000)
|
|
})
|
|
})
|
|
|
|
describe('getAllRetryDelaysWithJitter', () => {
|
|
it('should return array with 5 delays', () => {
|
|
const delays = getAllRetryDelaysWithJitter()
|
|
expect(delays).toHaveLength(5)
|
|
})
|
|
|
|
it('should return delays in increasing order (approximately)', () => {
|
|
const delays = getAllRetryDelaysWithJitter()
|
|
|
|
// Each delay should be roughly larger than the previous
|
|
// (with jitter, there's small chance of overlap at boundaries)
|
|
expect(delays[0]!).toBeLessThan(delays[2]!) // 1000 < 5000
|
|
expect(delays[1]!).toBeLessThan(delays[3]!) // 2000 < 10000
|
|
expect(delays[2]!).toBeLessThan(delays[4]!) // 5000 < 20000
|
|
})
|
|
|
|
it('should return different values on each call due to jitter', () => {
|
|
const delays1 = getAllRetryDelaysWithJitter()
|
|
const delays2 = getAllRetryDelaysWithJitter()
|
|
|
|
// At least one delay should differ between calls
|
|
const allSame = delays1.every((d, i) => d === delays2[i])
|
|
expect(allSame).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('retryConfigs.rsocket consistency', () => {
|
|
/**
|
|
* This test ensures the hardcoded values in retryConfigs.rsocket
|
|
* stay synchronized with RETRY_BACKOFF_DELAYS and RETRY_JITTER_FACTOR.
|
|
*
|
|
* The values are hardcoded to avoid ESM initialization order issues,
|
|
* but this test will fail if someone updates the constants without
|
|
* updating retryConfigs.rsocket.
|
|
*/
|
|
it('should have maxAttempts equal to RETRY_BACKOFF_DELAYS.length', () => {
|
|
expect(retryConfigs.rsocket.maxAttempts).toBe(RETRY_BACKOFF_DELAYS.length)
|
|
})
|
|
|
|
it('should have baseDelay equal to RETRY_BACKOFF_DELAYS[0]', () => {
|
|
expect(retryConfigs.rsocket.baseDelay).toBe(RETRY_BACKOFF_DELAYS[0])
|
|
})
|
|
|
|
it('should have maxDelay equal to last RETRY_BACKOFF_DELAYS element', () => {
|
|
expect(retryConfigs.rsocket.maxDelay).toBe(RETRY_BACKOFF_DELAYS[RETRY_BACKOFF_DELAYS.length - 1])
|
|
})
|
|
|
|
it('should have jitter equal to RETRY_JITTER_FACTOR', () => {
|
|
expect(retryConfigs.rsocket.jitter).toBe(RETRY_JITTER_FACTOR)
|
|
})
|
|
})
|