Fall back to get_token as subscriber on widget API errors

This commit is contained in:
Johannes Marbach
2026-10-12 16:03:00 +02:00
parent f8258f6a1d
commit 97b5e61ad8
4 changed files with 87 additions and 25 deletions
+16 -3
View File
@@ -5,12 +5,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type MatrixClient } from "matrix-js-sdk"; import { ConnectionError, HTTPError, type MatrixClient } from "matrix-js-sdk";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { extractFullConfigFromToken, type SFUConfig } from "./types.ts"; import { extractFullConfigFromToken, type SFUConfig } from "./types.ts";
import { doNetworkOperationWithRetry } from "../../utils/matrix.ts"; import { doNetworkOperationWithRetry } from "../../utils/matrix.ts";
/**
* In widget mode the host may refuse an action before any request is made
* which surfaces as a plain error. Asking again cannot change its answer,
* so only errors from the network are worth retrying.
*/
function isNetworkError(e: unknown): boolean {
return e instanceof HTTPError || e instanceof ConnectionError;
}
export type ClientGetTokenParts = Pick< export type ClientGetTokenParts = Pick<
MatrixClient, MatrixClient,
"_unstable_getLivekitToken" "_unstable_getLivekitToken"
@@ -55,7 +64,8 @@ export async function getSFUConfig({
roomId, roomId,
slotId, slotId,
}: GetSFUConfigParams): Promise<SFUConfig> { }: GetSFUConfigParams): Promise<SFUConfig> {
const res = await doNetworkOperationWithRetry(async () => const res = await doNetworkOperationWithRetry(
async () =>
client._unstable_getLivekitToken({ client._unstable_getLivekitToken({
url, url,
server_name: serverName, server_name: serverName,
@@ -63,6 +73,7 @@ export async function getSFUConfig({
slot_id: slotId, slot_id: slotId,
member_id: membership.memberId, member_id: membership.memberId,
}), }),
isNetworkError,
); );
return extractFullConfigFromToken({ url, jwt: res.jwt }); return extractFullConfigFromToken({ url, jwt: res.jwt });
} }
@@ -112,7 +123,8 @@ export async function delegateDelayedLeave({
slotId, slotId,
delayId, delayId,
}: DelegateDelayedLeaveParams): Promise<void> { }: DelegateDelayedLeaveParams): Promise<void> {
await doNetworkOperationWithRetry(async () => await doNetworkOperationWithRetry(
async () =>
client._unstable_delegateDelayedLeave({ client._unstable_delegateDelayedLeave({
url, url,
room_id: roomId, room_id: roomId,
@@ -120,5 +132,6 @@ export async function delegateDelayedLeave({
member_id: membership.memberId, member_id: membership.memberId,
delay_id: delayId, delay_id: delayId,
}), }),
isNetworkError,
); );
} }
+28
View File
@@ -158,6 +158,34 @@ describe("getSFUConfig", () => {
expect(openID.getSFUConfig).toHaveBeenCalled(); expect(openID.getSFUConfig).toHaveBeenCalled();
}); });
it("falls back to OpenID when the widget host refuses the CS API", async () => {
// In widget mode a host without MSC4533 rejects the request before it
// reaches the homeserver, with a plain error rather than a Matrix error
client._unstable_getLivekitToken.mockRejectedValueOnce(
new Error(
"The rtc_livekit_get_token action is not supported by the client.",
),
);
expect(
await getSFUConfig({
...defaultParams,
transport: urlAndServiceUrlTransport,
}),
).toEqual(expect.objectContaining({ jwt: openIDJWTToken }));
expect(openID.getSFUConfig).toHaveBeenCalled();
});
it("surfaces the widget host's refusal with url transport", async () => {
const error = new Error(
"The rtc_livekit_get_token action is not supported by the client.",
);
client._unstable_getLivekitToken.mockRejectedValueOnce(error);
await expect(
getSFUConfig({ ...defaultParams, transport: urlTransport }),
).rejects.toThrow(error);
expect(openID.getSFUConfig).not.toHaveBeenCalled();
});
it("does not fall back to OpenID when publishing", async () => { it("does not fall back to OpenID when publishing", async () => {
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError); client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
await expect( await expect(
+22 -7
View File
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
import { MatrixError } from "matrix-js-sdk"; import { ConnectionError, MatrixError } from "matrix-js-sdk";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc"; import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import * as openID from "./openID"; import * as openID from "./openID";
import * as csApi from "./csApi"; import * as csApi from "./csApi";
@@ -38,6 +38,19 @@ function isEndpointUnsupported(e: unknown): boolean {
); );
} }
/**
* Checks whether an error means the MSC4195 CS API cannot be used from here:
* the homeserver does not implement it, or the request never reached the
* homeserver because in widget mode the host does not expose the endpoint
* (MSC4533), which the widget API reports as a plain error rather than a
* Matrix error. A connection error is neither, as the endpoint may well exist.
* @param e The error to check.
*/
function isCsApiUnavailable(e: unknown): boolean {
if (isEndpointUnsupported(e)) return true;
return !(e instanceof MatrixError) && !(e instanceof ConnectionError);
}
export interface GetSFUConfigParams { export interface GetSFUConfigParams {
/** /**
* The Matrix client. * The Matrix client.
@@ -113,23 +126,25 @@ export async function getSFUConfig({
slotId, slotId,
}); });
} catch (e) { } catch (e) {
if (!isEndpointUnsupported(e)) throw e;
// Publishers should never fall back. If a homeserver advertises a `url` // Publishers should never fall back. If a homeserver advertises a `url`
// but doesn't support MSC4195 itself, that's a misconfiguration which // but doesn't support MSC4195 itself, that's a misconfiguration which
// the admin should know about so they can prepare for the future // the admin should know about so they can prepare for the future
// deprecation of `livekit_service_url`. // deprecation of `livekit_service_url`.
const mayFallBack = const mayFallBack =
role === "subscriber" && "livekit_service_url" in transport; role === "subscriber" && "livekit_service_url" in transport;
if (!mayFallBack) { if (mayFallBack && isCsApiUnavailable(e)) {
logger.warn(
`Cannot use the MSC4195 get_token endpoint (${e}), falling back to the OpenID flow with service ${transport.livekit_service_url}`,
);
return await viaOpenID(transport.livekit_service_url);
}
if (isEndpointUnsupported(e)) {
logger.error( logger.error(
`Homeserver does not support the MSC4195 get_token endpoint, cannot get token for transport ${flow.url}`, `Homeserver does not support the MSC4195 get_token endpoint, cannot get token for transport ${flow.url}`,
); );
throw new NoMatrix2AuthorizationService(e as Error); throw new NoMatrix2AuthorizationService(e as Error);
} }
logger.warn( throw e;
`Homeserver does not support the MSC4195 get_token endpoint, falling back to the OpenID flow with service ${transport.livekit_service_url}`,
);
return await viaOpenID(transport.livekit_service_url);
} }
} }
+7 -1
View File
@@ -341,9 +341,13 @@ export function getRelativeRoomUrl(
* Perform a network operation with retries on ConnectionError. * Perform 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. * If the error is not retryable, or the max number of retries is reached, the error is rethrown.
* Supports handling of matrix quotas. * Supports handling of matrix quotas.
* @param operation The operation to perform.
* @param shouldRetry Rules out errors that retrying cannot fix, on top of the
* standard backoff rule. Retries everything by default.
*/ */
export async function doNetworkOperationWithRetry<T>( export async function doNetworkOperationWithRetry<T>(
operation: () => Promise<T>, operation: () => Promise<T>,
shouldRetry: (e: unknown) => boolean = () => true,
): Promise<T> { ): Promise<T> {
let currentRetryCount = 0; let currentRetryCount = 0;
@@ -353,7 +357,9 @@ export async function doNetworkOperationWithRetry<T>(
return await operation(); return await operation();
} catch (e) { } catch (e) {
currentRetryCount++; currentRetryCount++;
const backoff = calculateRetryBackoff(e, currentRetryCount, true); const backoff = shouldRetry(e)
? calculateRetryBackoff(e, currentRetryCount, true)
: -1;
if (backoff < 0) { if (backoff < 0) {
// Max number of retries reached, or error is not retryable. rethrow the error // Max number of retries reached, or error is not retryable. rethrow the error
throw e; throw e;