Skip to content
Merged
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
31 changes: 29 additions & 2 deletions src/v1/signaling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,19 @@ describe("Signaling connect method", () => {
expect(emitSpy).toHaveBeenCalledWith("established", testEvent);
}
});

test("should tear down a prior client before connecting again", async () => {
await signaling.connect({ endpointToken: "test-token" });
const firstWs = (signaling as any).ws;

await signaling.connect({ endpointToken: "test-token" });
const secondWs = (signaling as any).ws;

expect(firstWs.setAutoReconnect).toHaveBeenCalledWith(false);
expect(firstWs.removeAllListeners).toHaveBeenCalled();
expect(firstWs.close).toHaveBeenCalled();
expect(secondWs).not.toBe(firstWs);
});
});

describe("Signaling websocket event handlers", () => {
Expand Down Expand Up @@ -145,14 +158,28 @@ describe("Signaling websocket event handlers", () => {
expect(ws.setAutoReconnect).toHaveBeenCalledWith(false);
});

test("should handle non-403 error without throwing", async () => {
// A 409 means another device holds this endpoint. Retrying cannot succeed
// until that device leaves, and the client is configured with unlimited
// auto-reconnect, so it must be disabled or the SDK storms the gateway.
test("should reject with error and stop reconnecting on 409 error", async () => {
const errorCallback = getWsCallback("error");
expect(errorCallback).toBeDefined();

const ws = (signaling as any).ws;
errorCallback({ message: "Unexpected server response: 409" });

expect(ws.close).toHaveBeenCalledWith(409);
expect(ws.setAutoReconnect).toHaveBeenCalledWith(false);
});

test("should handle non-fatal error without throwing", async () => {
const errorCallback = getWsCallback("error");
expect(errorCallback).toBeDefined();

// Should not throw on a generic error
expect(() => errorCallback({ message: "some other error" })).not.toThrow();

// ws should not be closed on non-403 errors
// ws should not be closed on errors we can recover from by reconnecting
const ws = (signaling as any).ws;
expect(ws.setAutoReconnect).not.toHaveBeenCalled();
});
Expand Down
45 changes: 41 additions & 4 deletions src/v1/signaling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,31 @@ import { EndpointType, HangupResult, OutboundConnectionResult, RtcAuthParams, Rt
import { PublishSdpAnswer, PublishMetadata, ReadyMetadata, SetMediaPreferencesWebRtcResponse, SdpAnswer } from "./types";
import { Diagnostics, DiagnosticsBatcher } from "./diagnostics";

/**
* Handshake failures that will keep recurring for as long as the underlying
* condition holds, keyed by the `ws` error message for a non-101 response.
*
* The client is built with unlimited auto-reconnect, so without this the SDK
* retries these forever — several times a second against a gateway that is
* telling it, correctly, that the connection cannot be established. Reconnect
* is disabled and the error surfaced instead, leaving retry policy to the app.
*
* Note this only works under Node: browsers do not expose the HTTP status of a
* failed websocket upgrade to the error handler.
*/
const FATAL_HANDSHAKE_ERRORS: Record<string, { status: number; logMessage: string; error: string }> = {
"Unexpected server response: 403": {
status: 403,
logMessage: "Authentication error: Invalid token",
error: "Invalid token",
},
"Unexpected server response: 409": {
status: 409,
logMessage: "Endpoint already has an active connection from a different device",
error: "Endpoint already has an active connection",
},
};

class Signaling extends EventEmitter {
private defaultWebsocketUrl: string = "wss://gateway.pv.prod.global.aws.bandwidth.com/prod/gateway-service/api/v1/endpoints";
private ws: JsonRpcClient | null = null;
Expand All @@ -29,6 +54,16 @@ class Signaling extends EventEmitter {
let rpc_id = 1;

return new Promise<void>((resolve, reject) => {
if (this.ws) {
// A prior connect() left a client behind. Tear it down before replacing
// this.ws — otherwise the old client's unlimited auto-reconnect keeps
// running in the background forever. Its "open" handler calls
// setMediaPreferences() via this.ws, which by then points at the new
// client, so the orphaned socket never sends anything on its own
// connection and just sits idle until the gateway reaps it.
this._disconnect(false);
}

let rtcOptions: RtcOptions = {
websocketUrl: this.defaultWebsocketUrl,
};
Expand Down Expand Up @@ -86,13 +121,15 @@ class Signaling extends EventEmitter {
});

ws.on("error", (error: ErrorEvent) => {
if (error.message === "Unexpected server response: 403") {
logger.error("Authentication error: Invalid token");
ws.close(403);
const fatal = FATAL_HANDSHAKE_ERRORS[error.message];
if (fatal) {
logger.error(fatal.logMessage);
ws.close(fatal.status);
ws.setAutoReconnect(false);
reject(new Error("Invalid token"));
reject(new Error(fatal.error));
// Disconnect without calling leave since we are not connected
this._disconnect(false);
return;
}
// TODO: make this a more informative error message
logger.error(`Websocket error: ${error.message}`);
Expand Down