sadpandajoe commented on code in PR #45404:
URL: https://github.com/apache/superset/pull/45404#discussion_r4319470351
##########
superset-frontend/src/middleware/realtime.ts:
##########
@@ -267,16 +301,132 @@ const teardownSocket = (): void => {
}
};
+interface Ticket {
+ token: string;
+ expiresInMs: number;
+}
+
+/** Fetch a fresh connection ticket for the current session. */
+const fetchTicket = async (): Promise<Ticket> => {
+ const { json } = await SupersetClient.get({ endpoint: TICKET_ENDPOINT });
Review Comment:
With `PUBLIC_ROLE_LIKE = "Gamma"` the public role inherits `can_read
Realtime`, so an anonymous visitor gets `WEBSOCKET_ENABLE` in bootstrap and
this ticket request now fires on every page load. The endpoint returns 401 for
them (no realtime principal), and `SupersetClient.get` runs its default
unauthorized handler on a 401, which navigates the whole page to the login
screen before `enterUnauthorized()` ever sees the rejection. Before this change
the anonymous case only produced silent socket retries. Should this request
pass `ignoreUnauthorized: true` (the 401/403 is already handled by
`isUnauthorizedError`) so an optional transport can't redirect a public
dashboard?
##########
superset-frontend/src/middleware/realtime.ts:
##########
@@ -267,16 +301,132 @@ const teardownSocket = (): void => {
}
};
+interface Ticket {
+ token: string;
+ expiresInMs: number;
+}
+
+/** Fetch a fresh connection ticket for the current session. */
+const fetchTicket = async (): Promise<Ticket> => {
+ const { json } = await SupersetClient.get({ endpoint: TICKET_ENDPOINT });
+ const { token, expires_in: expiresIn } = (json ?? {}) as {
+ token?: unknown;
+ expires_in?: unknown;
+ };
+ if (typeof token !== 'string' || typeof expiresIn !== 'number') {
+ throw new Error('Malformed realtime ticket response');
+ }
+ return { token, expiresInMs: expiresIn * 1000 };
+};
+
+/**
+ * Whether a ticket request failed because the session may not use the
transport
+ * (no session, or no realtime permission). Retrying cannot succeed.
+ */
+const isUnauthorizedError = (err: unknown): boolean => {
+ const status = (err as { status?: unknown } | null)?.status;
+ return status === 401 || status === 403;
+};
+
+// Tab ids carried by `auth` frames the server has not acknowledged yet, oldest
+// first, and the tab id the server last acknowledged. The server answers every
+// frame in order, so comparing the two tells whether an ack rebound the tab.
+let unackedTabIds: string[] = [];
+let ackedTabId: string | undefined;
+
+const sendAuthFrame = (ws: WebSocket, token: string): void => {
+ const tabId = getTabId();
+ ws.send(JSON.stringify({ type: 'auth', token, tab_id: tabId }));
+ unackedTabIds.push(tabId);
+};
+
+const notifyOpen = (reason: RealtimeOpenReason): void => {
+ openListeners.forEach(listener => {
+ try {
+ listener(reason);
+ } catch (err) {
+ logging.warn('Realtime open-listener error', err);
+ }
+ });
+};
+
+// Stop connecting after the server refused this session. Closes any live
socket
+// without scheduling a reconnect.
+const enterUnauthorized = (): void => {
+ teardownSocket();
+ emitState('unauthorized');
+};
+
+const refreshRetryDelayMs = (attempt: number): number =>
+ Math.min(REFRESH_RETRY_BASE_MS * 2 ** attempt, REFRESH_RETRY_MAX_MS);
+
+// Send a fresh ticket on the open socket `ws`. A transient ticket failure is
+// retried with backoff until the current ticket expires; past it the server
closes the socket
+// with AUTH_CLOSE_CODE and the reconnect path takes over. The server's
+// `realtime.auth` reply schedules the next refresh.
+function refreshTicket(
+ thisGeneration: number,
+ ws: WebSocket,
+ attempt = 0,
+): void {
+ fetchTicket().then(
+ ({ token }) => {
+ if (thisGeneration !== generation || socket !== ws) return;
+ if (ws.readyState !== WS_OPEN) return;
+ try {
+ sendAuthFrame(ws, token);
+ } catch (err) {
+ logging.warn('Failed to send realtime auth refresh', err);
+ }
+ },
+ err => {
+ if (thisGeneration !== generation || socket !== ws) return;
+ if (isUnauthorizedError(err)) {
+ enterUnauthorized();
+ return;
+ }
+ const delay = refreshRetryDelayMs(attempt);
+ if (Date.now() + delay >= ticketExpiresAtMs) return;
+ refreshTimeoutId = window.setTimeout(
+ () => refreshTicket(thisGeneration, ws, attempt + 1),
+ delay,
+ );
+ },
+ );
+}
+
// Declared as a hoisted function so scheduleReconnect (above) can reference it
// for the mutual reconnect recursion without a use-before-define cycle.
-function openSocket(thisGeneration: number, reason: RealtimeOpenReason): void {
+async function openSocket(
+ thisGeneration: number,
+ reason: RealtimeOpenReason,
+): Promise<void> {
if (thisGeneration !== generation) return;
if (!enabled || !url || typeof WebSocket === 'undefined') return;
- const connectUrl = buildConnectUrl(url);
+ attemptSeq += 1;
+ const attempt = attemptSeq;
+ const isCurrent = () =>
+ thisGeneration === generation && attempt === attemptSeq;
emitState('connecting');
+
+ let ticket: Ticket;
+ try {
+ ticket = await fetchTicket();
+ } catch (err) {
+ if (!isCurrent()) return;
+ if (isUnauthorizedError(err)) {
+ enterUnauthorized();
Review Comment:
`init()` in `asyncEvent.ts` runs at module load and calls `connectRealtime`,
so for the embedded entry (which imports `views/store` and therefore
`asyncEvent`) this first ticket request goes out before the SDK delivers the
guest token and `setupGuestClient()` runs. With no session and no guest header
it gets a 401, and `enterUnauthorized()` is terminal, so supplying the guest
token afterwards never restarts the socket and async charts then fail with
"Realtime connection unavailable". Before this change the same early failure
just fell into the backoff loop and recovered once the token existed. Should
the embedded path defer the connect until the guest client is configured, or
restart realtime when that credential arrives?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]