Merge pull request #3076 from element-hq/valere/async_error_show_boundary

Error management: Handle fail to get JWT token
This commit is contained in:
Valere Fedronic
2025-03-19 09:41:00 +01:00
committed by GitHub
7 changed files with 247 additions and 36 deletions

74
playwright/errors.spec.ts Normal file
View File

@@ -0,0 +1,74 @@
/*
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 { expect, test } from "@playwright/test";
test("Should show error screen if fails to get JWT token", async ({ page }) => {
await page.goto("/");
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("HelloCall");
await page.getByTestId("home_displayName").click();
await page.getByTestId("home_displayName").fill("John Doe");
await page.getByTestId("home_go").click();
await page.route(
"**/openid/request_token",
async (route) =>
await route.fulfill({
// 418 is a non retryable error, so test will fail immediately
status: 418,
}),
);
// Join the call
await page.getByTestId("lobby_joinCall").click();
// Should fail
await expect(page.getByText("Something went wrong")).toBeVisible();
await expect(page.getByText("OPEN_ID_ERROR")).toBeVisible();
});
test("Should automatically retry non fatal JWT errors", async ({
page,
browserName,
}) => {
test.skip(
browserName === "firefox",
"The test to check the video visibility is not working in Firefox CI environment. looks like video is disabled?",
);
await page.goto("/");
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("HelloCall");
await page.getByTestId("home_displayName").click();
await page.getByTestId("home_displayName").fill("John Doe");
await page.getByTestId("home_go").click();
let firstCall = true;
let hasRetriedCallback: (value: PromiseLike<void> | void) => void;
const hasRetriedPromise = new Promise<void>((resolve) => {
hasRetriedCallback = resolve;
});
await page.route("**/openid/request_token", async (route) => {
if (firstCall) {
firstCall = false;
await route.fulfill({
status: 429,
});
} else {
await route.continue();
hasRetriedCallback();
}
});
// Join the call
await page.getByTestId("lobby_joinCall").click();
// Expect that the call has been retried
await hasRetriedPromise;
await expect(page.getByTestId("video").first()).toBeVisible();
});

View File

@@ -12,6 +12,9 @@ import { useEffect, useState } from "react";
import { type LivekitFocus } from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
import { useActiveLivekitFocus } from "../room/useActiveFocus";
import { useGroupCallErrorBoundary } from "../room/useCallErrorBoundary.ts";
import { FailToGetOpenIdToken } from "../utils/errors.ts";
import { doNetworkOperationWithRetry } from "../utils/matrix.ts";
export interface SFUConfig {
url: string;
@@ -38,6 +41,7 @@ export function useOpenIDSFU(
const [sfuConfig, setSFUConfig] = useState<SFUConfig | undefined>(undefined);
const activeFocus = useActiveLivekitFocus(rtcSession);
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
useEffect(() => {
if (activeFocus) {
@@ -46,13 +50,14 @@ export function useOpenIDSFU(
setSFUConfig(sfuConfig);
},
(e) => {
showGroupCallErrorBoundary(new FailToGetOpenIdToken(e));
logger.error("Failed to get SFU config", e);
},
);
} else {
setSFUConfig(undefined);
}
}, [client, activeFocus]);
}, [client, activeFocus, showGroupCallErrorBoundary]);
return sfuConfig;
}
@@ -61,7 +66,16 @@ export async function getSFUConfigWithOpenID(
client: OpenIDClientParts,
activeFocus: LivekitFocus,
): Promise<SFUConfig | undefined> {
const openIdToken = await client.getOpenIdToken();
let openIdToken: IOpenIDToken;
try {
openIdToken = await doNetworkOperationWithRetry(async () =>
client.getOpenIdToken(),
);
} catch (error) {
throw new FailToGetOpenIdToken(
error instanceof Error ? error : new Error("Unknown error"),
);
}
logger.debug("Got openID token", openIdToken);
try {

View File

@@ -67,6 +67,7 @@ import {
useSetting,
} from "../settings/settings";
import { useTypedEventEmitter } from "../useEvents";
import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts";
declare global {
interface Window {
@@ -120,11 +121,13 @@ export const GroupCallView: FC<Props> = ({
};
}, [rtcSession]);
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
useTypedEventEmitter(
rtcSession,
MatrixRTCSessionEvent.MembershipManagerError,
(error) => {
setError(
showGroupCallErrorBoundary(
new RTCSessionError(
ErrorCode.MEMBERSHIP_MANAGER_UNRECOVERABLE,
error.message ?? error,
@@ -173,30 +176,32 @@ export const GroupCallView: FC<Props> = ({
const latestDevices = useLatest(deviceContext);
const latestMuteStates = useLatest(muteStates);
const enterRTCSessionOrError = async (
rtcSession: MatrixRTCSession,
perParticipantE2EE: boolean,
newMembershipManager: boolean,
): Promise<void> => {
try {
await enterRTCSession(
rtcSession,
perParticipantE2EE,
newMembershipManager,
);
} catch (e) {
if (e instanceof ElementCallError) {
// e.code === ErrorCode.MISSING_LIVE_KIT_SERVICE_URL)
setError(e);
} else {
logger.error(`Unknown Error while entering RTC session`, e);
const error = new UnknownCallError(
e instanceof Error ? e : new Error("Unknown error", { cause: e }),
const enterRTCSessionOrError = useCallback(
async (
rtcSession: MatrixRTCSession,
perParticipantE2EE: boolean,
newMembershipManager: boolean,
): Promise<void> => {
try {
await enterRTCSession(
rtcSession,
perParticipantE2EE,
newMembershipManager,
);
setError(error);
} catch (e) {
if (e instanceof ElementCallError) {
showGroupCallErrorBoundary(e);
} else {
logger.error(`Unknown Error while entering RTC session`, e);
const error = new UnknownCallError(
e instanceof Error ? e : new Error("Unknown error", { cause: e }),
);
showGroupCallErrorBoundary(error);
}
}
}
};
},
[showGroupCallErrorBoundary],
);
useEffect(() => {
const defaultDeviceSetup = async ({
@@ -289,11 +294,12 @@ export const GroupCallView: FC<Props> = ({
perParticipantE2EE,
latestDevices,
latestMuteStates,
enterRTCSessionOrError,
useNewMembershipManager,
]);
const [left, setLeft] = useState(false);
const [error, setError] = useState<ElementCallError | null>(null);
const navigate = useNavigate();
const onLeave = useCallback(
@@ -416,14 +422,7 @@ export const GroupCallView: FC<Props> = ({
);
let body: ReactNode;
if (error) {
// If an ElementCallError was recorded, then create a component that will fail to render and throw
// the error. This will then be handled by the ErrorBoundary component.
const ErrorComponent = (): ReactNode => {
throw error;
};
body = <ErrorComponent />;
} else if (isJoined) {
if (isJoined) {
body = (
<>
{shareModal}

View File

@@ -0,0 +1,51 @@
/*
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 { it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { type ReactElement, useCallback } from "react";
import userEvent from "@testing-library/user-event";
import { BrowserRouter } from "react-router-dom";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts";
import { ConnectionLostError } from "../utils/errors.ts";
it("should show async error", async () => {
const user = userEvent.setup();
const TestComponent = (): ReactElement => {
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
const onClick = useCallback((): void => {
showGroupCallErrorBoundary(new ConnectionLostError());
}, [showGroupCallErrorBoundary]);
return (
<div>
<h1>HELLO</h1>
<button onClick={onClick}>Click me</button>
</div>
);
};
render(
<BrowserRouter>
<GroupCallErrorBoundary widget={null} recoveryActionHandler={vi.fn()}>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await user.click(screen.getByRole("button", { name: "Click me" }));
await screen.findByText("Connection lost");
await user.click(screen.getByRole("button", { name: "Reconnect" }));
await screen.findByText("HELLO");
});

View File

@@ -0,0 +1,31 @@
/*
Copyright 2023, 2024 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 { useMemo, useState } from "react";
import type { ElementCallError } from "../utils/errors.ts";
export type UseErrorBoundaryApi = {
showGroupCallErrorBoundary: (error: ElementCallError) => void;
};
export function useGroupCallErrorBoundary(): UseErrorBoundaryApi {
const [error, setError] = useState<ElementCallError | null>(null);
const memoized: UseErrorBoundaryApi = useMemo(
() => ({
showGroupCallErrorBoundary: (error: ElementCallError) => setError(error),
}),
[],
);
if (error) {
throw error;
}
return memoized;
}

View File

@@ -17,6 +17,7 @@ export enum ErrorCode {
/** LiveKit indicates that the server has hit its track limits */
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
OPEN_ID_ERROR = "OPEN_ID_ERROR",
UNKNOWN_ERROR = "UNKNOWN_ERROR",
}
@@ -43,7 +44,7 @@ export class ElementCallError extends Error {
localisedTitle: string,
code: ErrorCode,
category: ErrorCategory,
localisedMessage: string,
localisedMessage?: string,
cause?: Error,
) {
super(localisedTitle, { cause });
@@ -88,7 +89,6 @@ export class RTCSessionError extends ElementCallError {
super("RTCSession Error", code, ErrorCategory.RTC_SESSION_FAILURE, message);
}
}
export class E2EENotSupportedError extends ElementCallError {
public constructor() {
super(
@@ -113,6 +113,19 @@ export class UnknownCallError extends ElementCallError {
}
}
export class FailToGetOpenIdToken extends ElementCallError {
public constructor(error: Error) {
super(
t("error.generic"),
ErrorCode.OPEN_ID_ERROR,
ErrorCategory.CONFIGURATION_ISSUE,
undefined,
// Properly set it as a cause for a better reporting on sentry
error,
);
}
}
export class InsufficientCapacityError extends ElementCallError {
public constructor() {
super(

View File

@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import { IndexedDBStore } from "matrix-js-sdk/src/store/indexeddb";
import { MemoryStore } from "matrix-js-sdk/src/store/memory";
import {
calculateRetryBackoff,
createClient,
type ICreateClientOpts,
Preset,
@@ -17,6 +18,7 @@ import { ClientEvent } from "matrix-js-sdk/src/client";
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/src/sync";
import { logger } from "matrix-js-sdk/src/logger";
import { secureRandomBase64Url } from "matrix-js-sdk/src/randomstring";
import { sleep } from "matrix-js-sdk/src/utils";
import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { Room } from "matrix-js-sdk/src/models/room";
@@ -335,3 +337,30 @@ export function getRelativeRoomUrl(
: "";
return `/room/#${roomPart}?${generateUrlSearchParams(roomId, encryptionSystem, viaServers).toString()}`;
}
/**
* Perfom a network operation with retries on ConnectionError.
* If the error is not retryable, or the max number of retries is reached, the error is rethrown.
* Supports handling of matrix quotas.
*/
export async function doNetworkOperationWithRetry<T>(
operation: () => Promise<T>,
): Promise<T> {
let currentRetryCount = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
try {
return await operation();
} catch (e) {
currentRetryCount++;
const backoff = calculateRetryBackoff(e, currentRetryCount, true);
if (backoff < 0) {
// Max number of retries reached, or error is not retryable. rethrow the error
throw e;
}
// wait for the specified time and then retry the request
await sleep(backoff);
}
}
}