Skip to content

Commit da35ab7

Browse files
committed
Consistently handle websocket errors
1 parent 4f33303 commit da35ab7

File tree

5 files changed

+111
-67
lines changed

5 files changed

+111
-67
lines changed

src/api/coderApi.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,9 @@ export class CoderApi extends Api implements vscode.Disposable {
311311
});
312312

313313
this.attachStreamLogger(ws);
314-
return ws;
314+
315+
// Wait for connection to open before returning
316+
return await this.waitForOpen(ws);
315317
}
316318

317319
private attachStreamLogger<TData>(
@@ -351,9 +353,8 @@ export class CoderApi extends Api implements vscode.Disposable {
351353
): Promise<UnidirectionalStream<ServerSentEvent>> {
352354
const { fallbackApiRoute, ...socketConfigs } = configs;
353355
try {
354-
const ws =
355-
await this.createOneWayWebSocket<ServerSentEvent>(socketConfigs);
356-
return await this.waitForOpen(ws);
356+
// createOneWayWebSocket already waits for open
357+
return await this.createOneWayWebSocket<ServerSentEvent>(socketConfigs);
357358
} catch (error) {
358359
if (this.is404Error(error)) {
359360
this.output.warn(
@@ -398,10 +399,11 @@ export class CoderApi extends Api implements vscode.Disposable {
398399

399400
/**
400401
* Wait for a connection to open. Rejects on error.
402+
* Preserves the specific connection type (e.g., OneWayWebSocket, SseConnection).
401403
*/
402-
private waitForOpen<TData>(
403-
connection: UnidirectionalStream<TData>,
404-
): Promise<UnidirectionalStream<TData>> {
404+
private waitForOpen<T extends UnidirectionalStream<unknown>>(
405+
connection: T,
406+
): Promise<T> {
405407
return new Promise((resolve, reject) => {
406408
const cleanup = () => {
407409
connection.removeEventListener("open", handleOpen);
@@ -416,7 +418,10 @@ export class CoderApi extends Api implements vscode.Disposable {
416418
const handleError = (event: ErrorEvent) => {
417419
cleanup();
418420
connection.close();
419-
const error = toError(event.error, "WebSocket connection error");
421+
const error = toError(
422+
event.error,
423+
event.message || "WebSocket connection error",
424+
);
420425
reject(error);
421426
};
422427

src/error/certificateError.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export abstract class CertificateError extends Error {
2121
): Promise<T | undefined> {
2222
const modal = options?.modal ?? false;
2323
const message =
24-
!modal && title ? `${title}\n\n${this.detail}` : title || this.detail;
24+
!modal && title ? `${title}: ${this.detail}` : title || this.detail;
2525

2626
return await vscode.window.showErrorMessage(
2727
message,

src/websocket/reconnectingWebSocket.ts

Lines changed: 32 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export class ReconnectingWebSocket<
5353
#isDisposed = false; // Permanent disposal, cannot be resumed
5454
#isConnecting = false;
5555
#pendingReconnect = false;
56+
#certRefreshAttempted = false; // Tracks if cert refresh was already attempted this connection cycle
5657
readonly #onDispose?: () => void;
5758

5859
private constructor(
@@ -86,45 +87,23 @@ export class ReconnectingWebSocket<
8687
onDispose,
8788
);
8889

89-
try {
90-
await instance.connect();
91-
} catch (error) {
92-
const certError = ClientCertificateError.fromError(error);
93-
if (!certError) {
94-
throw error;
95-
}
96-
97-
const refreshed = await instance.handleClientCertificateError(certError);
98-
if (refreshed) {
99-
try {
100-
await instance.connect();
101-
return instance;
102-
} catch (retryError) {
103-
// Check if still a cert error after refresh
104-
const retryCertError = ClientCertificateError.fromError(retryError);
105-
if (retryCertError) {
106-
void retryCertError.showNotification();
107-
} else {
108-
instance.#logger.error(
109-
"Connection failed after certificate refresh",
110-
retryError,
111-
);
112-
}
113-
}
114-
}
115-
116-
// Either refresh failed or retry failed - return disconnected instance
117-
instance.disconnect();
118-
return instance;
119-
}
120-
90+
// connect() handles all errors internally
91+
await instance.connect();
12192
return instance;
12293
}
12394

12495
get url(): string {
12596
return this.#currentSocket?.url ?? "";
12697
}
12798

99+
/**
100+
* Returns true if the socket is temporarily disconnected and not attempting to reconnect.
101+
* Use reconnect() to resume.
102+
*/
103+
get isDisconnected(): boolean {
104+
return this.#isDisconnected;
105+
}
106+
128107
/**
129108
* Extract the route (pathname + search) from the current socket URL for logging.
130109
* Falls back to the last known route when the socket is closed.
@@ -160,6 +139,7 @@ export class ReconnectingWebSocket<
160139
if (this.#isDisconnected) {
161140
this.#isDisconnected = false;
162141
this.#backoffMs = this.#options.initialBackoffMs;
142+
this.#certRefreshAttempted = false; // User-initiated reconnect, allow retry
163143
}
164144

165145
if (this.#isDisposed) {
@@ -171,14 +151,13 @@ export class ReconnectingWebSocket<
171151
this.#reconnectTimeoutId = null;
172152
}
173153

174-
// If already connecting, schedule reconnect after current attempt
175154
if (this.#isConnecting) {
176155
this.#pendingReconnect = true;
177156
return;
178157
}
179158

180-
// connect() will close any existing socket
181-
this.connect().catch((error) => this.handleConnectionError(error));
159+
// connect() handles all errors internally
160+
void this.connect();
182161
}
183162

184163
/**
@@ -239,6 +218,7 @@ export class ReconnectingWebSocket<
239218

240219
socket.addEventListener("open", (event) => {
241220
this.#backoffMs = this.#options.initialBackoffMs;
221+
this.#certRefreshAttempted = false; // Reset on successful connection
242222
this.executeHandlers("open", event);
243223
});
244224

@@ -248,17 +228,11 @@ export class ReconnectingWebSocket<
248228

249229
socket.addEventListener("error", (event) => {
250230
this.executeHandlers("error", event);
251-
252-
// Check for unrecoverable HTTP errors in the error event
253-
// HTTP errors during handshake fire 'error' then 'close' with 1006
254-
// We need to suspend here to prevent infinite reconnect loops
255-
const errorMessage = toError(event.error, event.message).message;
256-
if (this.isUnrecoverableHttpError(errorMessage)) {
257-
this.#logger.error(
258-
`Unrecoverable HTTP error for ${this.#route}: ${errorMessage}`,
259-
);
260-
this.disconnect();
261-
}
231+
// Errors during initial connection are caught by the factory (waitForOpen).
232+
// This handler is for errors AFTER successful connection.
233+
// Route through handleConnectionError for consistent handling.
234+
const error = toError(event.error, event.message);
235+
void this.handleConnectionError(error);
262236
});
263237

264238
socket.addEventListener("close", (event) => {
@@ -272,19 +246,18 @@ export class ReconnectingWebSocket<
272246
this.#logger.error(
273247
`WebSocket connection closed with unrecoverable error code ${event.code}`,
274248
);
275-
// Suspend instead of dispose - allows recovery when credentials change
276249
this.disconnect();
277250
return;
278251
}
279252

280-
// Don't reconnect on normal closure
281253
if (NORMAL_CLOSURE_CODES.has(event.code)) {
282254
return;
283255
}
284256

285-
// Reconnect on abnormal closures (e.g., 1006) or other unexpected codes
286257
this.scheduleReconnect();
287258
});
259+
} catch (error) {
260+
await this.handleConnectionError(error);
288261
} finally {
289262
this.#isConnecting = false;
290263

@@ -314,7 +287,8 @@ export class ReconnectingWebSocket<
314287

315288
this.#reconnectTimeoutId = setTimeout(() => {
316289
this.#reconnectTimeoutId = null;
317-
this.connect().catch((error) => this.handleConnectionError(error));
290+
// connect() handles all errors internally
291+
void this.connect();
318292
}, delayMs);
319293

320294
this.#backoffMs = Math.min(this.#backoffMs * 2, this.#options.maxBackoffMs);
@@ -343,7 +317,15 @@ export class ReconnectingWebSocket<
343317
private async handleClientCertificateError(
344318
certError: ClientCertificateError,
345319
): Promise<boolean> {
320+
// Only attempt refresh once per connection cycle
321+
if (this.#certRefreshAttempted) {
322+
this.#logger.warn("Certificate refresh already attempted, not retrying");
323+
void certError.showNotification();
324+
return false;
325+
}
326+
346327
if (certError.isRefreshable) {
328+
this.#certRefreshAttempted = true; // Mark that we're attempting
347329
this.#logger.info(
348330
`Client certificate error (alert ${certError.alertCode}), attempting refresh...`,
349331
);

test/unit/api/coderApi.test.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,15 @@ describe("CoderApi", () => {
217217

218218
beforeEach(() => {
219219
api = createApi(CODER_URL, AXIOS_TOKEN);
220-
const mockWs = createMockWebSocket(wsUrl);
220+
// createOneWayWebSocket waits for open, so we need to fire it
221+
const mockWs = createMockWebSocket(wsUrl, {
222+
on: vi.fn((event, handler) => {
223+
if (event === "open") {
224+
setImmediate(() => handler());
225+
}
226+
return mockWs as Ws;
227+
}),
228+
});
221229
setupWebSocketMock(mockWs);
222230
});
223231

@@ -390,15 +398,18 @@ describe("CoderApi", () => {
390398
expect(EventSource).toHaveBeenCalled();
391399
});
392400

393-
it("throws non-404 errors without SSE fallback", async () => {
401+
it("handles non-404 errors without SSE fallback", async () => {
394402
vi.mocked(Ws).mockImplementation(function () {
395403
throw new Error("Network error");
396404
});
397405

398-
await expect(api.watchAgentMetadata(AGENT_ID)).rejects.toThrow(
399-
"Network error",
400-
);
406+
// Non-404 errors are handled by ReconnectingWebSocket internally
407+
// (schedules reconnect with backoff) instead of being thrown
408+
const connection = await api.watchAgentMetadata(AGENT_ID);
409+
expect(connection).toBeInstanceOf(ReconnectingWebSocket);
401410
expect(EventSource).not.toHaveBeenCalled();
411+
412+
connection.close();
402413
});
403414

404415
describe("reconnection after fallback", () => {
@@ -572,6 +583,45 @@ describe("CoderApi", () => {
572583
"No base URL set on REST client",
573584
);
574585
});
586+
587+
interface WebSocketErrorConversionTestCase {
588+
description: string;
589+
eventMessage: string;
590+
expectedMessage: string;
591+
}
592+
593+
it.each<WebSocketErrorConversionTestCase>([
594+
{
595+
description: "event.message when available",
596+
eventMessage: "Custom error message",
597+
expectedMessage: "Custom error message",
598+
},
599+
{
600+
description: "fallback when event.message is empty",
601+
eventMessage: "",
602+
expectedMessage: "WebSocket connection error",
603+
},
604+
])(
605+
"uses $desc for WebSocket error",
606+
async ({ eventMessage, expectedMessage }) => {
607+
api = createApi();
608+
const mockWs = createMockWebSocket("wss://test", {
609+
on: vi.fn((event: string, handler: (e: unknown) => void) => {
610+
if (event === "error") {
611+
setImmediate(() =>
612+
handler({ error: undefined, message: eventMessage }),
613+
);
614+
}
615+
return mockWs as Ws;
616+
}),
617+
});
618+
setupWebSocketMock(mockWs);
619+
620+
await expect(api.watchBuildLogsByBuildId(BUILD_ID, [])).rejects.toThrow(
621+
expectedMessage,
622+
);
623+
},
624+
);
575625
});
576626

577627
describe("getHost/getSessionToken", () => {

test/unit/websocket/reconnectingWebSocket.test.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,20 @@ describe("ReconnectingWebSocket", () => {
9090
);
9191
});
9292

93-
await expect(
94-
ReconnectingWebSocket.create(factory, createMockLogger()),
95-
).rejects.toThrow(`Unexpected server response: ${statusCode}`);
93+
// create() returns a disconnected instance instead of throwing
94+
const ws = await ReconnectingWebSocket.create(
95+
factory,
96+
createMockLogger(),
97+
);
98+
99+
// Should be disconnected after unrecoverable HTTP error
100+
expect(ws.isDisconnected).toBe(true);
96101

97102
// Should not retry after unrecoverable HTTP error
98103
await vi.advanceTimersByTimeAsync(10000);
99104
expect(socketCreationAttempts).toBe(1);
105+
106+
ws.close();
100107
},
101108
);
102109

0 commit comments

Comments
 (0)