Unify C# and TS SignalR client auto-reconnect APIs (#10678)

This commit is contained in:
Stephen Halter 2019-06-05 21:32:47 -07:00 committed by GitHub
parent 915cc74df8
commit 81f2d46660
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 144 additions and 64 deletions

View File

@ -1,20 +1,20 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
import { IReconnectPolicy } from "./IReconnectPolicy";
import { IRetryPolicy, RetryContext } from "./IRetryPolicy";
// 0, 2, 10, 30 second delays before reconnect attempts.
const DEFAULT_RETRY_DELAYS_IN_MILLISECONDS = [0, 2000, 10000, 30000, null];
/** @private */
export class DefaultReconnectPolicy implements IReconnectPolicy {
export class DefaultReconnectPolicy implements IRetryPolicy {
private readonly retryDelays: Array<number | null>;
constructor(retryDelays?: number[]) {
this.retryDelays = retryDelays !== undefined ? [...retryDelays, null] : DEFAULT_RETRY_DELAYS_IN_MILLISECONDS;
}
public nextRetryDelayInMilliseconds(previousRetryCount: number): number | null {
return this.retryDelays[previousRetryCount];
public nextRetryDelayInMilliseconds(retryContext: RetryContext): number | null {
return this.retryDelays[retryContext.previousRetryCount];
}
}

View File

@ -5,7 +5,7 @@ import { HandshakeProtocol, HandshakeRequestMessage, HandshakeResponseMessage }
import { IConnection } from "./IConnection";
import { CancelInvocationMessage, CompletionMessage, IHubProtocol, InvocationMessage, MessageType, StreamInvocationMessage, StreamItemMessage } from "./IHubProtocol";
import { ILogger, LogLevel } from "./ILogger";
import { IReconnectPolicy } from "./IReconnectPolicy";
import { IRetryPolicy } from "./IRetryPolicy";
import { IStreamResult } from "./Stream";
import { Subject } from "./Subject";
import { Arg } from "./Utils";
@ -32,7 +32,7 @@ export class HubConnection {
private readonly cachedPingMessage: string | ArrayBuffer;
private readonly connection: IConnection;
private readonly logger: ILogger;
private readonly reconnectPolicy?: IReconnectPolicy;
private readonly reconnectPolicy?: IRetryPolicy;
private protocol: IHubProtocol;
private handshakeProtocol: HandshakeProtocol;
private callbacks: { [invocationId: string]: (invocationEvent: StreamItemMessage | CompletionMessage | null, error?: Error) => void };
@ -81,11 +81,11 @@ export class HubConnection {
// create method that can be used by HubConnectionBuilder. An "internal" constructor would just
// be stripped away and the '.d.ts' file would have no constructor, which is interpreted as a
// public parameter-less constructor.
public static create(connection: IConnection, logger: ILogger, protocol: IHubProtocol, reconnectPolicy?: IReconnectPolicy): HubConnection {
public static create(connection: IConnection, logger: ILogger, protocol: IHubProtocol, reconnectPolicy?: IRetryPolicy): HubConnection {
return new HubConnection(connection, logger, protocol, reconnectPolicy);
}
private constructor(connection: IConnection, logger: ILogger, protocol: IHubProtocol, reconnectPolicy?: IReconnectPolicy) {
private constructor(connection: IConnection, logger: ILogger, protocol: IHubProtocol, reconnectPolicy?: IRetryPolicy) {
Arg.isRequired(connection, "connection");
Arg.isRequired(logger, "logger");
Arg.isRequired(protocol, "protocol");
@ -666,11 +666,12 @@ export class HubConnection {
private async reconnect(error?: Error) {
const reconnectStartTime = Date.now();
let previousReconnectAttempts = 0;
let retryError = error !== undefined ? error : new Error("Attempting to reconnect due to a unknown error.");
let nextRetryDelay = this.getNextRetryDelay(previousReconnectAttempts++, 0);
let nextRetryDelay = this.getNextRetryDelay(previousReconnectAttempts++, 0, retryError);
if (nextRetryDelay === null) {
this.logger.log(LogLevel.Debug, "Connection not reconnecting because the IReconnectPolicy returned null on the first reconnect attempt.");
this.logger.log(LogLevel.Debug, "Connection not reconnecting because the IRetryPolicy returned null on the first reconnect attempt.");
this.completeClose(error);
return;
}
@ -732,9 +733,10 @@ export class HubConnection {
this.logger.log(LogLevel.Debug, "Connection left the reconnecting state during reconnect attempt. Done reconnecting.");
return;
}
}
nextRetryDelay = this.getNextRetryDelay(previousReconnectAttempts++, Date.now() - reconnectStartTime);
retryError = e instanceof Error ? e : new Error(e.toString());
nextRetryDelay = this.getNextRetryDelay(previousReconnectAttempts++, Date.now() - reconnectStartTime, retryError);
}
}
this.logger.log(LogLevel.Information, `Reconnect retries have been exhausted after ${Date.now() - reconnectStartTime} ms and ${previousReconnectAttempts} failed attempts. Connection disconnecting.`);
@ -742,11 +744,15 @@ export class HubConnection {
this.completeClose();
}
private getNextRetryDelay(previousRetryCount: number, elapsedMilliseconds: number) {
private getNextRetryDelay(previousRetryCount: number, elapsedMilliseconds: number, retryReason: Error) {
try {
return this.reconnectPolicy!.nextRetryDelayInMilliseconds(previousRetryCount, elapsedMilliseconds);
return this.reconnectPolicy!.nextRetryDelayInMilliseconds({
elapsedMilliseconds,
previousRetryCount,
retryReason,
});
} catch (e) {
this.logger.log(LogLevel.Error, `IReconnectPolicy.nextRetryDelayInMilliseconds(${previousRetryCount}, ${elapsedMilliseconds}) threw error '${e}'.`);
this.logger.log(LogLevel.Error, `IRetryPolicy.nextRetryDelayInMilliseconds(${previousRetryCount}, ${elapsedMilliseconds}) threw error '${e}'.`);
return null;
}
}

View File

@ -7,7 +7,7 @@ import { HubConnection } from "./HubConnection";
import { IHttpConnectionOptions } from "./IHttpConnectionOptions";
import { IHubProtocol } from "./IHubProtocol";
import { ILogger, LogLevel } from "./ILogger";
import { IReconnectPolicy } from "./IReconnectPolicy";
import { IRetryPolicy } from "./IRetryPolicy";
import { HttpTransportType } from "./ITransport";
import { JsonHubProtocol } from "./JsonHubProtocol";
import { NullLogger } from "./Loggers";
@ -51,7 +51,7 @@ export class HubConnectionBuilder {
/** If defined, this indicates the client should automatically attempt to reconnect if the connection is lost. */
/** @internal */
public reconnectPolicy?: IReconnectPolicy;
public reconnectPolicy?: IRetryPolicy;
/** Configures console logging for the {@link @aspnet/signalr.HubConnection}.
*
@ -164,10 +164,10 @@ export class HubConnectionBuilder {
/** Configures the {@link @aspnet/signalr.HubConnection} to automatically attempt to reconnect if the connection is lost.
*
* @param {IReconnectPolicy} reconnectPolicy An {@link @aspnet/signalR.IReconnectPolicy} that controls the timing and number of reconnect attempts.
* @param {IRetryPolicy} reconnectPolicy An {@link @aspnet/signalR.IRetryPolicy} that controls the timing and number of reconnect attempts.
*/
public withAutomaticReconnect(reconnectPolicy: IReconnectPolicy): HubConnectionBuilder;
public withAutomaticReconnect(retryDelaysOrReconnectPolicy?: number[] | IReconnectPolicy): HubConnectionBuilder {
public withAutomaticReconnect(reconnectPolicy: IRetryPolicy): HubConnectionBuilder;
public withAutomaticReconnect(retryDelaysOrReconnectPolicy?: number[] | IRetryPolicy): HubConnectionBuilder {
if (this.reconnectPolicy) {
throw new Error("A reconnectPolicy has already been set.");
}

View File

@ -1,15 +0,0 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
/** An abstraction that controls when the client attempts to reconnect and how many times it does so. */
export interface IReconnectPolicy {
/** Called after the transport loses the connection.
*
* @param {number} previousRetryCount The number of consecutive failed reconnect attempts so far.
*
* @param {number} elapsedMilliseconds The amount of time in milliseconds spent reconnecting so far.
*
* @returns {number | null} The amount of time in milliseconds to wait before the next reconnect attempt. `null` tells the client to stop retrying and close.
*/
nextRetryDelayInMilliseconds(previousRetryCount: number, elapsedMilliseconds: number): number | null;
}

View File

@ -0,0 +1,30 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
/** An abstraction that controls when the client attempts to reconnect and how many times it does so. */
export interface IRetryPolicy {
/** Called after the transport loses the connection.
*
* @param {RetryContext} retryContext Details related to the retry event to help determine how long to wait for the next retry.
*
* @returns {number | null} The amount of time in milliseconds to wait before the next retry. `null` tells the client to stop retrying.
*/
nextRetryDelayInMilliseconds(retryContext: RetryContext): number | null;
}
export interface RetryContext {
/**
* The number of consecutive failed tries so far.
*/
readonly previousRetryCount: number;
/**
* The amount of time in milliseconds spent retrying so far.
*/
readonly elapsedMilliseconds: number;
/**
* The error that forced the upcoming retry.
*/
readonly retryReason: Error;
}

View File

@ -21,4 +21,4 @@ export { IStreamSubscriber, IStreamResult, ISubscription } from "./Stream";
export { NullLogger } from "./Loggers";
export { JsonHubProtocol } from "./JsonHubProtocol";
export { Subject } from "./Subject";
export { IReconnectPolicy } from "./IReconnectPolicy";
export { IRetryPolicy, RetryContext } from "./IRetryPolicy";

View File

@ -3,6 +3,7 @@
import { DefaultReconnectPolicy } from "../src/DefaultReconnectPolicy";
import { HubConnection, HubConnectionState } from "../src/HubConnection";
import { RetryContext } from "../src/IRetryPolicy";
import { JsonHubProtocol } from "../src/JsonHubProtocol";
import { VerifyLogger } from "./Common";
@ -46,15 +47,17 @@ describe("auto reconnect", () => {
let lastRetryCount = -1;
let lastElapsedMs = -1;
let retryReason = null;
let onreconnectingCount = 0;
let onreconnectedCount = 0;
let closeCount = 0;
const connection = new TestConnection();
const hubConnection = HubConnection.create(connection, logger, new JsonHubProtocol(), {
nextRetryDelayInMilliseconds(previousRetryCount: number, elapsedMilliseconds: number) {
lastRetryCount = previousRetryCount;
lastElapsedMs = elapsedMilliseconds;
nextRetryDelayInMilliseconds(retryContext: RetryContext) {
lastRetryCount = retryContext.previousRetryCount;
lastElapsedMs = retryContext.elapsedMilliseconds;
retryReason = retryContext.retryReason;
nextRetryDelayCalledPromise.resolve();
return 0;
},
@ -81,8 +84,11 @@ describe("auto reconnect", () => {
return promise;
};
const oncloseError = new Error("Connection lost");
const continueRetryingError = new Error("Reconnect attempt failed");
// Typically this would be called by the transport
connection.onclose!(new Error("Connection lost"));
connection.onclose!(oncloseError);
await nextRetryDelayCalledPromise;
nextRetryDelayCalledPromise = new PromiseSource();
@ -90,17 +96,19 @@ describe("auto reconnect", () => {
expect(hubConnection.state).toBe(HubConnectionState.Reconnecting);
expect(lastRetryCount).toBe(0);
expect(lastElapsedMs).toBe(0);
expect(retryReason).toBe(oncloseError);
expect(onreconnectingCount).toBe(1);
expect(onreconnectedCount).toBe(0);
expect(closeCount).toBe(0);
// Make sure the the Promise is "handled" immediately upon rejection or else this test fails.
continueRetryingPromise.catch(() => { });
continueRetryingPromise.reject(new Error("Reconnect attempt failed"));
continueRetryingPromise.reject(continueRetryingError);
await nextRetryDelayCalledPromise;
expect(lastRetryCount).toBe(1);
expect(lastElapsedMs).toBeGreaterThanOrEqual(0);
expect(retryReason).toBe(continueRetryingError);
expect(onreconnectingCount).toBe(1);
expect(onreconnectedCount).toBe(0);
expect(closeCount).toBe(0);
@ -133,18 +141,20 @@ describe("auto reconnect", () => {
let lastRetryCount = -1;
let lastElapsedMs = -1;
let retryReason = null;
let onreconnectingCount = 0;
let onreconnectedCount = 0;
let closeCount = 0;
const connection = new TestConnection();
const hubConnection = HubConnection.create(connection, logger, new JsonHubProtocol(), {
nextRetryDelayInMilliseconds(previousRetryCount: number, elapsedMilliseconds: number) {
lastRetryCount = previousRetryCount;
lastElapsedMs = elapsedMilliseconds;
nextRetryDelayInMilliseconds(retryContext: RetryContext) {
lastRetryCount = retryContext.previousRetryCount;
lastElapsedMs = retryContext.elapsedMilliseconds;
retryReason = retryContext.retryReason;
nextRetryDelayCalledPromise.resolve();
return previousRetryCount === 0 ? 0 : null;
return retryContext.previousRetryCount === 0 ? 0 : null;
},
});
@ -163,12 +173,15 @@ describe("auto reconnect", () => {
await hubConnection.start();
const oncloseError = new Error("Connection lost");
const startError = new Error("Reconnect attempt failed");
connection.start = () => {
return Promise.reject("Reconnect attempt failed");
throw startError;
};
// Typically this would be called by the transport
connection.onclose!(new Error("Connection lost"));
connection.onclose!(oncloseError);
await nextRetryDelayCalledPromise;
nextRetryDelayCalledPromise = new PromiseSource();
@ -176,6 +189,7 @@ describe("auto reconnect", () => {
expect(hubConnection.state).toBe(HubConnectionState.Reconnecting);
expect(lastRetryCount).toBe(0);
expect(lastElapsedMs).toBe(0);
expect(retryReason).toBe(oncloseError);
expect(onreconnectingCount).toBe(1);
expect(onreconnectedCount).toBe(0);
expect(closeCount).toBe(0);
@ -185,6 +199,7 @@ describe("auto reconnect", () => {
expect(hubConnection.state).toBe(HubConnectionState.Disconnected);
expect(lastRetryCount).toBe(1);
expect(lastElapsedMs).toBeGreaterThanOrEqual(0);
expect(retryReason).toBe(startError);
expect(onreconnectingCount).toBe(1);
expect(onreconnectedCount).toBe(0);
expect(closeCount).toBe(1);
@ -198,15 +213,17 @@ describe("auto reconnect", () => {
let lastRetryCount = -1;
let lastElapsedMs = -1;
let retryReason = null;
let onreconnectingCount = 0;
let onreconnectedCount = 0;
let closeCount = 0;
const connection = new TestConnection();
const hubConnection = HubConnection.create(connection, logger, new JsonHubProtocol(), {
nextRetryDelayInMilliseconds(previousRetryCount: number, elapsedMilliseconds: number) {
lastRetryCount = previousRetryCount;
lastElapsedMs = elapsedMilliseconds;
nextRetryDelayInMilliseconds(retryContext: RetryContext) {
lastRetryCount = retryContext.previousRetryCount;
lastElapsedMs = retryContext.elapsedMilliseconds;
retryReason = retryContext.retryReason;
nextRetryDelayCalledPromise.resolve();
return 0;
},
@ -227,8 +244,11 @@ describe("auto reconnect", () => {
await hubConnection.start();
const oncloseError = new Error("Connection lost 1");
const oncloseError2 = new Error("Connection lost 2");
// Typically this would be called by the transport
connection.onclose!(new Error("Connection lost"));
connection.onclose!(oncloseError);
await nextRetryDelayCalledPromise;
nextRetryDelayCalledPromise = new PromiseSource();
@ -236,6 +256,7 @@ describe("auto reconnect", () => {
expect(hubConnection.state).toBe(HubConnectionState.Reconnecting);
expect(lastRetryCount).toBe(0);
expect(lastElapsedMs).toBe(0);
expect(retryReason).toBe(oncloseError);
expect(onreconnectingCount).toBe(1);
expect(onreconnectedCount).toBe(0);
expect(closeCount).toBe(0);
@ -250,13 +271,14 @@ describe("auto reconnect", () => {
expect(onreconnectedCount).toBe(1);
expect(closeCount).toBe(0);
connection.onclose!(new Error("Connection lost"));
connection.onclose!(oncloseError2);
await nextRetryDelayCalledPromise;
expect(hubConnection.state).toBe(HubConnectionState.Reconnecting);
expect(lastRetryCount).toBe(0);
expect(lastElapsedMs).toBe(0);
expect(retryReason).toBe(oncloseError2);
expect(onreconnectingCount).toBe(2);
expect(onreconnectedCount).toBe(1);
expect(closeCount).toBe(0);
@ -362,6 +384,7 @@ describe("auto reconnect", () => {
let nextRetryDelayCalledPromise = new PromiseSource();
let lastRetryCount = 0;
let retryReason = null;
let onreconnectingCount = 0;
let onreconnectedCount = 0;
let closeCount = 0;
@ -369,8 +392,9 @@ describe("auto reconnect", () => {
// Disable autoHandshake in TestConnection
const connection = new TestConnection(false);
const hubConnection = HubConnection.create(connection, logger, new JsonHubProtocol(), {
nextRetryDelayInMilliseconds(previousRetryCount: number) {
lastRetryCount = previousRetryCount;
nextRetryDelayInMilliseconds(retryContext: RetryContext) {
lastRetryCount = retryContext.previousRetryCount;
retryReason = retryContext.retryReason;
nextRetryDelayCalledPromise.resolve();
return 0;
},
@ -400,14 +424,18 @@ describe("auto reconnect", () => {
return Promise.resolve();
};
const oncloseError = new Error("Connection lost 1");
const oncloseError2 = new Error("Connection lost 2");
// Typically this would be called by the transport
connection.onclose!(new Error("Connection lost"));
connection.onclose!(oncloseError);
await nextRetryDelayCalledPromise;
nextRetryDelayCalledPromise = new PromiseSource();
expect(hubConnection.state).toBe(HubConnectionState.Reconnecting);
expect(lastRetryCount).toBe(0);
expect(retryReason).toBe(oncloseError);
expect(onreconnectingCount).toBe(1);
expect(onreconnectedCount).toBe(0);
expect(closeCount).toBe(0);
@ -416,12 +444,13 @@ describe("auto reconnect", () => {
replacedStartCalledPromise = new PromiseSource();
// Fail underlying connection during reconnect during handshake
connection.onclose!(new Error("Connection lost"));
connection.onclose!(oncloseError2);
await nextRetryDelayCalledPromise;
expect(hubConnection.state).toBe(HubConnectionState.Reconnecting);
expect(lastRetryCount).toBe(1);
expect(retryReason).toBe(oncloseError2);
expect(onreconnectingCount).toBe(1);
expect(onreconnectedCount).toBe(0);
expect(closeCount).toBe(0);
@ -461,8 +490,8 @@ describe("auto reconnect", () => {
// Disable autoHandshake in TestConnection
const connection = new TestConnection(false);
const hubConnection = HubConnection.create(connection, logger, new JsonHubProtocol(), {
nextRetryDelayInMilliseconds(previousRetryCount: number) {
lastRetryCount = previousRetryCount;
nextRetryDelayInMilliseconds(retryContext: RetryContext) {
lastRetryCount = retryContext.previousRetryCount;
nextRetryDelayCalledPromise.resolve();
return 0;
},

View File

@ -322,7 +322,13 @@ describe("HubConnectionBuilder", () => {
let retryCount = 0;
for (const delay of DEFAULT_RETRY_DELAYS_IN_MILLISECONDS) {
expect(builder.reconnectPolicy!.nextRetryDelayInMilliseconds(retryCount++, 0)).toBe(delay);
const retryContext = {
previousRetryCount: retryCount++,
elapsedMilliseconds: 0,
retryReason: new Error(),
};
expect(builder.reconnectPolicy!.nextRetryDelayInMilliseconds(retryContext)).toBe(delay);
}
});
@ -333,23 +339,47 @@ describe("HubConnectionBuilder", () => {
let retryCount = 0;
for (const delay of customRetryDelays) {
expect(builder.reconnectPolicy!.nextRetryDelayInMilliseconds(retryCount++, 0)).toBe(delay);
const retryContext = {
previousRetryCount: retryCount++,
elapsedMilliseconds: 0,
retryReason: new Error(),
};
expect(builder.reconnectPolicy!.nextRetryDelayInMilliseconds(retryContext)).toBe(delay);
}
expect(builder.reconnectPolicy!.nextRetryDelayInMilliseconds(retryCount, 0)).toBe(null);
const retryContextFinal = {
previousRetryCount: retryCount++,
elapsedMilliseconds: 0,
retryReason: new Error(),
};
expect(builder.reconnectPolicy!.nextRetryDelayInMilliseconds(retryContextFinal)).toBe(null);
});
it("withAutomaticReconnect uses a custom IReconnectPolicy when provided", () => {
it("withAutomaticReconnect uses a custom IRetryPolicy when provided", () => {
const customRetryDelays = [127, 0, 0, 1];
const builder = new HubConnectionBuilder()
.withAutomaticReconnect(new DefaultReconnectPolicy(customRetryDelays));
let retryCount = 0;
for (const delay of customRetryDelays) {
expect(builder.reconnectPolicy!.nextRetryDelayInMilliseconds(retryCount++, 0)).toBe(delay);
const retryContext = {
previousRetryCount: retryCount++,
elapsedMilliseconds: 0,
retryReason: new Error(),
};
expect(builder.reconnectPolicy!.nextRetryDelayInMilliseconds(retryContext)).toBe(delay);
}
expect(builder.reconnectPolicy!.nextRetryDelayInMilliseconds(retryCount, 0)).toBe(null);
const retryContextFinal = {
previousRetryCount: retryCount++,
elapsedMilliseconds: 0,
retryReason: new Error(),
};
expect(builder.reconnectPolicy!.nextRetryDelayInMilliseconds(retryContextFinal)).toBe(null);
});
});