You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/06/09 16:33:09 UTC
(superset) branch master updated: feat(embedded-sdk): add fetchGuestToken timeout and clean up refresh timer (#40870)
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new f70cd8b5b89 feat(embedded-sdk): add fetchGuestToken timeout and clean up refresh timer (#40870)
f70cd8b5b89 is described below
commit f70cd8b5b89c42cdaa0a72357bdba781f3d6b4bd
Author: Evan Rusackas <ev...@preset.io>
AuthorDate: Tue Jun 9 09:33:01 2026 -0700
feat(embedded-sdk): add fetchGuestToken timeout and clean up refresh timer (#40870)
Co-authored-by: Claude Code <no...@anthropic.com>
---
.../src/guestTokenRefresh.test.ts | 8 +++
superset-embedded-sdk/src/guestTokenRefresh.ts | 1 +
superset-embedded-sdk/src/index.ts | 81 +++++++++++++++++++---
superset-embedded-sdk/src/withTimeout.test.ts | 39 +++++++++++
superset-embedded-sdk/src/withTimeout.ts | 43 ++++++++++++
5 files changed, 163 insertions(+), 9 deletions(-)
diff --git a/superset-embedded-sdk/src/guestTokenRefresh.test.ts b/superset-embedded-sdk/src/guestTokenRefresh.test.ts
index df7434a0c77..a68286fdf1e 100644
--- a/superset-embedded-sdk/src/guestTokenRefresh.test.ts
+++ b/superset-embedded-sdk/src/guestTokenRefresh.test.ts
@@ -22,6 +22,7 @@ import {
getGuestTokenRefreshTiming,
MIN_REFRESH_WAIT_MS,
DEFAULT_TOKEN_EXP_MS,
+ DEFAULT_TOKEN_REFRESH_RETRY_MS,
} from "./guestTokenRefresh";
describe("guest token refresh", () => {
@@ -93,4 +94,11 @@ describe("guest token refresh", () => {
expect(timing).toBeGreaterThan(MIN_REFRESH_WAIT_MS);
expect(timing).toBe(DEFAULT_TOKEN_EXP_MS - REFRESH_TIMING_BUFFER_MS);
});
+
+ it("exposes a positive retry delay for failed token refreshes", () => {
+ // The refresh loop reschedules itself after this delay when a fetch
+ // fails or times out, so it must be a sane positive value.
+ expect(DEFAULT_TOKEN_REFRESH_RETRY_MS).toBe(10000);
+ expect(DEFAULT_TOKEN_REFRESH_RETRY_MS).toBeGreaterThan(0);
+ });
});
diff --git a/superset-embedded-sdk/src/guestTokenRefresh.ts b/superset-embedded-sdk/src/guestTokenRefresh.ts
index 0dd21494b36..80c7569c615 100644
--- a/superset-embedded-sdk/src/guestTokenRefresh.ts
+++ b/superset-embedded-sdk/src/guestTokenRefresh.ts
@@ -21,6 +21,7 @@ import { jwtDecode } from "jwt-decode";
export const REFRESH_TIMING_BUFFER_MS = 5000 // refresh guest token early to avoid failed superset requests
export const MIN_REFRESH_WAIT_MS = 10000 // avoid blasting requests as fast as the cpu can handle
export const DEFAULT_TOKEN_EXP_MS = 300000 // (5 min) used only when parsing guest token exp fails
+export const DEFAULT_TOKEN_REFRESH_RETRY_MS = 10000 // wait before retrying a failed/timed-out token refresh
// when do we refresh the guest token?
export function getGuestTokenRefreshTiming(currentGuestToken: string) {
diff --git a/superset-embedded-sdk/src/index.ts b/superset-embedded-sdk/src/index.ts
index 2eb9f37181a..862e1d7d354 100644
--- a/superset-embedded-sdk/src/index.ts
+++ b/superset-embedded-sdk/src/index.ts
@@ -24,7 +24,11 @@ import {
// We can swap this out for the actual switchboard package once it gets published
import { Switchboard } from '@superset-ui/switchboard';
-import { getGuestTokenRefreshTiming } from './guestTokenRefresh';
+import {
+ getGuestTokenRefreshTiming,
+ DEFAULT_TOKEN_REFRESH_RETRY_MS,
+} from './guestTokenRefresh';
+import { withTimeout } from './withTimeout';
/**
* The function to fetch a guest token from your Host App's backend server.
@@ -49,6 +53,9 @@ export type UiConfigType = {
showRowLimitWarning?: boolean;
};
+/** Default per-call timeout (ms) applied to the host `fetchGuestToken` callback. */
+const DEFAULT_GUEST_TOKEN_FETCH_TIMEOUT_MS = 30_000;
+
export type EmbedDashboardParams = {
/** The id provided by the embed configuration UI in Superset */
id: string;
@@ -73,6 +80,10 @@ export type EmbedDashboardParams = {
/** Callback to resolve permalink URLs. If provided, this will be called when generating permalinks
* to allow the host app to customize the URL. If not provided, Superset's default URL is used. */
resolvePermalinkUrl?: ResolvePermalinkUrlFn;
+ /** Timeout, in milliseconds, applied to each `fetchGuestToken` call so a host
+ * callback that never resolves cannot hang the embed/refresh cycle. Defaults
+ * to 30000ms. Set to 0 to disable the timeout. */
+ guestTokenFetchTimeoutMs?: number;
};
export type Size = {
@@ -127,6 +138,7 @@ export async function embedDashboard({
iframeAllowExtras = [],
referrerPolicy,
resolvePermalinkUrl,
+ guestTokenFetchTimeoutMs = DEFAULT_GUEST_TOKEN_FETCH_TIMEOUT_MS,
}: EmbedDashboardParams): Promise<EmbeddedDashboard> {
function log(...info: unknown[]) {
if (debug) {
@@ -134,6 +146,16 @@ export async function embedDashboard({
}
}
+ // Wrap the host-provided fetchGuestToken so a callback that never settles
+ // cannot hang the initial embed or a later refresh cycle.
+ function fetchGuestTokenWithTimeout(): Promise<string> {
+ return withTimeout(
+ fetchGuestToken(),
+ guestTokenFetchTimeoutMs,
+ 'fetchGuestToken',
+ );
+ }
+
log('embedding');
if (supersetDomain.endsWith('/')) {
@@ -247,21 +269,57 @@ export async function embedDashboard({
});
}
- const [guestToken, ourPort]: [string, Switchboard] = await Promise.all([
- fetchGuestToken(),
- mountIframe(),
- ]);
+ let guestToken: string;
+ let ourPort: Switchboard;
+ try {
+ [guestToken, ourPort] = await Promise.all([
+ fetchGuestTokenWithTimeout(),
+ mountIframe(),
+ ]);
+ } catch (err) {
+ // If the initial token fetch (or timeout) rejects after the iframe has
+ // already been mounted, tear down the partially initialized iframe so the
+ // host isn't left with an orphaned embedded dashboard before rethrowing.
+ //@ts-ignore
+ mountPoint.replaceChildren();
+ throw err;
+ }
ourPort.emit('guestToken', { guestToken });
log('sent guest token');
+ // Track the pending refresh timer so it can be cancelled on unmount, and
+ // stop the cycle once unmounted so it cannot leak across mount/unmount cycles.
+ let refreshTimer: ReturnType<typeof setTimeout> | undefined;
+ let unmounted = false;
+
async function refreshGuestToken() {
- const newGuestToken = await fetchGuestToken();
- ourPort.emit('guestToken', { guestToken: newGuestToken });
- setTimeout(refreshGuestToken, getGuestTokenRefreshTiming(newGuestToken));
+ if (unmounted) return;
+ try {
+ const newGuestToken = await fetchGuestTokenWithTimeout();
+ if (unmounted) return;
+ ourPort.emit('guestToken', { guestToken: newGuestToken });
+ refreshTimer = setTimeout(
+ refreshGuestToken,
+ getGuestTokenRefreshTiming(newGuestToken),
+ );
+ } catch (err) {
+ // A transient fetch failure or timeout must not permanently stop the
+ // refresh cycle. Log it and retry so the session can recover once the
+ // host callback succeeds again.
+ log('failed to refresh guest token, will retry:', err);
+ if (unmounted) return;
+ refreshTimer = setTimeout(
+ refreshGuestToken,
+ DEFAULT_TOKEN_REFRESH_RETRY_MS,
+ );
+ }
}
- setTimeout(refreshGuestToken, getGuestTokenRefreshTiming(guestToken));
+ refreshTimer = setTimeout(
+ refreshGuestToken,
+ getGuestTokenRefreshTiming(guestToken),
+ );
// Register the resolvePermalinkUrl method for the iframe to call
// Returns null if no callback provided or on error, allowing iframe to use default URL
@@ -283,6 +341,11 @@ export async function embedDashboard({
function unmount() {
log('unmounting');
+ unmounted = true;
+ if (refreshTimer !== undefined) {
+ clearTimeout(refreshTimer);
+ refreshTimer = undefined;
+ }
//@ts-ignore
mountPoint.replaceChildren();
}
diff --git a/superset-embedded-sdk/src/withTimeout.test.ts b/superset-embedded-sdk/src/withTimeout.test.ts
new file mode 100644
index 00000000000..89b01bbe28f
--- /dev/null
+++ b/superset-embedded-sdk/src/withTimeout.test.ts
@@ -0,0 +1,39 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { withTimeout } from "./withTimeout";
+
+test("resolves with the value when the promise settles in time", async () => {
+ await expect(withTimeout(Promise.resolve("ok"), 1000, "fetch")).resolves.toBe(
+ "ok"
+ );
+});
+
+test("rejects when the promise does not settle within the timeout", async () => {
+ const never = new Promise<string>(() => {});
+ await expect(withTimeout(never, 10, "fetch")).rejects.toThrow(
+ /fetch did not resolve within 10ms/
+ );
+});
+
+test("passes the promise through unchanged when the timeout is disabled", async () => {
+ await expect(withTimeout(Promise.resolve("ok"), 0, "fetch")).resolves.toBe(
+ "ok"
+ );
+});
diff --git a/superset-embedded-sdk/src/withTimeout.ts b/superset-embedded-sdk/src/withTimeout.ts
new file mode 100644
index 00000000000..bac7eb0fce0
--- /dev/null
+++ b/superset-embedded-sdk/src/withTimeout.ts
@@ -0,0 +1,43 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Rejects if `promise` does not settle within `ms` milliseconds. A non-positive
+ * `ms` disables the timeout and returns the promise unchanged. The timer is
+ * always cleared so it cannot keep the event loop alive.
+ */
+export function withTimeout<T>(
+ promise: Promise<T>,
+ ms: number,
+ label: string,
+): Promise<T> {
+ if (!ms || ms <= 0) {
+ return promise;
+ }
+ let timer: ReturnType<typeof setTimeout>;
+ const timeout = new Promise<never>((_resolve, reject) => {
+ timer = setTimeout(
+ () => reject(new Error(`${label} did not resolve within ${ms}ms`)),
+ ms,
+ );
+ });
+ return Promise.race([promise, timeout]).finally(() =>
+ clearTimeout(timer),
+ ) as Promise<T>;
+}