make errors more verbose

This commit is contained in:
Timo K.
2026-08-30 11:39:18 +02:00
parent efde51b8c1
commit 42497733ff
8 changed files with 167 additions and 24 deletions
+16 -4
View File
@@ -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",
);
+17 -3
View File
@@ -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 },
),
);
}
}
+17 -8
View File
@@ -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}
+7 -1
View File
@@ -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
View File
@@ -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;
}