Skip to content

Commit 31577d7

Browse files
robintownhughnsfkwp
authored
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>
1 parent 2bb5b02 commit 31577d7

File tree

4 files changed

+119
-9
lines changed

4 files changed

+119
-9
lines changed

locales/en/app.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@
8282
"e2ee_unsupported_description": "Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
8383
"generic": "Something went wrong",
8484
"generic_description": "Submitting debug logs will help us track down the problem.",
85+
"insufficient_capacity": "Insufficient capacity",
86+
"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.",
8587
"open_elsewhere": "Opened in another tab",
8688
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page."
8789
},

src/RichError.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ Please see LICENSE in the repository root for full details.
77

88
import { type FC, type ReactNode } from "react";
99
import { useTranslation } from "react-i18next";
10-
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
10+
import {
11+
HostIcon,
12+
PopOutIcon,
13+
} from "@vector-im/compound-design-tokens/assets/web/icons";
1114

1215
import { ErrorView } from "./ErrorView";
1316

@@ -46,3 +49,19 @@ export class OpenElsewhereError extends RichError {
4649
super("App opened in another tab", <OpenElsewhere />);
4750
}
4851
}
52+
53+
const InsufficientCapacity: FC = () => {
54+
const { t } = useTranslation();
55+
56+
return (
57+
<ErrorView Icon={HostIcon} title={t("error.insufficient_capacity")}>
58+
<p>{t("error.insufficient_capacity_description")}</p>
59+
</ErrorView>
60+
);
61+
};
62+
63+
export class InsufficientCapacityError extends RichError {
64+
public constructor() {
65+
super("Insufficient server capacity", <InsufficientCapacity />);
66+
}
67+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE in the repository root for full details.
6+
*/
7+
8+
import { type FC, useCallback, useState } from "react";
9+
import { test } from "vitest";
10+
import {
11+
ConnectionError,
12+
ConnectionErrorReason,
13+
type Room,
14+
} from "livekit-client";
15+
import userEvent from "@testing-library/user-event";
16+
import { render, screen } from "@testing-library/react";
17+
import { ErrorBoundary } from "@sentry/react";
18+
import { MemoryRouter } from "react-router-dom";
19+
20+
import { ErrorPage } from "../FullScreenView";
21+
import { useECConnectionState } from "./useECConnectionState";
22+
import { type SFUConfig } from "./openIDSFU";
23+
24+
test.each<[string, ConnectionError]>([
25+
[
26+
"LiveKit",
27+
new ConnectionError("", ConnectionErrorReason.InternalError, 503),
28+
],
29+
[
30+
"LiveKit Cloud",
31+
new ConnectionError("", ConnectionErrorReason.NotAllowed, 429),
32+
],
33+
])(
34+
"useECConnectionState throws error when %s hits track limit",
35+
async (_server, error) => {
36+
const mockRoom = {
37+
on: () => {},
38+
off: () => {},
39+
once: () => {},
40+
connect: () => {
41+
throw error;
42+
},
43+
localParticipant: {
44+
getTrackPublication: () => {},
45+
createTracks: () => [],
46+
},
47+
} as unknown as Room;
48+
49+
const TestComponent: FC = () => {
50+
const [sfuConfig, setSfuConfig] = useState<SFUConfig | undefined>(
51+
undefined,
52+
);
53+
const connect = useCallback(
54+
() => setSfuConfig({ url: "URL", jwt: "JWT token" }),
55+
[],
56+
);
57+
useECConnectionState({}, false, mockRoom, sfuConfig);
58+
return <button onClick={connect}>Connect</button>;
59+
};
60+
61+
const user = userEvent.setup();
62+
render(
63+
<MemoryRouter>
64+
<ErrorBoundary fallback={ErrorPage}>
65+
<TestComponent />
66+
</ErrorBoundary>
67+
</MemoryRouter>,
68+
);
69+
await user.click(screen.getByRole("button", { name: "Connect" }));
70+
screen.getByText("error.insufficient_capacity");
71+
},
72+
);

src/livekit/useECConnectionState.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
77

88
import {
99
type AudioCaptureOptions,
10+
ConnectionError,
1011
ConnectionState,
1112
type LocalTrack,
1213
type Room,
@@ -19,6 +20,7 @@ import * as Sentry from "@sentry/react";
1920

2021
import { type SFUConfig, sfuConfigEquals } from "./openIDSFU";
2122
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
23+
import { InsufficientCapacityError, RichError } from "../RichError";
2224

2325
declare global {
2426
interface Window {
@@ -106,7 +108,8 @@ async function doConnect(
106108
await connectAndPublish(livekitRoom, sfuConfig, preCreatedAudioTrack, []);
107109
} catch (e) {
108110
preCreatedAudioTrack?.stop();
109-
logger.warn("Stopped precreated audio tracks.", e);
111+
logger.debug("Stopped precreated audio tracks.");
112+
throw e;
110113
}
111114
}
112115

@@ -129,12 +132,22 @@ async function connectAndPublish(
129132
tracker.cacheConnectStart();
130133
livekitRoom.once(RoomEvent.SignalConnected, tracker.cacheWsConnect);
131134

132-
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt, {
133-
// Due to stability issues on Firefox we are testing the effect of different
134-
// timeouts, and allow these values to be set through the console
135-
peerConnectionTimeout: window.peerConnectionTimeout ?? 45000,
136-
websocketTimeout: window.websocketTimeout ?? 45000,
137-
});
135+
try {
136+
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt, {
137+
// Due to stability issues on Firefox we are testing the effect of different
138+
// timeouts, and allow these values to be set through the console
139+
peerConnectionTimeout: window.peerConnectionTimeout ?? 45000,
140+
websocketTimeout: window.websocketTimeout ?? 45000,
141+
});
142+
} catch (e) {
143+
// LiveKit uses 503 to indicate that the server has hit its track limits
144+
// or equivalently, 429 in LiveKit Cloud
145+
// For reference, the 503 response is generated at: https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
146+
147+
if (e instanceof ConnectionError && (e.status === 503 || e.status === 429))
148+
throw new InsufficientCapacityError();
149+
throw e;
150+
}
138151

139152
// remove listener in case the connect promise rejects before `SignalConnected` is emitted.
140153
livekitRoom.off(RoomEvent.SignalConnected, tracker.cacheWsConnect);
@@ -175,6 +188,8 @@ export function useECConnectionState(
175188

176189
const [isSwitchingFocus, setSwitchingFocus] = useState(false);
177190
const [isInDoConnect, setIsInDoConnect] = useState(false);
191+
const [error, setError] = useState<RichError | null>(null);
192+
if (error !== null) throw error;
178193

179194
const onConnStateChanged = useCallback((state: ConnectionState) => {
180195
if (state == ConnectionState.Connected) setSwitchingFocus(false);
@@ -256,7 +271,9 @@ export function useECConnectionState(
256271
initialAudioOptions,
257272
)
258273
.catch((e) => {
259-
logger.error("Failed to connect to SFU", e);
274+
if (e instanceof RichError)
275+
setError(e); // Bubble up any error screens to React
276+
else logger.error("Failed to connect to SFU", e);
260277
})
261278
.finally(() => setIsInDoConnect(false));
262279
}

0 commit comments

Comments
 (0)