Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { expect, test } from '../fixtures/e2e-test';

test('transient API failures only open Service Status when the health probe fails', async ({ e2eScenario, page }) => {
const webSocketErrors: string[] = [];
const serviceStatusNavigations: string[] = [];
let healthRequests = 0;

page.on('console', (message) => {
if (message.type() === 'error' && message.text().includes('[WebSocketClient]')) {
webSocketErrors.push(message.text());
}
});
page.on('framenavigated', (frame) => {
if (frame === page.mainFrame() && new URL(frame.url()).pathname === '/next/status') {
serviceStatusNavigations.push(frame.url());
}
});
page.on('request', (request) => {
if (new URL(request.url()).pathname === '/health') {
healthRequests += 1;
}
});

const targetUrl = '/next/project/list?project=test-project#details';
let transientFailuresRemaining = 1;
await page.route('**/api/v2/projects**', async (route) => {
if (transientFailuresRemaining > 0) {
transientFailuresRemaining -= 1;
await route.fulfill({
body: JSON.stringify({ status: 503, title: 'Controlled transient failure' }),
contentType: 'application/problem+json',
status: 503
});
return;
}

await route.continue();
});

await test.step('stay on the current page when the service is healthy', async () => {
await page.goto(targetUrl);

await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible();
await expect(page.getByText(e2eScenario.projectName, { exact: true })).toBeVisible({ timeout: 30_000 });
await expect.poll(() => healthRequests).toBe(1);
expect(serviceStatusNavigations).toEqual([]);
expect(webSocketErrors).toEqual([]);
});

await page.unroute('**/api/v2/projects**');
await page.route('**/api/v2/projects**', async (route) => {
await route.fulfill({
body: JSON.stringify({ status: 503, title: 'Controlled service failure' }),
contentType: 'application/problem+json',
status: 503
});
});
await page.route('**/health', async (route) => {
await route.fulfill({ body: 'Unavailable', contentType: 'text/plain', status: 503 });
});

await test.step('coalesce the redirect and preserve the current URL when the service is unavailable', async () => {
await page.reload();
await expect(page).toHaveURL(/\/next\/status(?:[?#]|$)/);

const statusUrl = new URL(page.url());
expect(statusUrl.searchParams.get('redirect')).toBe(targetUrl);
expect(serviceStatusNavigations).toHaveLength(1);
expect(webSocketErrors).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from 'vitest';

import { buildServiceStatusUrl, createServiceStatusRedirector } from './service-status-redirect';

describe('buildServiceStatusUrl', () => {
it('preserves the current path, query, and hash as an encoded redirect', () => {
const url = new URL('https://example.test/next/stack/most-frequent-errors?project=project-1&filter=status%3Aopen#details');

const result = buildServiceStatusUrl('/next/status', url);

expect(new URL(result, url.origin).searchParams.get('redirect')).toBe(
'/next/stack/most-frequent-errors?project=project-1&filter=status%3Aopen#details'
);
});
});

describe('createServiceStatusRedirector', () => {
it('coalesces concurrent health checks and stays on the current page when the service is healthy', async () => {
let resolveHealth!: (value: boolean) => void;
const healthResult = new Promise<boolean>((resolve) => {
resolveHealth = resolve;
});
const checkHealth = vi.fn(() => healthResult);
const navigate = vi.fn(async () => undefined);
const redirect = createServiceStatusRedirector({ checkHealth, navigate });

const first = redirect();
const second = redirect();
resolveHealth(true);
await Promise.all([first, second]);

expect(checkHealth).toHaveBeenCalledOnce();
expect(navigate).not.toHaveBeenCalled();
});

it('briefly caches a healthy result to bound repeated probes', async () => {
let currentTime = 1000;
const checkHealth = vi.fn(async () => true);
const redirect = createServiceStatusRedirector({
checkHealth,
healthyCacheMilliseconds: 5000,
navigate: vi.fn(async () => undefined),
now: () => currentTime
});

await redirect();
currentTime = 5999;
await redirect();
currentTime = 6000;
await redirect();

expect(checkHealth).toHaveBeenCalledTimes(2);
});

it('coalesces navigation when the service is unavailable', async () => {
let resolveHealth!: (value: boolean) => void;
let resolveNavigation!: () => void;
const healthResult = new Promise<boolean>((resolve) => {
resolveHealth = resolve;
});
const navigationResult = new Promise<void>((resolve) => {
resolveNavigation = resolve;
});
const checkHealth = vi.fn(() => healthResult);
const navigate = vi.fn(() => navigationResult);
const redirect = createServiceStatusRedirector({ checkHealth, navigate });

const first = redirect();
const second = redirect();
resolveHealth(false);
await vi.waitFor(() => expect(navigate).toHaveBeenCalledOnce());
resolveNavigation();
await Promise.all([first, second]);

expect(checkHealth).toHaveBeenCalledOnce();
expect(navigate).toHaveBeenCalledOnce();
});

it('treats a failed health probe as unavailable', async () => {
const navigate = vi.fn(async () => undefined);
const redirect = createServiceStatusRedirector({
checkHealth: vi.fn(async () => {
throw new Error('network unavailable');
}),
navigate
});

await redirect();

expect(navigate).toHaveBeenCalledOnce();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const DEFAULT_HEALTHY_CACHE_MILLISECONDS = 5000;

interface ServiceStatusRedirectorOptions {
checkHealth: () => Promise<boolean>;
healthyCacheMilliseconds?: number;
navigate: () => Promise<void>;
now?: () => number;
}

export function buildServiceStatusUrl(statusPath: string, currentUrl: Pick<URL, 'hash' | 'pathname' | 'search'>): string {
const redirect = `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}`;
return `${statusPath}?${new URLSearchParams({ redirect }).toString()}`;
}

export function createServiceStatusRedirector(options: ServiceStatusRedirectorOptions): () => Promise<void> {
const healthyCacheMilliseconds = options.healthyCacheMilliseconds ?? DEFAULT_HEALTHY_CACHE_MILLISECONDS;
const now = options.now ?? Date.now;
let healthyUntil = 0;
let redirectPromise: null | Promise<void> = null;

async function redirect(): Promise<void> {
try {
if (await options.checkHealth()) {
healthyUntil = now() + healthyCacheMilliseconds;
return;
}
} catch {
// A failed probe means the service cannot be reached.
}

await options.navigate();
}

return () => {
if (now() < healthyUntil) {
return Promise.resolve();
}

redirectPromise ??= redirect().finally(() => {
redirectPromise = null;
});
return redirectPromise;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ export class WebSocketClient {
private _url: null | string = null;
private accessToken: null | string = null;
private connectionTimeoutId: null | ReturnType<typeof setTimeout> = null;
private forcedClose: boolean = false;
private hasConnectedBefore: boolean = false;
private intentionallyClosedSockets = new WeakSet<WebSocket>();
private reconnectAfterClose: boolean = false;
private reconnectAttempts: number = 0;
private reconnectTimeoutId: null | ReturnType<typeof setTimeout> = null;
private terminalAuthFailure: boolean = false;
Expand Down Expand Up @@ -90,13 +91,14 @@ export class WebSocketClient {
}

public close(): boolean {
this.reconnectAfterClose = false;
clearTimeout(this.reconnectTimeoutId!);
this.reconnectTimeoutId = null;
clearTimeout(this.connectionTimeoutId!);
this.connectionTimeoutId = null;

if (this.ws) {
this.forcedClose = true;
this.intentionallyClosedSockets.add(this.ws);
this.ws.close();
return true;
}
Expand All @@ -106,17 +108,32 @@ export class WebSocketClient {
}

public connect() {
if (this.ws) {
if (this.intentionallyClosedSockets.has(this.ws)) {
this.reconnectAfterClose = true;
}

return;
}

if (this.readyState === WebSocket.CONNECTING || this.readyState === WebSocket.OPEN) {
return;
}

// isReconnect means: have we successfully connected before?
const isReconnect: boolean = this.hasConnectedBefore;

// Reset state
this.readyState = WebSocket.CONNECTING;
this.forcedClose = false;

let socket: WebSocket;

try {
this.ws = new WebSocket(`${this.url}?access_token=${this.accessToken}`);
socket = new WebSocket(`${this.url}?access_token=${this.accessToken}`);
this.ws = socket;
this.onConnecting(isReconnect);
} catch (error) {
this.readyState = WebSocket.CLOSED;
console.error('[WebSocketClient] Failed to create WebSocket', error);
throw error;
}
Expand All @@ -126,13 +143,17 @@ export class WebSocketClient {
const timeout = this._options.connectionTimeout ?? 10000;
this.connectionTimeoutId = setTimeout(() => {
this.connectionTimeoutId = null;
if (this.ws && this.readyState === WebSocket.CONNECTING) {
if (this.ws === socket && this.readyState === WebSocket.CONNECTING) {
console.warn(`[WebSocketClient] Connection timeout after ${timeout}ms`);
this.ws.close();
socket.close();
}
}, timeout);

this.ws.onopen = (event: Event) => {
socket.onopen = (event: Event) => {
if (this.ws !== socket || this.intentionallyClosedSockets.has(socket)) {
return;
}

clearTimeout(this.connectionTimeoutId!);
this.connectionTimeoutId = null;
this.readyState = WebSocket.OPEN;
Expand All @@ -141,14 +162,24 @@ export class WebSocketClient {
this.onOpen(event, isReconnect);
};

this.ws.onclose = (event: CloseEvent) => {
socket.onclose = (event: CloseEvent) => {
const wasIntentionallyClosed = this.intentionallyClosedSockets.delete(socket);
if (this.ws !== socket) {
return;
}

clearTimeout(this.connectionTimeoutId!);
this.connectionTimeoutId = null;
this.ws = null;

if (this.forcedClose) {
if (wasIntentionallyClosed) {
this.readyState = WebSocket.CLOSED;
this.onClose(event);
if (this.reconnectAfterClose) {
this.reconnectAfterClose = false;
this.connect();
}

return;
}

Expand All @@ -167,6 +198,7 @@ export class WebSocketClient {
}

// Calculate reconnection delay with exponential backoff
this.readyState = WebSocket.CLOSED;
this.reconnectAttempts++;
const delay = this.getReconnectDelay(this.reconnectAttempts);

Expand All @@ -181,11 +213,19 @@ export class WebSocketClient {
}, delay);
};

this.ws.onmessage = (event) => {
socket.onmessage = (event) => {
if (this.ws !== socket || this.intentionallyClosedSockets.has(socket)) {
return;
}

this.onMessage(event);
};

this.ws.onerror = (event) => {
socket.onerror = (event) => {
if (this.ws !== socket || this.intentionallyClosedSockets.has(socket)) {
return;
}

console.error('[WebSocketClient] onerror triggered', {
event,
readyState: this.readyState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,21 @@ describe('WebSocketClient', () => {

expect(client.readyState).toBe(WebSocket.CLOSED);
});

it('should not report an error when a connecting socket is intentionally closed', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const onError = vi.fn();
const client = createClient();
client.onError = onError;

client.connect();
client.close();
await new Promise((resolve) => setTimeout(resolve, 10));

expect(consoleError).not.toHaveBeenCalledWith('[WebSocketClient] onerror triggered', expect.anything());
expect(onError).not.toHaveBeenCalled();
consoleError.mockRestore();
});
});

describe('Reconnection Logic', () => {
Expand Down
Loading
Loading