Files
scrabble-game/ui/src/lib/retry.test.ts
T
Ilia Denisov 84ecc85f51 Stage 17 #2 fix: connection failures show only the spinner, never a toast
A dropped/reset/timed-out connection can surface as a Connect code other than
Unavailable (Canceled/DeadlineExceeded/Unknown/…) which fell through to the generic
'internal' -> a red 'something went wrong' toast appeared alongside the Connecting
spinner. Now toGatewayError (moved to the pure retry.ts, unit-tested) collapses every
transport-level code to 'unavailable' so it is retried + flips offline; and handleError
suppresses the toast for any connection code AND whenever the app is mid-reconnect
(!connection.online), covering the race where a unary error lands before the stream
reports the drop. Genuine server-internal / domain errors still toast while online.
2026-06-09 07:42:47 +02:00

73 lines
3.3 KiB
TypeScript

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);
}
});
});