From 8864794ddc05cbd69924663e63f4b4a8b82b2e6b Mon Sep 17 00:00:00 2001 From: DexterKoelson <271681336+DexterKoelson@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:37:07 -0700 Subject: [PATCH] sdk: make auto-reconnect backoff configurable via the builder Add DbConnectionBuilder.withReconnectOptions({ baseDelayMs, maxDelayMs }) and a getReconnectOptions() accessor. connectionManagerReconnectDelayMs now takes optional overrides, and #scheduleReconnect reads them from the retained builder, so the exponential backoff is configurable per connection instead of fixed at the module 1000ms/30000ms constants. baseDelayMs is the delay before the first retry (the minimum backoff) and doubles each attempt up to maxDelayMs. Unset fields keep the defaults. Invalid values (non-positive, non-finite, or base > max) throw. The ReconnectOptions type is exported from the package root. Applies to connections retained through the ConnectionManager (the React and Solid providers); a connection built and used directly does not auto-reconnect. --- .../src/sdk/connection_manager.ts | 42 +++++++++++--- .../src/sdk/db_connection_builder.ts | 58 +++++++++++++++++++ crates/bindings-typescript/src/sdk/index.ts | 1 + .../connection_manager_reconnect.test.ts | 46 +++++++++++++++ .../tests/db_connection.test.ts | 40 +++++++++++++ 5 files changed, 180 insertions(+), 7 deletions(-) diff --git a/crates/bindings-typescript/src/sdk/connection_manager.ts b/crates/bindings-typescript/src/sdk/connection_manager.ts index 2ee3b086758..7be9e4bc298 100644 --- a/crates/bindings-typescript/src/sdk/connection_manager.ts +++ b/crates/bindings-typescript/src/sdk/connection_manager.ts @@ -49,16 +49,41 @@ type Listener = () => void; export const CONNECTION_MANAGER_RECONNECT_BASE_DELAY_MS = 1000; export const CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS = 30_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; +}; + /** * 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 fields use the module defaults. */ -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 = + options?.baseDelayMs ?? CONNECTION_MANAGER_RECONNECT_BASE_DELAY_MS; + const maxDelay = + options?.maxDelayMs ?? CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS; + return Math.min(baseDelay * 2 ** attempt, maxDelay); } type ManagedConnection = { @@ -244,7 +269,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..8edac941ecf 100644 --- a/crates/bindings-typescript/src/sdk/db_connection_builder.ts +++ b/crates/bindings-typescript/src/sdk/db_connection_builder.ts @@ -9,6 +9,7 @@ import type { import { ensureMinimumVersionOrThrow } from './version'; import { WebsocketDecompressAdapter } from './websocket_decompress_adapter'; import type { WebSocketFactory } from './ws'; +import type { ReconnectOptions } from './connection_manager'; /** * The database client connection to a SpacetimeDB server. @@ -28,6 +29,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 +90,57 @@ 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). + * + * 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.isFinite(baseDelayMs) || baseDelayMs <= 0) + ) { + throw new TypeError( + 'withReconnectOptions: baseDelayMs must be a positive number' + ); + } + if ( + maxDelayMs !== undefined && + (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0) + ) { + throw new TypeError( + 'withReconnectOptions: maxDelayMs must be a positive number' + ); + } + if ( + baseDelayMs !== undefined && + maxDelayMs !== undefined && + baseDelayMs > maxDelayMs + ) { + 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 +299,11 @@ export class DbConnectionBuilder> { return this.#nameOrAddress ?? ''; } + /** The reconnect backoff overrides set via {@link withReconnectOptions}, if any. */ + getReconnectOptions(): ReconnectOptions | undefined { + return this.#reconnectOptions; + } + /** * 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_reconnect.test.ts b/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts index 88b66811ac1..ebe4657cd88 100644 --- a/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts +++ b/crates/bindings-typescript/tests/connection_manager_reconnect.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { ConnectionId } from '../src'; import { + CONNECTION_MANAGER_RECONNECT_BASE_DELAY_MS, CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS, connectionManagerReconnectDelayMs, ConnectionManager, @@ -107,6 +108,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 +426,43 @@ describe('ConnectionManager retained reconnect behavior', () => { CONNECTION_MANAGER_RECONNECT_MAX_DELAY_MS ); }); + + test('reconnect delay honors baseDelayMs and maxDelayMs overrides', () => { + const options = { baseDelayMs: 200, maxDelayMs: 1000 }; + expect(connectionManagerReconnectDelayMs(0, options)).toBe(200); + expect(connectionManagerReconnectDelayMs(1, options)).toBe(400); + expect(connectionManagerReconnectDelayMs(2, options)).toBe(800); + // Capped at maxDelayMs. + expect(connectionManagerReconnectDelayMs(3, options)).toBe(1000); + // An unset field falls back to the module default. + expect(connectionManagerReconnectDelayMs(0, { maxDelayMs: 5000 })).toBe( + CONNECTION_MANAGER_RECONNECT_BASE_DELAY_MS + ); + }); + + test('retained reconnect uses the builder reconnect options for backoff', () => { + const key = nextKey(); + const builder = new MockBuilder(); + builder.reconnectOptions = { baseDelayMs: 200, maxDelayMs: 1000 }; + + const first = retainMock(key, builder); + first.simulateDisconnect(); + + // First retry fires after the configured base delay, not the 1000 ms default. + vi.advanceTimersByTime(199); + expect(builder.buildCount).toBe(1); + vi.advanceTimersByTime(1); + expect(builder.buildCount).toBe(2); + + // Second failure doubles to 400 ms. + builder.connections[1].simulateConnectError(new Error('still down')); + vi.advanceTimersByTime(399); + 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 e7fb4215079..767cae43fff 100644 --- a/crates/bindings-typescript/tests/db_connection.test.ts +++ b/crates/bindings-typescript/tests/db_connection.test.ts @@ -1037,3 +1037,43 @@ 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: 5_000 }) + .getReconnectOptions(); + expect(options?.baseDelayMs).toBeUndefined(); + expect(options?.maxDelayMs).toBe(5_000); + }); + + test('rejects non-positive, non-finite, or base > max delays', () => { + expect(() => + DbConnection.builder().withReconnectOptions({ baseDelayMs: 0 }) + ).toThrow(TypeError); + expect(() => + DbConnection.builder().withReconnectOptions({ maxDelayMs: -1 }) + ).toThrow(TypeError); + expect(() => + DbConnection.builder().withReconnectOptions({ baseDelayMs: Infinity }) + ).toThrow(TypeError); + expect(() => + DbConnection.builder().withReconnectOptions({ + baseDelayMs: 1_000, + maxDelayMs: 500, + }) + ).toThrow(TypeError); + }); +});