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>;
+}