Skip to content

Commit

Permalink
feat(client): Retry with randomised exponential backoff or provide yo…
Browse files Browse the repository at this point in the history
…ur own strategy (enisdenjo#84)

BREAKING CHANGE: Client `retryTimeout` option has been replaced with the new `retryWait`.

`retryWait` allows you to control the retry timeout strategy by resolving the returned promise when ready. The default implements the randomised exponential backoff like so:
```ts
// this is the default
const retryWait = async function randomisedExponentialBackoff(retries: number) {
  let retryDelay = 1000; // start with 1s delay
  for (let i = 0; i < retries; i++) {
    retryDelay *= 2; // square `retries` times
  }
  await new Promise((resolve) =>
    setTimeout(
      // resolve pending promise with added random timeout from 300ms to 3s
      resolve,
      retryDelay + Math.floor(Math.random() * (3000 - 300) + 300),
    ),
  );
};
```
  • Loading branch information
enisdenjo authored Dec 9, 2020
1 parent 71d8174 commit d3e7a17
Show file tree
Hide file tree
Showing 4 changed files with 116 additions and 58 deletions.
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,33 @@ const link = new WebSocketLink({

</details>

<details id="retry-strategy">
<summary><a href="#retry-strategy">🔗</a> Client usage with custom retry timeout strategy</summary>

```typescript
import { createClient } from 'graphql-ws';

const client = createClient({
url: 'wss://i.want.retry/control/graphql',
// this is the default
retryWait: async function randomisedExponentialBackoff(retries: number) {
let retryDelay = 1000; // start with 1s delay
for (let i = 0; i < retries; i++) {
retryDelay *= 2; // square `retries` times
}
await new Promise((resolve) =>
setTimeout(
// resolve pending promise with added random timeout from 300ms to 3s
resolve,
retryDelay + Math.floor(Math.random() * (3000 - 300) + 300),
),
);
},
});
```

</details>

<details id="browser">
<summary><a href="#browser">🔗</a> Client usage in browser</summary>

Expand Down
11 changes: 6 additions & 5 deletions docs/interfaces/_client_.clientoptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Configuration used for the GraphQL over WebSocket client.
* [lazy](_client_.clientoptions.md#lazy)
* [on](_client_.clientoptions.md#on)
* [retryAttempts](_client_.clientoptions.md#retryattempts)
* [retryTimeout](_client_.clientoptions.md#retrytimeout)
* [retryWait](_client_.clientoptions.md#retrywait)
* [url](_client_.clientoptions.md#url)
* [webSocketImpl](_client_.clientoptions.md#websocketimpl)

Expand Down Expand Up @@ -111,13 +111,14 @@ These events are reported immediately and the client will not reconnect.

___

### retryTimeout
### retryWait

`Optional` **retryTimeout**: undefined \| number
`Optional` **retryWait**: undefined \| (retries: number) => Promise\<void>

How long should the client wait until attempting to retry.
Control the wait time between retries. You may implement your own strategy
by timing the resolution of the returned promise with the retries count.

**`default`** 3 * 1000 (3 seconds)
**`default`** Randomised exponential backoff

___

Expand Down
78 changes: 56 additions & 22 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,12 @@ export interface ClientOptions {
*/
retryAttempts?: number;
/**
* How long should the client wait until attempting to retry.
* Control the wait time between retries. You may implement your own strategy
* by timing the resolution of the returned promise with the retries count.
*
* @default 3 * 1000 (3 seconds)
* @default Randomised exponential backoff
*/
retryTimeout?: number;
retryWait?: (retries: number) => Promise<void>;
/**
* Register listeners before initialising the client. This way
* you can ensure to catch all client relevant emitted events.
Expand Down Expand Up @@ -154,7 +155,20 @@ export function createClient(options: ClientOptions): Client {
lazy = true,
keepAlive = 0,
retryAttempts = 5,
retryTimeout = 3 * 1000, // 3 seconds
retryWait = async function randomisedExponentialBackoff(retries) {
let retryDelay = 1000; // start with 1s delay
for (let i = 0; i < retries; i++) {
retryDelay *= 2;
}
await new Promise((resolve) =>
setTimeout(
resolve,
retryDelay +
// add random timeout from 300ms to 3s
Math.floor(Math.random() * (3000 - 300) + 300),
),
);
},
on,
webSocketImpl,
/**
Expand Down Expand Up @@ -233,9 +247,13 @@ export function createClient(options: ClientOptions): Client {
socket: null as WebSocket | null,
acknowledged: false,
locks: 0,
tries: 0,
retrying: false,
retries: 0,
};

// all those waiting for the `retryWait` to resolve
const retryWaiting: (() => void)[] = [];

type ConnectReturn = Promise<
[
socket: WebSocket,
Expand All @@ -251,28 +269,46 @@ export function createClient(options: ClientOptions): Client {
throw new Error('Kept trying to connect but the socket never settled.');
}

// retry wait strategy only on root caller
if (state.retrying && callDepth === 0) {
if (retryWaiting.length) {
// if others are waiting for retry, I'll wait too
await new Promise<void>((resolve) => retryWaiting.push(resolve));
} else {
retryWaiting.push(() => {
/** fake waiter to lead following connects in the `retryWaiting` queue */
});
// use retry wait strategy
await retryWait(state.retries++);
// complete all waiting and clear the queue
while (retryWaiting.length) {
retryWaiting.pop()?.();
}
}
}

// if recursive call, wait a bit for socket change
await new Promise((resolve) => setTimeout(resolve, callDepth * 50));

// socket already exists. can be ready or pending, check and behave accordingly
if (state.socket) {
switch (state.socket.readyState) {
case WebSocketImpl.OPEN: {
// if the socket is not acknowledged, wait a bit and reavaluate
if (!state.acknowledged) {
await new Promise((resolve) => setTimeout(resolve, 300));
return connect(cancellerRef, callDepth + 1);
}

return makeConnectReturn(state.socket, cancellerRef);
}
case WebSocketImpl.CONNECTING: {
// if the socket is in the connecting phase, wait a bit and reavaluate
await new Promise((resolve) => setTimeout(resolve, 300));
return connect(cancellerRef, callDepth + 1);
}
case WebSocketImpl.CLOSED:
break; // just continue, we'll make a new one
case WebSocketImpl.CLOSING: {
// if the socket is in the closing phase, wait a bit and connect
await new Promise((resolve) => setTimeout(resolve, 300));
return connect(cancellerRef, callDepth + 1);
}
default:
Expand All @@ -286,7 +322,6 @@ export function createClient(options: ClientOptions): Client {
...state,
acknowledged: false,
socket,
tries: state.tries + 1,
};
emitter.emit('connecting');

Expand Down Expand Up @@ -329,7 +364,13 @@ export function createClient(options: ClientOptions): Client {
}

clearTimeout(tooLong);
state = { ...state, acknowledged: true, socket, tries: 0 };
state = {
...state,
acknowledged: true,
socket,
retrying: false,
retries: 0,
};
emitter.emit('connected', socket, message.payload); // connected = socket opened + acknowledged
return resolve();
} catch (err) {
Expand Down Expand Up @@ -455,11 +496,12 @@ export function createClient(options: ClientOptions): Client {
}

// retries are not allowed or we tried to many times, report error
if (!retryAttempts || state.tries > retryAttempts) {
if (!retryAttempts || state.retries >= retryAttempts) {
throw errOrCloseEvent;
}

// looks good, please retry
// looks good, start retrying
state.retrying = true;
return true;
}

Expand All @@ -476,11 +518,7 @@ export function createClient(options: ClientOptions): Client {
return;
} catch (errOrCloseEvent) {
// return if shouldnt try again
if (!shouldRetryConnectOrThrow(errOrCloseEvent)) {
return;
}
// if should try again, wait a bit and continue loop
await new Promise((resolve) => setTimeout(resolve, retryTimeout));
if (!shouldRetryConnectOrThrow(errOrCloseEvent)) return;
}
}
})();
Expand Down Expand Up @@ -579,11 +617,7 @@ export function createClient(options: ClientOptions): Client {
return;
} catch (errOrCloseEvent) {
// return if shouldnt try again
if (!shouldRetryConnectOrThrow(errOrCloseEvent)) {
return;
}
// if should try again, wait a bit and continue loop
await new Promise((resolve) => setTimeout(resolve, retryTimeout));
if (!shouldRetryConnectOrThrow(errOrCloseEvent)) return;
}
}
})()
Expand Down
58 changes: 27 additions & 31 deletions src/tests/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,6 @@ describe('reconnecting', () => {
createClient({
url,
retryAttempts: 0,
retryTimeout: 5, // fake timeout
}),
{
query: 'subscription { ping }',
Expand All @@ -655,48 +654,49 @@ describe('reconnecting', () => {
client.close();
});

await server.waitForClient(() => {
fail('Shouldnt have tried again');
}, 20);

// client reported the error
// client reported the error immediately, meaning it wont retry
expect.assertions(1);
await sub.waitForError((err) => {
expect((err as CloseEvent).code).toBe(1005);
});
}, 20);
});

it('should reconnect silently after socket closes', async () => {
const { url, ...server } = await startTServer();

const sub = tsubscribe(
createClient({
url,
retryAttempts: 1,
retryTimeout: 5,
}),
{
query: 'subscription { ping }',
},
);
const client = createClient({
url,
retryAttempts: 3,
retryWait: () => Promise.resolve(),
});
const sub = tsubscribe(client, {
query: 'subscription { ping }',
});

await server.waitForClient((client) => {
client.close();
});

// tried again
// retried
await server.waitForClient((client) => {
client.close();
});

// never again
await server.waitForClient(() => {
fail('Shouldnt have tried again');
}, 20);
// once more
await server.waitForClient((client) => {
client.close();
});

// client reported the error
// and once more
await server.waitForClient((client) => {
client.close();
});

// client reported the error, meaning it wont retry anymore
expect.assertions(1);
await sub.waitForError((err) => {
expect((err as CloseEvent).code).toBe(1005);
});
}, 20);
});

it('should report some close events immediately and not reconnect', async () => {
Expand All @@ -707,7 +707,6 @@ describe('reconnecting', () => {
createClient({
url,
retryAttempts: Infinity, // keep retrying forever
retryTimeout: 5,
}),
{
query: 'subscription { ping }',
Expand All @@ -718,16 +717,13 @@ describe('reconnecting', () => {
client.close(code);
});

await server.waitForClient(() => {
fail('Shouldnt have tried again');
}, 20);

// client reported the error
// client reported the error immediately, meaning it wont retry
await sub.waitForError((err) => {
expect((err as CloseEvent).code).toBe(code);
});
}, 20);
}

expect.assertions(6);
await testCloseCode(1002);
await testCloseCode(1011);
await testCloseCode(4400);
Expand Down

0 comments on commit d3e7a17

Please sign in to comment.