Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions crates/bindings-typescript/src/sdk/connection_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
Expand Down
58 changes: 58 additions & 0 deletions crates/bindings-typescript/src/sdk/db_connection_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -28,6 +29,7 @@ export class DbConnectionBuilder<DbConnection extends DbConnectionImpl<any>> {
#lightMode: boolean = false;
#confirmedReads?: boolean;
#createWSFn: WebSocketFactory;
#reconnectOptions?: ReconnectOptions;

/**
* Creates a new `DbConnectionBuilder` database client and set the initial parameters.
Expand Down Expand Up @@ -88,6 +90,57 @@ export class DbConnectionBuilder<DbConnection extends DbConnectionImpl<any>> {
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.
*
Expand Down Expand Up @@ -246,6 +299,11 @@ export class DbConnectionBuilder<DbConnection extends DbConnectionImpl<any>> {
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.
*
Expand Down
1 change: 1 addition & 0 deletions crates/bindings-typescript/src/sdk/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<
Expand Down Expand Up @@ -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', () => {
Expand Down
40 changes: 40 additions & 0 deletions crates/bindings-typescript/tests/db_connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});