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)); }