84ecc85f51
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.
73 lines
3.3 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|