diff --git a/crates/bindings-typescript/src/sdk/connection_manager.ts b/crates/bindings-typescript/src/sdk/connection_manager.ts index 211b01b5add..5faa7d774aa 100644 --- a/crates/bindings-typescript/src/sdk/connection_manager.ts +++ b/crates/bindings-typescript/src/sdk/connection_manager.ts @@ -48,17 +48,65 @@ type Listener = () => void; export const CONNECTION_MANAGER_RECONNECT_BASE_DELAY_MS = 1000; export const CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS = 30_000; +export const CONNECTION_MANAGER_RECONNECT_MIN_BASE_DELAY_MS = 500; +export const CONNECTION_MANAGER_RECONNECT_MIN_MAX_DELAY_MS = 10_000; +export const CONNECTION_MANAGER_RECONNECT_MAX_ALLOWED_DELAY_MS = 5 * 60_000; + +/** + * Options controlling the auto-reconnect backoff. Set them per connection via + * {@link DbConnectionBuilder.withReconnectOptions}. Any field left undefined + * falls back to the module default. + */ +export type ReconnectOptions = { + /** + * Delay before the first reconnect attempt, in milliseconds. This is the + * minimum backoff; each subsequent attempt doubles it up to `maxDelayMs`. + * Defaults to {@link CONNECTION_MANAGER_RECONNECT_BASE_DELAY_MS} (1000). + */ + baseDelayMs?: number; + /** + * Maximum delay between reconnect attempts, in milliseconds. The exponential + * backoff is capped here. Defaults to + * {@link CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS} (30000). + */ + maxDelayMs?: number; +}; + +function normalizeReconnectDelay( + value: number | undefined, + fallback: number, + minimum: number +): number { + if (value === undefined || !Number.isSafeInteger(value)) { + return fallback; + } + return Math.min( + Math.max(value, minimum), + CONNECTION_MANAGER_RECONNECT_MAX_ALLOWED_DELAY_MS + ); +} /** * Computes the reconnect delay for the given attempt (0-based) using * exponential backoff: the base delay doubles with each consecutive failed - * attempt, capped at the maximum delay. + * attempt, capped at the maximum delay. `options` overrides the base and/or + * maximum delay; unset or malformed fields use safe defaults or bounds. */ -export function connectionManagerReconnectDelayMs(attempt: number): number { - return Math.min( - CONNECTION_MANAGER_RECONNECT_BASE_DELAY_MS * 2 ** attempt, - CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS +export function connectionManagerReconnectDelayMs( + attempt: number, + options?: ReconnectOptions +): number { + const baseDelay = normalizeReconnectDelay( + options?.baseDelayMs, + CONNECTION_MANAGER_RECONNECT_BASE_DELAY_MS, + CONNECTION_MANAGER_RECONNECT_MIN_BASE_DELAY_MS + ); + const maxDelay = normalizeReconnectDelay( + options?.maxDelayMs, + CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS, + CONNECTION_MANAGER_RECONNECT_MIN_MAX_DELAY_MS ); + return Math.min(baseDelay * 2 ** attempt, maxDelay); } type ManagedConnection = { @@ -336,7 +384,10 @@ class ConnectionManagerImpl { return; } - const delay = connectionManagerReconnectDelayMs(managed.reconnectAttempt); + const delay = connectionManagerReconnectDelayMs( + managed.reconnectAttempt, + managed.builder?.getReconnectOptions() + ); managed.reconnectAttempt += 1; managed.reconnectTimer = setTimeout(() => { managed.reconnectTimer = null; diff --git a/crates/bindings-typescript/src/sdk/db_connection_builder.ts b/crates/bindings-typescript/src/sdk/db_connection_builder.ts index 282cab02d37..81b5b998d60 100644 --- a/crates/bindings-typescript/src/sdk/db_connection_builder.ts +++ b/crates/bindings-typescript/src/sdk/db_connection_builder.ts @@ -9,6 +9,14 @@ import type { import { ensureMinimumVersionOrThrow } from './version'; import { WebsocketDecompressAdapter } from './websocket_decompress_adapter'; import type { WebSocketFactory } from './ws'; +import { + CONNECTION_MANAGER_RECONNECT_BASE_DELAY_MS, + CONNECTION_MANAGER_RECONNECT_MAX_ALLOWED_DELAY_MS, + CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS, + CONNECTION_MANAGER_RECONNECT_MIN_BASE_DELAY_MS, + CONNECTION_MANAGER_RECONNECT_MIN_MAX_DELAY_MS, + type ReconnectOptions, +} from './connection_manager'; /** * The database client connection to a SpacetimeDB server. @@ -28,6 +36,7 @@ export class DbConnectionBuilder> { #lightMode: boolean = false; #confirmedReads?: boolean; #createWSFn: WebSocketFactory; + #reconnectOptions?: ReconnectOptions; /** * Creates a new `DbConnectionBuilder` database client and set the initial parameters. @@ -88,6 +97,66 @@ export class DbConnectionBuilder> { return this; } + /** + * Configure the auto-reconnect backoff. `baseDelayMs` is the delay before the + * first retry (the minimum backoff); it doubles on each consecutive failure + * up to `maxDelayMs`. Unset fields keep the defaults (1000 ms base, 30000 ms + * max). `baseDelayMs` must be between 500 ms and 5 minutes, and + * `maxDelayMs` must be between 10 seconds and 5 minutes. + * + * Auto-reconnect is performed by the `ConnectionManager`, so these options + * apply to any connection retained through it. That currently means the + * `SpacetimeDBProvider` for React and Solid. It has no effect on a connection + * built and used directly via `build()`, which does not auto-reconnect. + * + * @example + * + * ```ts + * DbConnection.builder().withReconnectOptions({ + * baseDelayMs: 500, + * maxDelayMs: 10_000, + * }); + * ``` + */ + withReconnectOptions(options: ReconnectOptions): this { + const { baseDelayMs, maxDelayMs } = options; + if ( + baseDelayMs !== undefined && + (!Number.isSafeInteger(baseDelayMs) || + baseDelayMs < CONNECTION_MANAGER_RECONNECT_MIN_BASE_DELAY_MS || + baseDelayMs > CONNECTION_MANAGER_RECONNECT_MAX_ALLOWED_DELAY_MS) + ) { + throw new TypeError( + `withReconnectOptions: baseDelayMs must be a safe integer between ${CONNECTION_MANAGER_RECONNECT_MIN_BASE_DELAY_MS} and ${CONNECTION_MANAGER_RECONNECT_MAX_ALLOWED_DELAY_MS} milliseconds` + ); + } + if ( + maxDelayMs !== undefined && + (!Number.isSafeInteger(maxDelayMs) || + maxDelayMs < CONNECTION_MANAGER_RECONNECT_MIN_MAX_DELAY_MS || + maxDelayMs > CONNECTION_MANAGER_RECONNECT_MAX_ALLOWED_DELAY_MS) + ) { + throw new TypeError( + `withReconnectOptions: maxDelayMs must be a safe integer between ${CONNECTION_MANAGER_RECONNECT_MIN_MAX_DELAY_MS} and ${CONNECTION_MANAGER_RECONNECT_MAX_ALLOWED_DELAY_MS} milliseconds` + ); + } + // Resolve against the defaults the ConnectionManager will apply, so that + // supplying only one of the two fields is still validated against the value + // actually used for the other. e.g. baseDelayMs: 40000 with the default + // maxDelayMs of 30000 must be rejected. + const resolvedBaseDelayMs = + baseDelayMs ?? CONNECTION_MANAGER_RECONNECT_BASE_DELAY_MS; + const resolvedMaxDelayMs = + maxDelayMs ?? CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS; + if (resolvedBaseDelayMs > resolvedMaxDelayMs) { + throw new TypeError( + 'withReconnectOptions: baseDelayMs must be less than or equal to maxDelayMs' + ); + } + this.#reconnectOptions = { baseDelayMs, maxDelayMs }; + return this; + } + /** * Set the compression algorithm to use for the connection. * @@ -246,6 +315,11 @@ export class DbConnectionBuilder> { return this.#nameOrAddress ?? ''; } + /** The reconnect backoff overrides set via {@link withReconnectOptions}, if any. */ + getReconnectOptions(): ReconnectOptions | undefined { + return this.#reconnectOptions ? { ...this.#reconnectOptions } : undefined; + } + /** * Builds a new `DbConnection` with the parameters set on this `DbConnectionBuilder` and attempts to connect to the SpacetimeDB server. * diff --git a/crates/bindings-typescript/src/sdk/index.ts b/crates/bindings-typescript/src/sdk/index.ts index 3afeba97a17..11ce7718cbe 100644 --- a/crates/bindings-typescript/src/sdk/index.ts +++ b/crates/bindings-typescript/src/sdk/index.ts @@ -1,5 +1,6 @@ // Should be at the top as other modules depend on it export * from './db_connection_impl.ts'; +export type { ReconnectOptions } from './connection_manager.ts'; export * from './client_cache.ts'; export * from './message_types.ts'; export * from '../lib/errors.ts'; diff --git a/crates/bindings-typescript/tests/connection_manager_liveness.test.ts b/crates/bindings-typescript/tests/connection_manager_liveness.test.ts index ea24ea6887e..b216739777f 100644 --- a/crates/bindings-typescript/tests/connection_manager_liveness.test.ts +++ b/crates/bindings-typescript/tests/connection_manager_liveness.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { ConnectionId } from '../src'; -import { connectionManagerReconnectDelayMs } from '../src/sdk/connection_manager.ts'; +import { + connectionManagerReconnectDelayMs, + type ReconnectOptions, +} from '../src/sdk/connection_manager.ts'; // These tests exercise the page-resume + zombie-socket liveness recovery in the // ConnectionManager. That logic wires itself to `document`/`window` events in @@ -76,6 +79,10 @@ class MockBuilder { buildCount = 0; connections: MockConnection[] = []; + getReconnectOptions(): ReconnectOptions | undefined { + return undefined; + } + #onConnect = new Set<(conn: MockConnection) => void>(); #onDisconnect = new Set< (ctx: ErrorContextInterface, error?: Error) => void diff --git a/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts b/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts index 88b66811ac1..91f5652da2f 100644 --- a/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts +++ b/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts @@ -1,7 +1,11 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { ConnectionId } from '../src'; import { + CONNECTION_MANAGER_RECONNECT_BASE_DELAY_MS, + CONNECTION_MANAGER_RECONNECT_MAX_ALLOWED_DELAY_MS, CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS, + CONNECTION_MANAGER_RECONNECT_MIN_BASE_DELAY_MS, + CONNECTION_MANAGER_RECONNECT_MIN_MAX_DELAY_MS, connectionManagerReconnectDelayMs, ConnectionManager, } from '../src/sdk/connection_manager.ts'; @@ -107,6 +111,14 @@ class MockConnection { class MockBuilder { buildCount = 0; connections: MockConnection[] = []; + reconnectOptions: { baseDelayMs?: number; maxDelayMs?: number } | undefined = + undefined; + + getReconnectOptions(): + | { baseDelayMs?: number; maxDelayMs?: number } + | undefined { + return this.reconnectOptions; + } #onConnectCallbacks = new Set<(conn: MockConnection) => void>(); #onDisconnectCallbacks = new Set< @@ -417,6 +429,60 @@ describe('ConnectionManager retained reconnect behavior', () => { CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS ); }); + + test('reconnect delay honors baseDelayMs and maxDelayMs overrides', () => { + const options = { baseDelayMs: 500, maxDelayMs: 10_000 }; + expect(connectionManagerReconnectDelayMs(0, options)).toBe(500); + expect(connectionManagerReconnectDelayMs(1, options)).toBe(1000); + expect(connectionManagerReconnectDelayMs(2, options)).toBe(2000); + expect(connectionManagerReconnectDelayMs(3, options)).toBe(4000); + expect(connectionManagerReconnectDelayMs(4, options)).toBe(8000); + // Capped at maxDelayMs. + expect(connectionManagerReconnectDelayMs(5, options)).toBe(10_000); + // An unset field falls back to the module default. + expect(connectionManagerReconnectDelayMs(0, { maxDelayMs: 10_000 })).toBe( + CONNECTION_MANAGER_RECONNECT_BASE_DELAY_MS + ); + }); + + test('reconnect delay normalizes malformed options to safe bounds', () => { + expect(connectionManagerReconnectDelayMs(0, { baseDelayMs: 0 })).toBe( + CONNECTION_MANAGER_RECONNECT_MIN_BASE_DELAY_MS + ); + expect( + connectionManagerReconnectDelayMs(0, { baseDelayMs: Infinity }) + ).toBe(CONNECTION_MANAGER_RECONNECT_BASE_DELAY_MS); + expect( + connectionManagerReconnectDelayMs(100, { maxDelayMs: 999_999 }) + ).toBe(CONNECTION_MANAGER_RECONNECT_MAX_ALLOWED_DELAY_MS); + expect(connectionManagerReconnectDelayMs(100, { maxDelayMs: -1 })).toBe( + CONNECTION_MANAGER_RECONNECT_MIN_MAX_DELAY_MS + ); + }); + + test('retained reconnect uses the builder reconnect options for backoff', () => { + const key = nextKey(); + const builder = new MockBuilder(); + builder.reconnectOptions = { baseDelayMs: 500, maxDelayMs: 10_000 }; + + const first = retainMock(key, builder); + first.simulateDisconnect(); + + // First retry fires after the configured base delay, not the 1000 ms default. + vi.advanceTimersByTime(499); + expect(builder.buildCount).toBe(1); + vi.advanceTimersByTime(1); + expect(builder.buildCount).toBe(2); + + // Second failure doubles to 1000 ms. + builder.connections[1].simulateConnectError(new Error('still down')); + vi.advanceTimersByTime(999); + expect(builder.buildCount).toBe(2); + vi.advanceTimersByTime(1); + expect(builder.buildCount).toBe(3); + + ConnectionManager.release(key); + }); }); describe('ConnectionManager.rebuild', () => { diff --git a/crates/bindings-typescript/tests/db_connection.test.ts b/crates/bindings-typescript/tests/db_connection.test.ts index ae4fa365032..66f94de7ec2 100644 --- a/crates/bindings-typescript/tests/db_connection.test.ts +++ b/crates/bindings-typescript/tests/db_connection.test.ts @@ -1069,3 +1069,79 @@ describe('DbConnection', () => { expect(client.db.user.count()).toEqual(2n); }); }); + +describe('DbConnectionBuilder.withReconnectOptions', () => { + test('getReconnectOptions is undefined by default', () => { + expect(DbConnection.builder().getReconnectOptions()).toBeUndefined(); + }); + + test('stores the provided base and max delays', () => { + const options = DbConnection.builder() + .withReconnectOptions({ baseDelayMs: 500, maxDelayMs: 10_000 }) + .getReconnectOptions(); + expect(options?.baseDelayMs).toBe(500); + expect(options?.maxDelayMs).toBe(10_000); + }); + + test('accepts a single field, leaving the other at its default', () => { + const options = DbConnection.builder() + .withReconnectOptions({ maxDelayMs: 10_000 }) + .getReconnectOptions(); + expect(options?.baseDelayMs).toBeUndefined(); + expect(options?.maxDelayMs).toBe(10_000); + }); + + test('accepts reconnect delay bounds', () => { + expect(() => + DbConnection.builder().withReconnectOptions({ + baseDelayMs: 500, + maxDelayMs: 10_000, + }) + ).not.toThrow(); + expect(() => + DbConnection.builder().withReconnectOptions({ + baseDelayMs: 300_000, + maxDelayMs: 300_000, + }) + ).not.toThrow(); + }); + + test('rejects delays outside reconnect delay bounds', () => { + expect(() => + DbConnection.builder().withReconnectOptions({ baseDelayMs: 499 }) + ).toThrow(TypeError); + expect(() => + DbConnection.builder().withReconnectOptions({ maxDelayMs: 9_999 }) + ).toThrow(TypeError); + expect(() => + DbConnection.builder().withReconnectOptions({ baseDelayMs: 300_001 }) + ).toThrow(TypeError); + expect(() => + DbConnection.builder().withReconnectOptions({ maxDelayMs: 300_001 }) + ).toThrow(TypeError); + }); + + test('rejects non-integer, unsafe, or base > max delays', () => { + expect(() => + DbConnection.builder().withReconnectOptions({ baseDelayMs: 500.5 }) + ).toThrow(TypeError); + expect(() => + DbConnection.builder().withReconnectOptions({ + maxDelayMs: Number.MAX_SAFE_INTEGER + 1, + }) + ).toThrow(TypeError); + expect(() => + DbConnection.builder().withReconnectOptions({ + baseDelayMs: 1_000, + maxDelayMs: 500, + }) + ).toThrow(TypeError); + }); + + test('rejects baseDelayMs that conflicts with the default maxDelayMs', () => { + // baseDelayMs above the default maxDelayMs (30000). + expect(() => + DbConnection.builder().withReconnectOptions({ baseDelayMs: 40_000 }) + ).toThrow(TypeError); + }); +});