import { describe, expect, it } from 'vitest'; import { Code, ConnectError } from '@connectrpc/connect'; import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry'; describe('toGatewayError', () => { it('collapses every transport-level failure to a connection code (so it is retried + suppressed)', () => { for (const c of [Code.Unavailable, Code.DeadlineExceeded, Code.Canceled, Code.Aborted, Code.Unknown]) { const e = toGatewayError(new ConnectError('x', c)); expect(e.code).toBe('unavailable'); expect(isConnectionCode(e.code)).toBe(true); } }); it('treats a raw (non-Connect) network error as a connection failure', () => { expect(toGatewayError(new TypeError('Failed to fetch')).code).toBe('unavailable'); }); it('preserves rate-limit, an invalid session, and a genuine server-internal error', () => { expect(toGatewayError(new ConnectError('x', Code.ResourceExhausted)).code).toBe('rate_limited'); expect(toGatewayError(new ConnectError('x', Code.Unauthenticated)).code).toBe('session_invalid'); expect(toGatewayError(new ConnectError('x', Code.Internal)).code).toBe('internal'); expect(isConnectionCode('internal')).toBe(false); }); }); describe('retryable', () => { it('retries any op on a rate-limit rejection (it never reached the backend)', () => { expect(retryable('rate_limited', 'game.submit_play')).toBe(true); expect(retryable('rate_limited', 'games.list')).toBe(true); expect(retryable('rate_limited', 'chat.post')).toBe(true); }); it('retries only read-only ops on a transport unavailable (a mutation could double-apply)', () => { expect(retryable('unavailable', 'games.list')).toBe(true); expect(retryable('unavailable', 'game.state')).toBe(true); expect(retryable('unavailable', 'draft.get')).toBe(true); // mutations are not auto-resent on a dropped connection expect(retryable('unavailable', 'game.submit_play')).toBe(false); expect(retryable('unavailable', 'chat.post')).toBe(false); expect(retryable('unavailable', 'game.hide')).toBe(false); }); it('never retries a domain rejection or an unknown code', () => { expect(retryable('not_your_turn', 'game.submit_play')).toBe(false); expect(retryable('illegal_play', 'game.submit_play')).toBe(false); expect(retryable('not_found', 'game.state')).toBe(false); expect(retryable('internal', 'games.list')).toBe(false); }); }); describe('isConnectionCode', () => { it('flags the transport/rate-limit codes the indicator covers', () => { expect(isConnectionCode('unavailable')).toBe(true); expect(isConnectionCode('rate_limited')).toBe(true); expect(isConnectionCode('not_your_turn')).toBe(false); expect(isConnectionCode('internal')).toBe(false); }); }); describe('backoffMs', () => { it('grows exponentially and caps at 8s (+ jitter under 250ms)', () => { // attempt 1 ~ 500, 2 ~ 1000, 3 ~ 2000, 4 ~ 4000, 5 ~ 8000, then capped. expect(backoffMs(1)).toBeGreaterThanOrEqual(500); expect(backoffMs(1)).toBeLessThan(750); expect(backoffMs(3)).toBeGreaterThanOrEqual(2000); expect(backoffMs(3)).toBeLessThan(2250); for (const n of [5, 6, 10, 20]) { expect(backoffMs(n)).toBeGreaterThanOrEqual(8000); expect(backoffMs(n)).toBeLessThan(8250); } }); });