Skip to content

Commit 41132a8

Browse files
authored
Merge pull request #3076 from element-hq/valere/async_error_show_boundary
Error management: Handle fail to get JWT token
2 parents 4a2f44a + b6ad6ae commit 41132a8

File tree

7 files changed

+247
-36
lines changed

7 files changed

+247
-36
lines changed

playwright/errors.spec.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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 { expect, test } from "@playwright/test";
9+
10+
test("Should show error screen if fails to get JWT token", async ({ page }) => {
11+
await page.goto("/");
12+
13+
await page.getByTestId("home_callName").click();
14+
await page.getByTestId("home_callName").fill("HelloCall");
15+
await page.getByTestId("home_displayName").click();
16+
await page.getByTestId("home_displayName").fill("John Doe");
17+
await page.getByTestId("home_go").click();
18+
19+
await page.route(
20+
"**/openid/request_token",
21+
async (route) =>
22+
await route.fulfill({
23+
// 418 is a non retryable error, so test will fail immediately
24+
status: 418,
25+
}),
26+
);
27+
28+
// Join the call
29+
await page.getByTestId("lobby_joinCall").click();
30+
31+
// Should fail
32+
await expect(page.getByText("Something went wrong")).toBeVisible();
33+
await expect(page.getByText("OPEN_ID_ERROR")).toBeVisible();
34+
});
35+
36+
test("Should automatically retry non fatal JWT errors", async ({
37+
page,
38+
browserName,
39+
}) => {
40+
test.skip(
41+
browserName === "firefox",
42+
"The test to check the video visibility is not working in Firefox CI environment. looks like video is disabled?",
43+
);
44+
await page.goto("/");
45+
46+
await page.getByTestId("home_callName").click();
47+
await page.getByTestId("home_callName").fill("HelloCall");
48+
await page.getByTestId("home_displayName").click();
49+
await page.getByTestId("home_displayName").fill("John Doe");
50+
await page.getByTestId("home_go").click();
51+
52+
let firstCall = true;
53+
let hasRetriedCallback: (value: PromiseLike<void> | void) => void;
54+
const hasRetriedPromise = new Promise<void>((resolve) => {
55+
hasRetriedCallback = resolve;
56+
});
57+
await page.route("**/openid/request_token", async (route) => {
58+
if (firstCall) {
59+
firstCall = false;
60+
await route.fulfill({
61+
status: 429,
62+
});
63+
} else {
64+
await route.continue();
65+
hasRetriedCallback();
66+
}
67+
});
68+
69+
// Join the call
70+
await page.getByTestId("lobby_joinCall").click();
71+
// Expect that the call has been retried
72+
await hasRetriedPromise;
73+
await expect(page.getByTestId("video").first()).toBeVisible();
74+
});

src/livekit/openIDSFU.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import { useEffect, useState } from "react";
1212
import { type LivekitFocus } from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
1313

1414
import { useActiveLivekitFocus } from "../room/useActiveFocus";
15+
import { useGroupCallErrorBoundary } from "../room/useCallErrorBoundary.ts";
16+
import { FailToGetOpenIdToken } from "../utils/errors.ts";
17+
import { doNetworkOperationWithRetry } from "../utils/matrix.ts";
1518

1619
export interface SFUConfig {
1720
url: string;
@@ -38,6 +41,7 @@ export function useOpenIDSFU(
3841
const [sfuConfig, setSFUConfig] = useState<SFUConfig | undefined>(undefined);
3942

4043
const activeFocus = useActiveLivekitFocus(rtcSession);
44+
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
4145

4246
useEffect(() => {
4347
if (activeFocus) {
@@ -46,13 +50,14 @@ export function useOpenIDSFU(
4650
setSFUConfig(sfuConfig);
4751
},
4852
(e) => {
53+
showGroupCallErrorBoundary(new FailToGetOpenIdToken(e));
4954
logger.error("Failed to get SFU config", e);
5055
},
5156
);
5257
} else {
5358
setSFUConfig(undefined);
5459
}
55-
}, [client, activeFocus]);
60+
}, [client, activeFocus, showGroupCallErrorBoundary]);
5661

5762
return sfuConfig;
5863
}
@@ -61,7 +66,16 @@ export async function getSFUConfigWithOpenID(
6166
client: OpenIDClientParts,
6267
activeFocus: LivekitFocus,
6368
): Promise<SFUConfig | undefined> {
64-
const openIdToken = await client.getOpenIdToken();
69+
let openIdToken: IOpenIDToken;
70+
try {
71+
openIdToken = await doNetworkOperationWithRetry(async () =>
72+
client.getOpenIdToken(),
73+
);
74+
} catch (error) {
75+
throw new FailToGetOpenIdToken(
76+
error instanceof Error ? error : new Error("Unknown error"),
77+
);
78+
}
6579
logger.debug("Got openID token", openIdToken);
6680

6781
try {

src/room/GroupCallView.tsx

Lines changed: 31 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import {
6767
useSetting,
6868
} from "../settings/settings";
6969
import { useTypedEventEmitter } from "../useEvents";
70+
import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts";
7071

7172
declare global {
7273
interface Window {
@@ -120,11 +121,13 @@ export const GroupCallView: FC<Props> = ({
120121
};
121122
}, [rtcSession]);
122123

124+
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
125+
123126
useTypedEventEmitter(
124127
rtcSession,
125128
MatrixRTCSessionEvent.MembershipManagerError,
126129
(error) => {
127-
setError(
130+
showGroupCallErrorBoundary(
128131
new RTCSessionError(
129132
ErrorCode.MEMBERSHIP_MANAGER_UNRECOVERABLE,
130133
error.message ?? error,
@@ -173,30 +176,32 @@ export const GroupCallView: FC<Props> = ({
173176
const latestDevices = useLatest(deviceContext);
174177
const latestMuteStates = useLatest(muteStates);
175178

176-
const enterRTCSessionOrError = async (
177-
rtcSession: MatrixRTCSession,
178-
perParticipantE2EE: boolean,
179-
newMembershipManager: boolean,
180-
): Promise<void> => {
181-
try {
182-
await enterRTCSession(
183-
rtcSession,
184-
perParticipantE2EE,
185-
newMembershipManager,
186-
);
187-
} catch (e) {
188-
if (e instanceof ElementCallError) {
189-
// e.code === ErrorCode.MISSING_LIVE_KIT_SERVICE_URL)
190-
setError(e);
191-
} else {
192-
logger.error(`Unknown Error while entering RTC session`, e);
193-
const error = new UnknownCallError(
194-
e instanceof Error ? e : new Error("Unknown error", { cause: e }),
179+
const enterRTCSessionOrError = useCallback(
180+
async (
181+
rtcSession: MatrixRTCSession,
182+
perParticipantE2EE: boolean,
183+
newMembershipManager: boolean,
184+
): Promise<void> => {
185+
try {
186+
await enterRTCSession(
187+
rtcSession,
188+
perParticipantE2EE,
189+
newMembershipManager,
195190
);
196-
setError(error);
191+
} catch (e) {
192+
if (e instanceof ElementCallError) {
193+
showGroupCallErrorBoundary(e);
194+
} else {
195+
logger.error(`Unknown Error while entering RTC session`, e);
196+
const error = new UnknownCallError(
197+
e instanceof Error ? e : new Error("Unknown error", { cause: e }),
198+
);
199+
showGroupCallErrorBoundary(error);
200+
}
197201
}
198-
}
199-
};
202+
},
203+
[showGroupCallErrorBoundary],
204+
);
200205

201206
useEffect(() => {
202207
const defaultDeviceSetup = async ({
@@ -289,11 +294,12 @@ export const GroupCallView: FC<Props> = ({
289294
perParticipantE2EE,
290295
latestDevices,
291296
latestMuteStates,
297+
enterRTCSessionOrError,
292298
useNewMembershipManager,
293299
]);
294300

295301
const [left, setLeft] = useState(false);
296-
const [error, setError] = useState<ElementCallError | null>(null);
302+
297303
const navigate = useNavigate();
298304

299305
const onLeave = useCallback(
@@ -416,14 +422,7 @@ export const GroupCallView: FC<Props> = ({
416422
);
417423

418424
let body: ReactNode;
419-
if (error) {
420-
// If an ElementCallError was recorded, then create a component that will fail to render and throw
421-
// the error. This will then be handled by the ErrorBoundary component.
422-
const ErrorComponent = (): ReactNode => {
423-
throw error;
424-
};
425-
body = <ErrorComponent />;
426-
} else if (isJoined) {
425+
if (isJoined) {
427426
body = (
428427
<>
429428
{shareModal}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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 { it, vi } from "vitest";
9+
import { render, screen } from "@testing-library/react";
10+
import { type ReactElement, useCallback } from "react";
11+
import userEvent from "@testing-library/user-event";
12+
import { BrowserRouter } from "react-router-dom";
13+
14+
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
15+
import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts";
16+
import { ConnectionLostError } from "../utils/errors.ts";
17+
18+
it("should show async error", async () => {
19+
const user = userEvent.setup();
20+
21+
const TestComponent = (): ReactElement => {
22+
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
23+
24+
const onClick = useCallback((): void => {
25+
showGroupCallErrorBoundary(new ConnectionLostError());
26+
}, [showGroupCallErrorBoundary]);
27+
28+
return (
29+
<div>
30+
<h1>HELLO</h1>
31+
<button onClick={onClick}>Click me</button>
32+
</div>
33+
);
34+
};
35+
36+
render(
37+
<BrowserRouter>
38+
<GroupCallErrorBoundary widget={null} recoveryActionHandler={vi.fn()}>
39+
<TestComponent />
40+
</GroupCallErrorBoundary>
41+
</BrowserRouter>,
42+
);
43+
44+
await user.click(screen.getByRole("button", { name: "Click me" }));
45+
46+
await screen.findByText("Connection lost");
47+
48+
await user.click(screen.getByRole("button", { name: "Reconnect" }));
49+
50+
await screen.findByText("HELLO");
51+
});

src/room/useCallErrorBoundary.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
Copyright 2023, 2024 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 { useMemo, useState } from "react";
9+
10+
import type { ElementCallError } from "../utils/errors.ts";
11+
12+
export type UseErrorBoundaryApi = {
13+
showGroupCallErrorBoundary: (error: ElementCallError) => void;
14+
};
15+
16+
export function useGroupCallErrorBoundary(): UseErrorBoundaryApi {
17+
const [error, setError] = useState<ElementCallError | null>(null);
18+
19+
const memoized: UseErrorBoundaryApi = useMemo(
20+
() => ({
21+
showGroupCallErrorBoundary: (error: ElementCallError) => setError(error),
22+
}),
23+
[],
24+
);
25+
26+
if (error) {
27+
throw error;
28+
}
29+
30+
return memoized;
31+
}

src/utils/errors.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export enum ErrorCode {
1717
/** LiveKit indicates that the server has hit its track limits */
1818
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
1919
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
20+
OPEN_ID_ERROR = "OPEN_ID_ERROR",
2021
UNKNOWN_ERROR = "UNKNOWN_ERROR",
2122
}
2223

@@ -43,7 +44,7 @@ export class ElementCallError extends Error {
4344
localisedTitle: string,
4445
code: ErrorCode,
4546
category: ErrorCategory,
46-
localisedMessage: string,
47+
localisedMessage?: string,
4748
cause?: Error,
4849
) {
4950
super(localisedTitle, { cause });
@@ -88,7 +89,6 @@ export class RTCSessionError extends ElementCallError {
8889
super("RTCSession Error", code, ErrorCategory.RTC_SESSION_FAILURE, message);
8990
}
9091
}
91-
9292
export class E2EENotSupportedError extends ElementCallError {
9393
public constructor() {
9494
super(
@@ -113,6 +113,19 @@ export class UnknownCallError extends ElementCallError {
113113
}
114114
}
115115

116+
export class FailToGetOpenIdToken extends ElementCallError {
117+
public constructor(error: Error) {
118+
super(
119+
t("error.generic"),
120+
ErrorCode.OPEN_ID_ERROR,
121+
ErrorCategory.CONFIGURATION_ISSUE,
122+
undefined,
123+
// Properly set it as a cause for a better reporting on sentry
124+
error,
125+
);
126+
}
127+
}
128+
116129
export class InsufficientCapacityError extends ElementCallError {
117130
public constructor() {
118131
super(

0 commit comments

Comments
 (0)