Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 5 commits into from
Feb 26, 2025
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
2 changes: 2 additions & 0 deletions locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand Down
21 changes: 20 additions & 1 deletion src/RichError.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 />);
}
}
72 changes: 72 additions & 0 deletions src/livekit/useECConnectionState.test.tsx
Original file line number Diff line number Diff line change
@@ -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");
},
);
33 changes: 25 additions & 8 deletions src/livekit/useECConnectionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.

import {
type AudioCaptureOptions,
ConnectionError,
ConnectionState,
type LocalTrack,
type Room,
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}
}

Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
}
Expand Down