diff --git a/locales/en/app.json b/locales/en/app.json
index d27b9a6c0..7da0f5939 100644
--- a/locales/en/app.json
+++ b/locales/en/app.json
@@ -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."
},
diff --git a/src/RichError.tsx b/src/RichError.tsx
index 5ce31e044..d16ef6409 100644
--- a/src/RichError.tsx
+++ b/src/RichError.tsx
@@ -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", );
}
}
+
+const InsufficientCapacity: FC = () => {
+ const { t } = useTranslation();
+
+ return (
+
+
{t("error.insufficient_capacity_description")}
+
+ );
+};
+
+export class InsufficientCapacityError extends RichError {
+ public constructor() {
+ super("Insufficient server capacity", );
+ }
+}
diff --git a/src/livekit/useECConnectionState.test.tsx b/src/livekit/useECConnectionState.test.tsx
new file mode 100644
index 000000000..7194c252b
--- /dev/null
+++ b/src/livekit/useECConnectionState.test.tsx
@@ -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(
+ undefined,
+ );
+ const connect = useCallback(
+ () => setSfuConfig({ url: "URL", jwt: "JWT token" }),
+ [],
+ );
+ useECConnectionState({}, false, mockRoom, sfuConfig);
+ return ;
+ };
+
+ const user = userEvent.setup();
+ render(
+
+
+
+
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Connect" }));
+ screen.getByText("error.insufficient_capacity");
+ },
+);
diff --git a/src/livekit/useECConnectionState.ts b/src/livekit/useECConnectionState.ts
index 56139037d..8cd5f87ea 100644
--- a/src/livekit/useECConnectionState.ts
+++ b/src/livekit/useECConnectionState.ts
@@ -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(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));
}