mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
make errors more verbose
This commit is contained in:
@@ -79,8 +79,14 @@ describe("getSFUConfigWithOpenID", () => {
|
||||
);
|
||||
} catch (ex: unknown) {
|
||||
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
|
||||
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
|
||||
const mxError = (ex as Error).cause as MatrixError;
|
||||
// The cause is a wrapper naming the operation we were performing; the
|
||||
// underlying MatrixError sits behind it.
|
||||
const context = (ex as FailToGetOpenIdToken).cause as Error;
|
||||
expect(context.message).toEqual(
|
||||
"Failed to get a JWT from the legacy endpoint of the MatrixRTC backend at https://sfu.example.org",
|
||||
);
|
||||
expect(context.cause).toBeInstanceOf(MatrixError);
|
||||
const mxError = context.cause as MatrixError;
|
||||
expect(mxError.message).toEqual(
|
||||
"MatrixError: [500] Failed to look up user info from homeserver",
|
||||
);
|
||||
@@ -216,8 +222,14 @@ describe("getSFUConfigWithOpenID", () => {
|
||||
);
|
||||
} catch (ex) {
|
||||
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
|
||||
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
|
||||
const mxError = (ex as Error).cause as MatrixError;
|
||||
// The cause is a wrapper naming the operation we were performing; the
|
||||
// underlying MatrixError sits behind it.
|
||||
const context = (ex as FailToGetOpenIdToken).cause as Error;
|
||||
expect(context.message).toEqual(
|
||||
"Failed to get a JWT from the legacy endpoint of the MatrixRTC backend at https://sfu.example.org",
|
||||
);
|
||||
expect(context.cause).toBeInstanceOf(MatrixError);
|
||||
const mxError = context.cause as MatrixError;
|
||||
expect(mxError.message).toEqual(
|
||||
"MatrixError: [500] Failed to look up user info from homeserver",
|
||||
);
|
||||
|
||||
@@ -109,8 +109,14 @@ export async function getSFUConfigWithOpenID(
|
||||
client.getOpenIdToken(),
|
||||
);
|
||||
} catch (error) {
|
||||
// Note that in widget mode this is the `get_openid` widget action rather
|
||||
// than a homeserver request, so the hosting client is a likely culprit.
|
||||
throw new FailToGetOpenIdToken(
|
||||
error instanceof Error ? error : new Error("Unknown error"),
|
||||
new Error(
|
||||
`Failed to get an OpenID token, needed to authenticate with the MatrixRTC backend at ${serviceUrl}` +
|
||||
`${opts?.delayId ? " (re-authenticating to delegate delayed leave event " + opts.delayId + ")" : ""}`,
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
}
|
||||
logger?.debug("Got openID token", openIdToken);
|
||||
@@ -142,7 +148,12 @@ export async function getSFUConfigWithOpenID(
|
||||
logger?.debug(`Failed fetching jwt with matrix 2.0 endpoint:`, e);
|
||||
// Make this throw a hard error in case we force the matrix2.0 endpoint.
|
||||
if (forceMatrix2Jwt) {
|
||||
throw new NoMatrix2AuthorizationService(e as Error);
|
||||
throw new NoMatrix2AuthorizationService(
|
||||
new Error(
|
||||
`Failed to get a JWT from the Matrix 2.0 endpoint of the MatrixRTC backend at ${serviceUrl}`,
|
||||
{ cause: e },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,7 +177,10 @@ export async function getSFUConfigWithOpenID(
|
||||
return extractFullConfigFromToken(sfuConfig);
|
||||
} catch (ex) {
|
||||
throw new FailToGetOpenIdToken(
|
||||
ex instanceof Error ? ex : new Error(`Unknown error ${ex}`),
|
||||
new Error(
|
||||
`Failed to get a JWT from the legacy endpoint of the MatrixRTC backend at ${serviceUrl}`,
|
||||
{ cause: ex },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,10 @@ import {
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { MatrixError } from "matrix-js-sdk";
|
||||
|
||||
import {
|
||||
ConnectionLostError,
|
||||
describeErrorChain,
|
||||
ElementCallError,
|
||||
ErrorCategory,
|
||||
ErrorCode,
|
||||
@@ -80,8 +80,13 @@ const ErrorPage: FC<ErrorPageProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
const technicalError =
|
||||
error.cause instanceof MatrixError ? error.cause : null;
|
||||
// Show the whole cause chain rather than just a `MatrixError` cause: the
|
||||
// request that actually failed is often several wrappers deep, and errors
|
||||
// that are not `MatrixError`s (widget API timeouts, LiveKit connection
|
||||
// errors, ...) used to leave this section empty entirely. A chain of length
|
||||
// one is just the error we already render above, so keep it hidden.
|
||||
const errorChain = describeErrorChain(error);
|
||||
const technicalDetails = errorChain.length > 1 ? errorChain.join("\n") : null;
|
||||
|
||||
return (
|
||||
<FullScreenView>
|
||||
@@ -119,14 +124,12 @@ const ErrorPage: FC<ErrorPageProps> = ({
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
{technicalError ? (
|
||||
{technicalDetails ? (
|
||||
<details className={styles.technicalDetails}>
|
||||
<summary className={styles.technicalDetailsSummary}>
|
||||
{t("technical_details")}
|
||||
</summary>
|
||||
<pre className={styles.technicalDetailsPre}>
|
||||
{technicalError.message}
|
||||
</pre>
|
||||
<pre className={styles.technicalDetailsPre}>{technicalDetails}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
{actions &&
|
||||
@@ -162,7 +165,13 @@ export const GroupCallErrorBoundary = ({
|
||||
const callError =
|
||||
error instanceof ElementCallError
|
||||
? error
|
||||
: new UnknownCallError(error instanceof Error ? error : new Error());
|
||||
: new UnknownCallError(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(
|
||||
`Non-error value thrown during the call: ${String(error)}`,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<ErrorPage
|
||||
widget={widget ?? null}
|
||||
|
||||
@@ -179,7 +179,13 @@ export const GroupCallView: FC<Props> = ({
|
||||
) {
|
||||
setExternalError(new StickyEventsRequiredError());
|
||||
} else {
|
||||
setExternalError(new ConnectionLostError());
|
||||
logger.error("MembershipManager gave up on the session", error);
|
||||
// Keep the original error as the cause: the scheduler only tells us
|
||||
// that it shut down, while the request that actually failed (and its
|
||||
// errcode) lives further down the cause chain.
|
||||
setExternalError(
|
||||
new ConnectionLostError(error instanceof Error ? error : undefined),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -179,7 +179,11 @@ export const createLocalTransport$ = ({
|
||||
`Failed to authenticate to transport ${transport.livekit_service_url}`,
|
||||
e,
|
||||
);
|
||||
throw mapAuthErrorToUserFriendlyError(e);
|
||||
throw mapAuthErrorToUserFriendlyError(
|
||||
e,
|
||||
transport.livekit_service_url,
|
||||
delayId,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -258,16 +262,26 @@ async function doOpenIdAndJWTFromUrl(
|
||||
};
|
||||
}
|
||||
|
||||
function mapAuthErrorToUserFriendlyError(e: unknown): Error {
|
||||
function mapAuthErrorToUserFriendlyError(
|
||||
e: unknown,
|
||||
serviceUrl: string,
|
||||
delayId: string | null,
|
||||
): Error {
|
||||
if (
|
||||
e instanceof FailToGetOpenIdToken ||
|
||||
e instanceof NoMatrix2AuthorizationService
|
||||
) {
|
||||
// rethrow as is
|
||||
// Already carries its own context, rethrow as is.
|
||||
return e;
|
||||
}
|
||||
// Catch others and rethrow as FailToGetOpenIdToken that has user friendly message.
|
||||
// Catch others and rethrow as FailToGetOpenIdToken that has user friendly
|
||||
// message. Record what we were doing, since this branch is reached by
|
||||
// anything unexpected and otherwise leaves no trace of it.
|
||||
return new FailToGetOpenIdToken(
|
||||
e instanceof Error ? e : new Error(String(e)),
|
||||
new Error(
|
||||
`Unexpected error while authenticating with the MatrixRTC backend at ${serviceUrl}` +
|
||||
`${delayId ? ` (re-authenticating to delegate delayed leave event ${delayId})` : ""}`,
|
||||
{ cause: e },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,10 @@ export class RtcTransportAutoDiscovery {
|
||||
);
|
||||
}
|
||||
} catch (ex) {
|
||||
this.logger.info(`Failed to use getRTCTransports end point: ${ex}`);
|
||||
// Expected against homeservers without MSC4143, so this is not a
|
||||
// warning, but pass the error itself so the stack and any errcode
|
||||
// survive into the logs rather than being flattened into a string.
|
||||
this.logger.info("Failed to use getRTCTransports end point", ex);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import { ObservableScope } from "../../ObservableScope.ts";
|
||||
import { type OpenIDClientParts } from "../../../livekit/openIDSFU.ts";
|
||||
import {
|
||||
describeErrorChain,
|
||||
ElementCallError,
|
||||
FailToGetOpenIdToken,
|
||||
} from "../../../utils/errors.ts";
|
||||
@@ -284,7 +285,9 @@ describe("Start connection states", () => {
|
||||
capturedState instanceof ElementCallError &&
|
||||
capturedState.cause instanceof Error
|
||||
) {
|
||||
expect(capturedState.cause.message).toContain(
|
||||
// The homeserver error is wrapped in a context error naming the
|
||||
// operation that failed, so assert against the whole cause chain.
|
||||
expect(describeErrorChain(capturedState).join("\n")).toContain(
|
||||
"Failed to look up user info from homeserver",
|
||||
);
|
||||
expect(connection.transport.livekit_alias).toEqual(
|
||||
|
||||
+83
-1
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { t } from "i18next";
|
||||
import { type ConnectionError } from "livekit-client";
|
||||
import { HTTPError, MatrixError } from "matrix-js-sdk";
|
||||
|
||||
import { i18nKey } from "./i18n";
|
||||
|
||||
@@ -95,12 +96,20 @@ export class MatrixRTCTransportMissingError extends ElementCallError {
|
||||
* Error indicating that the connection to the call was lost and could not be re-established.
|
||||
*/
|
||||
export class ConnectionLostError extends ElementCallError {
|
||||
public constructor() {
|
||||
/**
|
||||
* Creates an instance of ConnectionLostError.
|
||||
*
|
||||
* @param cause - The underlying error that made us give up on the
|
||||
* connection, if there is one. It is not shown to the user directly, but it
|
||||
* ends up in the technical details of the error screen and in Sentry.
|
||||
*/
|
||||
public constructor(cause?: Error) {
|
||||
super(
|
||||
t("error.connection_lost"),
|
||||
ErrorCode.CONNECTION_LOST_ERROR,
|
||||
ErrorCategory.NETWORK_CONNECTIVITY,
|
||||
t("error.connection_lost_description"),
|
||||
cause,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -293,3 +302,76 @@ export class LivekitConnectionError extends ElementCallError {
|
||||
this.localisedMessageValues = { reason: cause.reasonName };
|
||||
}
|
||||
}
|
||||
|
||||
/** Maximum number of `cause` levels walked by {@link describeErrorChain}. */
|
||||
const MAX_CAUSE_DEPTH = 8;
|
||||
|
||||
/** Prefix that `@sentry/react` gives the synthetic error it appends as a cause. */
|
||||
const SENTRY_WRAPPER_NAME = "React ErrorBoundary ";
|
||||
|
||||
/**
|
||||
* Describes a single error, including the protocol level details that Matrix
|
||||
* and HTTP errors carry but leave out of their `message`.
|
||||
*/
|
||||
function describeError(error: unknown): string {
|
||||
if (error instanceof MatrixError) {
|
||||
// An `M_UNKNOWN` errcode usually means the failure did not come from the
|
||||
// homeserver at all: when we run as a widget, the hosting client's widget
|
||||
// driver serialises any error it cannot map onto a Matrix error that way
|
||||
// (see `MatrixError.asWidgetApiErrorData`). The url and status are then far
|
||||
// more telling than the errcode, so always print them.
|
||||
const parts = [
|
||||
`errcode=${error.errcode ?? "<none>"}`,
|
||||
`httpStatus=${error.httpStatus ?? "<none>"}`,
|
||||
];
|
||||
if (error.url) parts.push(`url=${error.url}`);
|
||||
if (error.error) parts.push(`error=${error.error}`);
|
||||
return `MatrixError(${parts.join(", ")}): ${error.message}`;
|
||||
}
|
||||
if (error instanceof HTTPError)
|
||||
return `HTTPError(httpStatus=${error.httpStatus ?? "<none>"}): ${error.message}`;
|
||||
if (error instanceof ElementCallError)
|
||||
return `ElementCallError(code=${error.code}, category=${error.category}): ${error.localisedTitle}`;
|
||||
if (error instanceof Error) return `${error.name}: ${error.message}`;
|
||||
return `Non-error value: ${String(error)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an error together with its whole `cause` chain.
|
||||
*
|
||||
* By the time an error reaches the error screen it has usually been wrapped
|
||||
* several times over (the MembershipManager scheduler, an
|
||||
* {@link ElementCallError} subclass, a context wrapper at the call site, ...),
|
||||
* so showing only the outermost error, or a single `cause` hop, tends to hide
|
||||
* the request that actually failed.
|
||||
*
|
||||
* @param error - The error to describe.
|
||||
* @returns One entry per error in the chain, outermost wrapper first. A chain
|
||||
* of length one means we have nothing beyond the error itself to report.
|
||||
*/
|
||||
export function describeErrorChain(error: unknown): string[] {
|
||||
const lines: string[] = [];
|
||||
let current: unknown = error;
|
||||
for (
|
||||
let depth = 0;
|
||||
current !== undefined && depth < MAX_CAUSE_DEPTH;
|
||||
depth++
|
||||
) {
|
||||
// `@sentry/react` appends a synthetic error to the end of the chain to
|
||||
// carry the React component stack. That is useful in Sentry, but here it
|
||||
// only repeats the message of the error it is attached to.
|
||||
if (
|
||||
current instanceof Error &&
|
||||
current.name.startsWith(SENTRY_WRAPPER_NAME)
|
||||
)
|
||||
break;
|
||||
lines.push(
|
||||
(depth === 0 ? "" : `${" ".repeat(depth)}caused by: `) +
|
||||
describeError(current),
|
||||
);
|
||||
// Only `Error`s carry a `cause`, and a self-referencing one would loop.
|
||||
if (!(current instanceof Error) || current.cause === current) break;
|
||||
current = current.cause;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user