a57fd355ba
Introduce a minimum-supported-client gate so a future incompatible wire change can turn away installed builds too old to speak it, cleanly, instead of letting them crash on decode. It rides the outermost stable layer (an HTTP header), never the FlatBuffers payload. Gateway: - New internal/clientver: dependency-free parse + compare of the leading MAJOR.MINOR.PATCH (a git-describe suffix is tolerated). - GATEWAY_MIN_CLIENT_VERSION config (empty => gate dormant; validated at load). - connectsrv checks the X-Client-Version header before decoding the payload: Execute returns result_code="update_required" (before the registry lookup), Subscribe returns FailedPrecondition. It fails open on an absent or garbled header — the header is a client-controlled compatibility signal, not an access control. Client: - Attach X-Client-Version on every call. - A terminal update.svelte.ts store + a non-dismissable UpdateOverlay (native opens VITE_STORE_URL, web reloads); retry.ts maps FailedPrecondition to the update_required sentinel; a mock __update hook drives the e2e. Wire-additive and contour-safe: no FBS/proto regen, no schema migration; the gate stays dormant until GATEWAY_MIN_CLIENT_VERSION is deliberately set, so web / VK / Telegram behaviour is unchanged. The silent reconciliation seam is deferred to the offline-first work (its only caller). Tests: Go clientver/config/connectsrv gate tests, retry.test.ts, Playwright update.spec.ts.
77 lines
3.5 KiB
TypeScript
77 lines
3.5 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);
|
|
});
|
|
|
|
it('maps the Subscribe FailedPrecondition to the update_required sentinel (client too old)', () => {
|
|
expect(toGatewayError(new ConnectError('x', Code.FailedPrecondition)).code).toBe('update_required');
|
|
});
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
});
|