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:
@@ -82,6 +82,8 @@
|
|||||||
"e2ee_unsupported_description": "Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
|
"e2ee_unsupported_description": "Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
|
||||||
"generic": "Something went wrong",
|
"generic": "Something went wrong",
|
||||||
"generic_description": "Submitting debug logs will help us track down the problem.",
|
"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": "Opened in another tab",
|
||||||
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page."
|
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { type FC, type ReactNode } from "react";
|
import { type FC, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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";
|
import { ErrorView } from "./ErrorView";
|
||||||
|
|
||||||
@@ -46,3 +49,19 @@ export class OpenElsewhereError extends RichError {
|
|||||||
super("App opened in another tab", <OpenElsewhere />);
|
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 />);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
72
src/livekit/useECConnectionState.test.tsx
Normal file
72
src/livekit/useECConnectionState.test.tsx
Normal 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");
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type AudioCaptureOptions,
|
type AudioCaptureOptions,
|
||||||
|
ConnectionError,
|
||||||
ConnectionState,
|
ConnectionState,
|
||||||
type LocalTrack,
|
type LocalTrack,
|
||||||
type Room,
|
type Room,
|
||||||
@@ -19,6 +20,7 @@ import * as Sentry from "@sentry/react";
|
|||||||
|
|
||||||
import { type SFUConfig, sfuConfigEquals } from "./openIDSFU";
|
import { type SFUConfig, sfuConfigEquals } from "./openIDSFU";
|
||||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||||
|
import { InsufficientCapacityError, RichError } from "../RichError";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -106,7 +108,8 @@ async function doConnect(
|
|||||||
await connectAndPublish(livekitRoom, sfuConfig, preCreatedAudioTrack, []);
|
await connectAndPublish(livekitRoom, sfuConfig, preCreatedAudioTrack, []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
preCreatedAudioTrack?.stop();
|
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();
|
tracker.cacheConnectStart();
|
||||||
livekitRoom.once(RoomEvent.SignalConnected, tracker.cacheWsConnect);
|
livekitRoom.once(RoomEvent.SignalConnected, tracker.cacheWsConnect);
|
||||||
|
|
||||||
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt, {
|
try {
|
||||||
// Due to stability issues on Firefox we are testing the effect of different
|
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt, {
|
||||||
// timeouts, and allow these values to be set through the console
|
// Due to stability issues on Firefox we are testing the effect of different
|
||||||
peerConnectionTimeout: window.peerConnectionTimeout ?? 45000,
|
// timeouts, and allow these values to be set through the console
|
||||||
websocketTimeout: window.websocketTimeout ?? 45000,
|
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.
|
// remove listener in case the connect promise rejects before `SignalConnected` is emitted.
|
||||||
livekitRoom.off(RoomEvent.SignalConnected, tracker.cacheWsConnect);
|
livekitRoom.off(RoomEvent.SignalConnected, tracker.cacheWsConnect);
|
||||||
@@ -175,6 +188,8 @@ export function useECConnectionState(
|
|||||||
|
|
||||||
const [isSwitchingFocus, setSwitchingFocus] = useState(false);
|
const [isSwitchingFocus, setSwitchingFocus] = useState(false);
|
||||||
const [isInDoConnect, setIsInDoConnect] = useState(false);
|
const [isInDoConnect, setIsInDoConnect] = useState(false);
|
||||||
|
const [error, setError] = useState<RichError | null>(null);
|
||||||
|
if (error !== null) throw error;
|
||||||
|
|
||||||
const onConnStateChanged = useCallback((state: ConnectionState) => {
|
const onConnStateChanged = useCallback((state: ConnectionState) => {
|
||||||
if (state == ConnectionState.Connected) setSwitchingFocus(false);
|
if (state == ConnectionState.Connected) setSwitchingFocus(false);
|
||||||
@@ -256,7 +271,9 @@ export function useECConnectionState(
|
|||||||
initialAudioOptions,
|
initialAudioOptions,
|
||||||
)
|
)
|
||||||
.catch((e) => {
|
.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));
|
.finally(() => setIsInDoConnect(false));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user