Skip to content

Reconnect ​

The reconnect helper is dialer-only. An acceptor cannot dial and has no reconnect helper; on the acceptor's side a reconnection is a new connection with a new Peer.

  • connect() raises when the first attempt fails, whatever is configured here. Reconnection applies only to connections that were established and then lost. If the first dial were retried, a typo in the URL would produce no error.
  • The attempt counter resets only on an established connection: the socket is open with the subprotocol accepted, and the hello is acknowledged. If the counter reset when the socket opened, a server that accepts sockets while its backend is down would be redialled at a fixed interval instead of with exponential backoff.
  • A reconnect restores a live socket and an accepted identity, and nothing else. No stream survives, nothing is replayed, the stream id space starts empty, and tags start empty on the acceptor's side, where the connection has a new Peer.
  • Durations are seconds as floats in Python and milliseconds as integers in TypeScript.Reconnect(initial_delay=0.25) and new Reconnect({ initialDelayMs: 250 }) are the same schedule.

The delay before retry number n is:

text
delay = min(initial_delay * factor ** attempts, max_delay)
delay = delay * (1 + uniform(-jitter, +jitter))

Jitter is applied to every computed delay, including the capped ones. Without jitter, peers whose sockets closed at the same instant retry at the same instant, and a restarting server receives all their reconnections at once.

Reconnect (Python) ​

The backoff options. These are the only reconnect settings an application configures.

Signature ​

python
@dataclass(frozen=True, slots=True)
class Reconnect:
    initial_delay: float = 0.25
    factor: float = 2.0
    max_delay: float = 30.0
    jitter: float = 0.3
    #: `None` means unlimited. Exhausting it fires `on_close` once with `will_retry` false and never
    #: dials again (WSM-RCN-044).
    max_attempts: int | None = None

Parameters ​

NameTypeDefaultWhat it does
initial_delayfloat — seconds0.25The unjittered delay before the first retry, in seconds. Every later delay is this multiplied by factor for each failed attempt.
factorfloat2.0The multiplier per failed attempt. 1.0 gives a fixed interval; a value below 1 is rejected.
max_delayfloat — seconds30.0The ceiling on the unjittered delay, in seconds. Jitter is still applied after the cap, so capped retries stay dispersed.
jitterfloat0.3The dispersion, as a fraction between 0 and 1: the delay is multiplied by 1 + uniform(-jitter, +jitter). 0 disables jitter, so peers that lost their sockets at the same instant retry at the same instant.
max_attemptsint | NoneNoneHow many consecutive failed attempts before the helper gives up. None is unlimited. Exhausting it fires on_close exactly once with will_retry false and never dials again; 0 gives up without dialling at all.

Return ​

An immutable Reconnect instance. It is frozen, so a running helper's schedule cannot be changed; construct a new instance instead.

Raises ​

  • ValueError — initial_delay is not greater than zero, factor is not at least 1, max_delay is NaN, or jitter is outside the range 0 to 1. A NaN initial_delay, factor or jitter raises. Validated at construction.

max_delay is not otherwise validated, and max_attempts is not validated: a max_delay below initial_delay caps every delay at max_delay, and a negative max_attempts behaves as 0.

Example ​

python
import asyncio

from muxws import Reconnect, unjittered_delay


async def main() -> None:
    schedule = Reconnect(initial_delay=0.25, factor=2.0, max_delay=30.0, jitter=0.3)
    print([unjittered_delay(attempt, schedule) for attempt in range(9)], "seconds")

    capped = Reconnect(initial_delay=1.0, factor=10.0, max_delay=5.0)
    print([unjittered_delay(attempt, capped) for attempt in range(4)], "seconds")

    try:
        Reconnect(factor=0.5)
    except ValueError as exc:
        print("rejected:", exc)


asyncio.run(main())

Reconnect (TypeScript) ​

A class rather than an interface, so the defaults are defined in one place and the three invalid configurations are rejected at construction.

Signature ​

ts
export class Reconnect {
  readonly initialDelayMs: number;
  readonly factor: number;
  readonly maxDelayMs: number;
  readonly jitter: number;
  readonly maxAttempts: number;

  constructor(options: ReconnectOptions = {});
}

Parameters ​

NameTypeDefaultWhat it does
optionsReconnectOptions{}The five fields below, all optional. See ReconnectOptions.
initialDelayMsnumber — milliseconds250The unjittered delay before the first retry, in milliseconds.
factornumber2The multiplier per failed attempt.
maxDelayMsnumber — milliseconds30_000The ceiling on the unjittered delay, in milliseconds. Jitter is applied after the cap.
jitternumber0.3The dispersion as a fraction between 0 and 1.
maxAttemptsnumberInfinityHow many consecutive failed attempts before giving up. Infinity is unlimited; Python uses None.

Return ​

A Reconnect instance whose five fields are readonly.

Raises ​

  • Error — initialDelayMs is not greater than zero, factor is not at least 1, maxDelayMs is NaN, or jitter is outside 0 to 1. A NaN initialDelayMs, factor or jitter throws. A plain Error, not a muxws error class, because it is a configuration error, not a protocol or connection failure.

maxDelayMs is not otherwise validated, and maxAttempts is not validated: a maxDelayMs below initialDelayMs caps every delay at maxDelayMs, and a negative or NaN maxAttempts behaves as 0.

Example ​

ts
import { Reconnect, unjitteredDelay } from 'muxws';

const schedule = new Reconnect({ initialDelayMs: 250, factor: 2, maxDelayMs: 30_000, jitter: 0.3 });
console.log([0, 1, 2, 3, 4, 5, 6, 7, 8].map((attempt) => unjitteredDelay(attempt, schedule)), 'milliseconds');

const capped = new Reconnect({ initialDelayMs: 1000, factor: 10, maxDelayMs: 5000 });
console.log([0, 1, 2, 3].map((attempt) => unjitteredDelay(attempt, capped)), 'milliseconds');

try {
  // eslint-disable-next-line no-new
  new Reconnect({ factor: 0.5 });
} catch (error) {
  console.log('rejected:', (error as Error).message);
}
console.log('unlimited by default:', new Reconnect().maxAttempts);

ReconnectOptions (TypeScript) ​

The options object of new Reconnect(). Every field is optional and has a default.

Signature ​

ts
export interface ReconnectOptions {
  initialDelayMs?: number;
  factor?: number;
  maxDelayMs?: number;
  jitter?: number;
  maxAttempts?: number;
}

Parameters ​

NameTypeDefaultWhat it does
initialDelayMsnumber — milliseconds250The unjittered delay before the first retry, in milliseconds.
factornumber2The multiplier per failed attempt.
maxDelayMsnumber — milliseconds30_000The ceiling on the unjittered delay, in milliseconds.
jitternumber0.3The dispersion, a fraction between 0 and 1.
maxAttemptsnumberInfinityConsecutive failed attempts before giving up.

Return ​

None: ReconnectOptions is an interface, not a call.

Raises ​

Raises: nothing. The values are validated by Reconnect's constructor.

Example ​

ts
import { Reconnect, type ReconnectOptions } from 'muxws';

const patient: ReconnectOptions = { initialDelayMs: 500, maxDelayMs: 60_000, jitter: 0.5 };
const impatient: ReconnectOptions = { initialDelayMs: 50, maxAttempts: 3 };

console.log(new Reconnect(patient).maxDelayMs, 'milliseconds');
console.log(new Reconnect(impatient).maxAttempts, 'attempts before giving up');
console.log(new Reconnect({}).initialDelayMs, 'milliseconds by default');

Hello (Python) ​

The opening payload sent on every connection this peer makes.

connect(hello=..., hello_headers=...) constructs one; the class is exported so an application can construct and inspect one. The payload is captured once, by value: it is deep-copied at construction and deep-copied again for every replay, so neither a later mutation of the application's object nor anything the codec does to one send changes what the next connection sends.

The hello is an ordinary open(payload, headers=..., end=True). Nothing marks it on the wire, and the acceptor's on_stream handler receives it like any other stream. The acknowledgement is that handler returning, which ends the stream implicitly; no application code sends it.

A credential must not be carried in the hello: authentication belongs to the handshake.

Signature ​

python
@dataclass(frozen=True, slots=True)
class Hello:
    payload: Any = None
    headers: dict[str, Any] | None = None
    timeout: float = 10.0
    #: The copies taken at construction. Only these reach the wire; `payload` and `headers` are not
    #: read again.
    _captured_payload: Any = field(init=False, repr=False, compare=False, default=None)
    _captured_headers: dict[str, Any] | None = field(init=False, repr=False, compare=False, default=None)

Parameters ​

NameTypeDefaultWhat it does
payloadAnyNoneThe hello payload, deep-copied at construction.
headersdict[str, Any] | NoneNoneHeaders for the hello's open frame, deep-copied at construction.
timeoutfloat — seconds10.0How long, in seconds, the hello may go unacknowledged before the attempt fails. On the first connection connect() raises that failure; on a later one it counts as a failed attempt and increments the counter.
_captured_payloadAnyNoneNot passed by a caller (init=False). The deep copy taken at construction; the only payload value sent on the wire.
_captured_headersdict[str, Any] | NoneNoneThe same for the headers.

It has three read-only properties: configured (true when a payload or headers were given; a peer with no hello sends none and is established when its socket opens), payload_for_wire and headers_for_wire, each returning a fresh copy per read.

Return ​

An immutable Hello.

Raises ​

  • Whatever copy.deepcopy raises for a value that cannot be copied, for example a TypeError for a payload holding a socket or a lock. It is raised at construction, not during a later reconnect.

Example ​

python
import asyncio

from muxws import Hello


async def main() -> None:
    identity = {"client": "docs", "rooms": ["general"]}
    hello = Hello(payload=identity, headers={"v": 1}, timeout=10.0)

    print(hello.configured, hello.timeout, "seconds")

    identity["rooms"].append("secret")
    print("captured by value:", hello.payload_for_wire)
    print("a fresh copy per replay:", hello.payload_for_wire is not hello.payload_for_wire)
    print("headers:", hello.headers_for_wire)
    print("no hello configured:", Hello().configured)


asyncio.run(main())

Hello (TypeScript) ​

The same contract as the Python class, with structuredClone in place of copy.deepcopy.

Signature ​

ts
export class Hello {
  readonly timeoutMs: number;

  constructor(options: HelloOptions = {});

  get configured(): boolean;
  payloadForWire(): unknown;
  headersForWire(): Record<string, unknown> | undefined;
}

Parameters ​

NameTypeDefaultWhat it does
optionsHelloOptions{}payload, headers and timeoutMs. See HelloOptions.

Return ​

A Hello. configured is true when a payload or headers were given. payloadForWire() and headersForWire() each return a fresh deep copy per call, so the object passed to the codec on one connection is never the object the next connection sends. headersForWire() returns undefined when no headers were given.

Raises ​

  • DataCloneError — structuredClone cannot copy the payload or the headers, for example a function or a Symbol, or an object that contains one. Thrown at construction, not during a later reconnect. A class instance does not throw; it is copied as a plain object without its prototype.

Example ​

ts
import { Hello } from 'muxws';

const identity = { client: 'docs', rooms: ['general'] };
const hello = new Hello({ payload: identity, headers: { v: 1 }, timeoutMs: 10_000 });

console.log(hello.configured, hello.timeoutMs, 'milliseconds');

identity.rooms.push('secret');
console.log('captured by value:', hello.payloadForWire());
console.log('a fresh copy per replay:', hello.payloadForWire() !== hello.payloadForWire());
console.log('headers:', hello.headersForWire());
console.log('no hello configured:', new Hello().configured);

try {
  // eslint-disable-next-line no-new
  new Hello({ payload: () => undefined });
} catch (error) {
  console.log('rejected at construction:', (error as Error).name);
}

HelloOptions (TypeScript) ​

Signature ​

ts
export interface HelloOptions {
  payload?: unknown;
  headers?: Record<string, unknown> | null;
  timeoutMs?: number;
}

Parameters ​

NameTypeDefaultWhat it does
payloadunknownnullThe hello payload, deep-copied at construction.
headersRecord<string, unknown> | nullnullHeaders for the hello's open frame, deep-copied at construction.
timeoutMsnumber — millisecondsDEFAULT_HELLO_TIMEOUT_MS (10000)How long, in milliseconds, the hello may go unacknowledged before the attempt fails. Python's timeout=10.0 seconds.

Return ​

None: HelloOptions is an interface, not a call.

Raises ​

Raises: nothing.

Example ​

ts
import { Hello, type HelloOptions } from 'muxws';

const options: HelloOptions = { payload: { client: 'docs' }, headers: { v: 1 }, timeoutMs: 10_000 };
const none: HelloOptions = {};

console.log(new Hello(options).configured, options.timeoutMs, 'milliseconds');
console.log(new Hello(none).configured, new Hello(none).timeoutMs, 'milliseconds by default');

backoff_delay() (Python) ​

The delay before retry number attempts, jittered. The helper calls it; it is exported so an application can compute when the next retry will happen.

Signature ​

python
def backoff_delay(attempts: int, options: Reconnect, draw: RandomDraw = _uniform) -> float:

Parameters ​

NameTypeDefaultWhat it does
attemptsintrequiredHow many consecutive attempts have already failed. 0 is the first retry after a loss.
optionsReconnectrequiredThe schedule to compute from.
drawRandomDraw_uniformA callable returning a float in the range -1 to 1, the jitter draw. Injectable, so the schedule can be tested as a pure function. The default uses random, not secrets: jitter spreads reconnects over time and has no security function.

Return ​

float: the delay in seconds, never negative; the jittered value is clamped at zero.

Raises ​

  • ValueError — attempts is negative.

Example ​

python
import asyncio

from muxws import backoff_delay, Reconnect


async def main() -> None:
    schedule = Reconnect(initial_delay=1.0, factor=2.0, max_delay=30.0, jitter=0.5)

    print("no jitter drawn:", [backoff_delay(n, schedule, draw=lambda: 0.0) for n in range(4)], "seconds")
    print("jitter at its lowest:", backoff_delay(0, schedule, draw=lambda: -1.0), "seconds")
    print("jitter at its highest:", backoff_delay(0, schedule, draw=lambda: 1.0), "seconds")
    print("the cap is jittered too:", backoff_delay(20, schedule, draw=lambda: -1.0), "seconds")

    try:
        backoff_delay(-1, schedule)
    except ValueError as exc:
        print("rejected:", exc)


asyncio.run(main())

backoffDelay() (TypeScript) ​

Signature ​

ts
export function backoffDelay(attempts: number, options: Reconnect, draw: RandomDraw = uniform): number;

Parameters ​

NameTypeDefaultWhat it does
attemptsnumberrequiredHow many consecutive attempts have already failed.
optionsReconnectrequiredThe schedule to compute from.
drawRandomDrawuniformA callable returning a number in the range -1 to 1. The default uses Math.random, not crypto.getRandomValues.

Return ​

number: the delay in milliseconds, clamped at zero.

Raises ​

  • Error — attempts is negative.

Example ​

ts
import { backoffDelay, Reconnect } from 'muxws';

const schedule = new Reconnect({ initialDelayMs: 1000, factor: 2, maxDelayMs: 30_000, jitter: 0.5 });

console.log('no jitter drawn:', [0, 1, 2, 3].map((n) => backoffDelay(n, schedule, () => 0)), 'milliseconds');
console.log('jitter at its lowest:', backoffDelay(0, schedule, () => -1), 'milliseconds');
console.log('jitter at its highest:', backoffDelay(0, schedule, () => 1), 'milliseconds');
console.log('the cap is jittered too:', backoffDelay(20, schedule, () => -1), 'milliseconds');

try {
  backoffDelay(-1, schedule);
} catch (error) {
  console.log('rejected:', (error as Error).message);
}

unjittered_delay() (Python) ​

The delay before jitter and after the cap. It is deterministic.

Signature ​

python
def unjittered_delay(attempts: int, options: Reconnect) -> float:

Parameters ​

NameTypeDefaultWhat it does
attemptsintrequiredHow many consecutive attempts have already failed.
optionsReconnectrequiredThe schedule to compute from.

Return ​

float: min(initial_delay * factor ** attempts, max_delay), in seconds. When factor ** attempts overflows the float range, the result is max_delay.

Raises ​

Raises: nothing. Unlike backoff_delay, it does not check attempts: a negative value produces a delay smaller than initial_delay.

Example ​

python
import asyncio

from muxws import Reconnect, unjittered_delay


async def main() -> None:
    schedule = Reconnect(initial_delay=0.25, factor=2.0, max_delay=30.0)
    print([unjittered_delay(attempt, schedule) for attempt in range(10)], "seconds")
    print("the cap holds:", unjittered_delay(50, schedule), "seconds")


asyncio.run(main())

unjitteredDelay() (TypeScript) ​

Signature ​

ts
export function unjitteredDelay(attempts: number, options: Reconnect): number;

Parameters ​

NameTypeDefaultWhat it does
attemptsnumberrequiredHow many consecutive attempts have already failed.
optionsReconnectrequiredThe schedule to compute from.

Return ​

number: min(initialDelayMs * factor ** attempts, maxDelayMs), in milliseconds.

Raises ​

Raises: nothing.

Example ​

ts
import { Reconnect, unjitteredDelay } from 'muxws';

const schedule = new Reconnect({ initialDelayMs: 250, factor: 2, maxDelayMs: 30_000 });
console.log([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((attempt) => unjitteredDelay(attempt, schedule)), 'milliseconds');
console.log('the cap holds:', unjitteredDelay(50, schedule), 'milliseconds');

should_retry() (Python) ​

Whether another attempt is allowed. The helper calls it before every dial; the attempt limit is computed from the attempt count and the schedule, not stored on the peer.

Signature ​

python
def should_retry(attempts: int, options: Reconnect) -> bool:

Parameters ​

NameTypeDefaultWhat it does
attemptsintrequiredHow many consecutive attempts have already failed.
optionsReconnectrequiredThe schedule holding max_attempts.

Return ​

bool: True while max_attempts is None or attempts is below it. When it returns False, the helper fires on_close once with will_retry false and does not dial again.

Raises ​

Raises: nothing.

Example ​

python
import asyncio

from muxws import Reconnect, should_retry


async def main() -> None:
    limited = Reconnect(max_attempts=3)
    print([should_retry(attempt, limited) for attempt in range(5)])

    print("unlimited by default:", should_retry(1_000_000, Reconnect()))
    print("max_attempts=0 never dials:", should_retry(0, Reconnect(max_attempts=0)))


asyncio.run(main())

shouldRetry() (TypeScript) ​

Signature ​

ts
export function shouldRetry(attempts: number, options: Reconnect): boolean;

Parameters ​

NameTypeDefaultWhat it does
attemptsnumberrequiredHow many consecutive attempts have already failed.
optionsReconnectrequiredThe schedule holding maxAttempts.

Return ​

boolean: attempts < options.maxAttempts, which is always true for the default Infinity.

Raises ​

Raises: nothing.

Example ​

ts
import { Reconnect, shouldRetry } from 'muxws';

const limited = new Reconnect({ maxAttempts: 3 });
console.log([0, 1, 2, 3, 4].map((attempt) => shouldRetry(attempt, limited)));

console.log('unlimited by default:', shouldRetry(1_000_000, new Reconnect()));
console.log('maxAttempts 0 never dials:', shouldRetry(0, new Reconnect({ maxAttempts: 0 })));

See also ​

  • connect() — where a Reconnect and a hello are passed, and why a failed first attempt raises.
  • Peer — on_reconnect, on_close and peer.id, which changes on every new socket.
  • Errors — StreamTimeout and StreamRefused, the two ways a hello fails.

Released under the MIT License.