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:
74
playwright/errors.spec.ts
Normal file
74
playwright/errors.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
@@ -12,6 +12,9 @@ import { useEffect, useState } from "react";
|
|||||||
import { type LivekitFocus } from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
|
import { type LivekitFocus } from "matrix-js-sdk/src/matrixrtc/LivekitFocus";
|
||||||
|
|
||||||
import { useActiveLivekitFocus } from "../room/useActiveFocus";
|
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 {
|
export interface SFUConfig {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -38,6 +41,7 @@ export function useOpenIDSFU(
|
|||||||
const [sfuConfig, setSFUConfig] = useState<SFUConfig | undefined>(undefined);
|
const [sfuConfig, setSFUConfig] = useState<SFUConfig | undefined>(undefined);
|
||||||
|
|
||||||
const activeFocus = useActiveLivekitFocus(rtcSession);
|
const activeFocus = useActiveLivekitFocus(rtcSession);
|
||||||
|
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeFocus) {
|
if (activeFocus) {
|
||||||
@@ -46,13 +50,14 @@ export function useOpenIDSFU(
|
|||||||
setSFUConfig(sfuConfig);
|
setSFUConfig(sfuConfig);
|
||||||
},
|
},
|
||||||
(e) => {
|
(e) => {
|
||||||
|
showGroupCallErrorBoundary(new FailToGetOpenIdToken(e));
|
||||||
logger.error("Failed to get SFU config", e);
|
logger.error("Failed to get SFU config", e);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setSFUConfig(undefined);
|
setSFUConfig(undefined);
|
||||||
}
|
}
|
||||||
}, [client, activeFocus]);
|
}, [client, activeFocus, showGroupCallErrorBoundary]);
|
||||||
|
|
||||||
return sfuConfig;
|
return sfuConfig;
|
||||||
}
|
}
|
||||||
@@ -61,7 +66,16 @@ export async function getSFUConfigWithOpenID(
|
|||||||
client: OpenIDClientParts,
|
client: OpenIDClientParts,
|
||||||
activeFocus: LivekitFocus,
|
activeFocus: LivekitFocus,
|
||||||
): Promise<SFUConfig | undefined> {
|
): 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);
|
logger.debug("Got openID token", openIdToken);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import {
|
|||||||
useSetting,
|
useSetting,
|
||||||
} from "../settings/settings";
|
} from "../settings/settings";
|
||||||
import { useTypedEventEmitter } from "../useEvents";
|
import { useTypedEventEmitter } from "../useEvents";
|
||||||
|
import { useGroupCallErrorBoundary } from "./useCallErrorBoundary.ts";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -120,11 +121,13 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
};
|
};
|
||||||
}, [rtcSession]);
|
}, [rtcSession]);
|
||||||
|
|
||||||
|
const { showGroupCallErrorBoundary } = useGroupCallErrorBoundary();
|
||||||
|
|
||||||
useTypedEventEmitter(
|
useTypedEventEmitter(
|
||||||
rtcSession,
|
rtcSession,
|
||||||
MatrixRTCSessionEvent.MembershipManagerError,
|
MatrixRTCSessionEvent.MembershipManagerError,
|
||||||
(error) => {
|
(error) => {
|
||||||
setError(
|
showGroupCallErrorBoundary(
|
||||||
new RTCSessionError(
|
new RTCSessionError(
|
||||||
ErrorCode.MEMBERSHIP_MANAGER_UNRECOVERABLE,
|
ErrorCode.MEMBERSHIP_MANAGER_UNRECOVERABLE,
|
||||||
error.message ?? error,
|
error.message ?? error,
|
||||||
@@ -173,30 +176,32 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
const latestDevices = useLatest(deviceContext);
|
const latestDevices = useLatest(deviceContext);
|
||||||
const latestMuteStates = useLatest(muteStates);
|
const latestMuteStates = useLatest(muteStates);
|
||||||
|
|
||||||
const enterRTCSessionOrError = async (
|
const enterRTCSessionOrError = useCallback(
|
||||||
rtcSession: MatrixRTCSession,
|
async (
|
||||||
perParticipantE2EE: boolean,
|
rtcSession: MatrixRTCSession,
|
||||||
newMembershipManager: boolean,
|
perParticipantE2EE: boolean,
|
||||||
): Promise<void> => {
|
newMembershipManager: boolean,
|
||||||
try {
|
): Promise<void> => {
|
||||||
await enterRTCSession(
|
try {
|
||||||
rtcSession,
|
await enterRTCSession(
|
||||||
perParticipantE2EE,
|
rtcSession,
|
||||||
newMembershipManager,
|
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 }),
|
|
||||||
);
|
);
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
const defaultDeviceSetup = async ({
|
const defaultDeviceSetup = async ({
|
||||||
@@ -289,11 +294,12 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
perParticipantE2EE,
|
perParticipantE2EE,
|
||||||
latestDevices,
|
latestDevices,
|
||||||
latestMuteStates,
|
latestMuteStates,
|
||||||
|
enterRTCSessionOrError,
|
||||||
useNewMembershipManager,
|
useNewMembershipManager,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [left, setLeft] = useState(false);
|
const [left, setLeft] = useState(false);
|
||||||
const [error, setError] = useState<ElementCallError | null>(null);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const onLeave = useCallback(
|
const onLeave = useCallback(
|
||||||
@@ -416,14 +422,7 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
let body: ReactNode;
|
let body: ReactNode;
|
||||||
if (error) {
|
if (isJoined) {
|
||||||
// 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) {
|
|
||||||
body = (
|
body = (
|
||||||
<>
|
<>
|
||||||
{shareModal}
|
{shareModal}
|
||||||
|
|||||||
51
src/room/useCallErrorBoundary.test.tsx
Normal file
51
src/room/useCallErrorBoundary.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
31
src/room/useCallErrorBoundary.ts
Normal file
31
src/room/useCallErrorBoundary.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export enum ErrorCode {
|
|||||||
/** LiveKit indicates that the server has hit its track limits */
|
/** LiveKit indicates that the server has hit its track limits */
|
||||||
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
|
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
|
||||||
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
|
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
|
||||||
|
OPEN_ID_ERROR = "OPEN_ID_ERROR",
|
||||||
UNKNOWN_ERROR = "UNKNOWN_ERROR",
|
UNKNOWN_ERROR = "UNKNOWN_ERROR",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ export class ElementCallError extends Error {
|
|||||||
localisedTitle: string,
|
localisedTitle: string,
|
||||||
code: ErrorCode,
|
code: ErrorCode,
|
||||||
category: ErrorCategory,
|
category: ErrorCategory,
|
||||||
localisedMessage: string,
|
localisedMessage?: string,
|
||||||
cause?: Error,
|
cause?: Error,
|
||||||
) {
|
) {
|
||||||
super(localisedTitle, { cause });
|
super(localisedTitle, { cause });
|
||||||
@@ -88,7 +89,6 @@ export class RTCSessionError extends ElementCallError {
|
|||||||
super("RTCSession Error", code, ErrorCategory.RTC_SESSION_FAILURE, message);
|
super("RTCSession Error", code, ErrorCategory.RTC_SESSION_FAILURE, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class E2EENotSupportedError extends ElementCallError {
|
export class E2EENotSupportedError extends ElementCallError {
|
||||||
public constructor() {
|
public constructor() {
|
||||||
super(
|
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 {
|
export class InsufficientCapacityError extends ElementCallError {
|
||||||
public constructor() {
|
public constructor() {
|
||||||
super(
|
super(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { IndexedDBStore } from "matrix-js-sdk/src/store/indexeddb";
|
import { IndexedDBStore } from "matrix-js-sdk/src/store/indexeddb";
|
||||||
import { MemoryStore } from "matrix-js-sdk/src/store/memory";
|
import { MemoryStore } from "matrix-js-sdk/src/store/memory";
|
||||||
import {
|
import {
|
||||||
|
calculateRetryBackoff,
|
||||||
createClient,
|
createClient,
|
||||||
type ICreateClientOpts,
|
type ICreateClientOpts,
|
||||||
Preset,
|
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 { type ISyncStateData, type SyncState } from "matrix-js-sdk/src/sync";
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
import { secureRandomBase64Url } from "matrix-js-sdk/src/randomstring";
|
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 { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import type { Room } from "matrix-js-sdk/src/models/room";
|
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()}`;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user