Show an error screen when the SFU is at capacity (#3022)

Co-authored-by: Hugh Nimmo-Smith <hughns@users.noreply.github.com>
Co-authored-by: fkwp <fkwp@users.noreply.github.com>
This commit is contained in:
Robin
2025-02-26 19:00:56 +07:00
committed by GitHub
parent 2bb5b020e6
commit 31577d7263
4 changed files with 119 additions and 9 deletions

View File

@@ -82,6 +82,8 @@
"e2ee_unsupported_description": "Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
"generic": "Something went wrong",
"generic_description": "Submitting debug logs will help us track down the problem.",
"insufficient_capacity": "Insufficient capacity",
"insufficient_capacity_description": "The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
"open_elsewhere": "Opened in another tab",
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page."
},

View File

@@ -7,7 +7,10 @@ Please see LICENSE in the repository root for full details.
import { type FC, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import {
HostIcon,
PopOutIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { ErrorView } from "./ErrorView";
@@ -46,3 +49,19 @@ export class OpenElsewhereError extends RichError {
super("App opened in another tab", <OpenElsewhere />);
}
}
const InsufficientCapacity: FC = () => {
const { t } = useTranslation();
return (
<ErrorView Icon={HostIcon} title={t("error.insufficient_capacity")}>
<p>{t("error.insufficient_capacity_description")}</p>
</ErrorView>
);
};
export class InsufficientCapacityError extends RichError {
public constructor() {
super("Insufficient server capacity", <InsufficientCapacity />);
}
}

View File

@@ -0,0 +1,72 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, useCallback, useState } from "react";
import { test } from "vitest";
import {
ConnectionError,
ConnectionErrorReason,
type Room,
} from "livekit-client";
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "@sentry/react";
import { MemoryRouter } from "react-router-dom";
import { ErrorPage } from "../FullScreenView";
import { useECConnectionState } from "./useECConnectionState";
import { type SFUConfig } from "./openIDSFU";
test.each<[string, ConnectionError]>([
[
"LiveKit",
new ConnectionError("", ConnectionErrorReason.InternalError, 503),
],
[
"LiveKit Cloud",
new ConnectionError("", ConnectionErrorReason.NotAllowed, 429),
],
])(
"useECConnectionState throws error when %s hits track limit",
async (_server, error) => {
const mockRoom = {
on: () => {},
off: () => {},
once: () => {},
connect: () => {
throw error;
},
localParticipant: {
getTrackPublication: () => {},
createTracks: () => [],
},
} as unknown as Room;
const TestComponent: FC = () => {
const [sfuConfig, setSfuConfig] = useState<SFUConfig | undefined>(
undefined,
);
const connect = useCallback(
() => setSfuConfig({ url: "URL", jwt: "JWT token" }),
[],
);
useECConnectionState({}, false, mockRoom, sfuConfig);
return <button onClick={connect}>Connect</button>;
};
const user = userEvent.setup();
render(
<MemoryRouter>
<ErrorBoundary fallback={ErrorPage}>
<TestComponent />
</ErrorBoundary>
</MemoryRouter>,
);
await user.click(screen.getByRole("button", { name: "Connect" }));
screen.getByText("error.insufficient_capacity");
},
);

View File

@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
import {
type AudioCaptureOptions,
ConnectionError,
ConnectionState,
type LocalTrack,
type Room,
@@ -19,6 +20,7 @@ import * as Sentry from "@sentry/react";
import { type SFUConfig, sfuConfigEquals } from "./openIDSFU";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { InsufficientCapacityError, RichError } from "../RichError";
declare global {
interface Window {
@@ -106,7 +108,8 @@ async function doConnect(
await connectAndPublish(livekitRoom, sfuConfig, preCreatedAudioTrack, []);
} catch (e) {
preCreatedAudioTrack?.stop();
logger.warn("Stopped precreated audio tracks.", e);
logger.debug("Stopped precreated audio tracks.");
throw e;
}
}
@@ -129,12 +132,22 @@ async function connectAndPublish(
tracker.cacheConnectStart();
livekitRoom.once(RoomEvent.SignalConnected, tracker.cacheWsConnect);
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt, {
// Due to stability issues on Firefox we are testing the effect of different
// timeouts, and allow these values to be set through the console
peerConnectionTimeout: window.peerConnectionTimeout ?? 45000,
websocketTimeout: window.websocketTimeout ?? 45000,
});
try {
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt, {
// Due to stability issues on Firefox we are testing the effect of different
// timeouts, and allow these values to be set through the console
peerConnectionTimeout: window.peerConnectionTimeout ?? 45000,
websocketTimeout: window.websocketTimeout ?? 45000,
});
} catch (e) {
// LiveKit uses 503 to indicate that the server has hit its track limits
// or equivalently, 429 in LiveKit Cloud
// For reference, the 503 response is generated at: https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
if (e instanceof ConnectionError && (e.status === 503 || e.status === 429))
throw new InsufficientCapacityError();
throw e;
}
// remove listener in case the connect promise rejects before `SignalConnected` is emitted.
livekitRoom.off(RoomEvent.SignalConnected, tracker.cacheWsConnect);
@@ -175,6 +188,8 @@ export function useECConnectionState(
const [isSwitchingFocus, setSwitchingFocus] = useState(false);
const [isInDoConnect, setIsInDoConnect] = useState(false);
const [error, setError] = useState<RichError | null>(null);
if (error !== null) throw error;
const onConnStateChanged = useCallback((state: ConnectionState) => {
if (state == ConnectionState.Connected) setSwitchingFocus(false);
@@ -256,7 +271,9 @@ export function useECConnectionState(
initialAudioOptions,
)
.catch((e) => {
logger.error("Failed to connect to SFU", e);
if (e instanceof RichError)
setError(e); // Bubble up any error screens to React
else logger.error("Failed to connect to SFU", e);
})
.finally(() => setIsInDoConnect(false));
}