Skip to content

Commit eec4c99

Browse files
committed
Address review comments
* Better error handling for HTTP errors * Refactor magic numbers * Use strict types and avoid casting
1 parent 656bb4c commit eec4c99

File tree

7 files changed

+241
-86
lines changed

7 files changed

+241
-86
lines changed

src/api/coderApi.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
type WorkspaceAgentLog,
1515
} from "coder/site/src/api/typesGenerated";
1616
import * as vscode from "vscode";
17-
import { type ClientOptions, type CloseEvent, type ErrorEvent } from "ws";
17+
import { type ClientOptions } from "ws";
1818

1919
import { CertificateError } from "../error";
2020
import { getHeaderCommand, getHeaders } from "../headers";
@@ -31,7 +31,12 @@ import {
3131
HttpClientLogLevel,
3232
} from "../logging/types";
3333
import { sizeOf } from "../logging/utils";
34-
import { type UnidirectionalStream } from "../websocket/eventStreamConnection";
34+
import { HttpStatusCode } from "../websocket/codes";
35+
import {
36+
type UnidirectionalStream,
37+
type CloseEvent,
38+
type ErrorEvent,
39+
} from "../websocket/eventStreamConnection";
3540
import {
3641
OneWayWebSocket,
3742
type OneWayWebSocketInit,
@@ -336,8 +341,8 @@ export class CoderApi extends Api {
336341
const handleError = (event: ErrorEvent) => {
337342
cleanup();
338343
const is404 =
339-
event.message?.includes("404") ||
340-
event.error?.message?.includes("404");
344+
event.message?.includes(String(HttpStatusCode.NOT_FOUND)) ||
345+
event.error?.message?.includes(String(HttpStatusCode.NOT_FOUND));
341346

342347
if (is404 && onNotFound) {
343348
connection.close();

src/websocket/codes.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* WebSocket close codes (RFC 6455) and HTTP status codes for socket connections.
3+
* @see https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1
4+
*/
5+
6+
/** WebSocket close codes defined in RFC 6455 */
7+
export const WebSocketCloseCode = {
8+
/** Normal closure - connection successfully completed */
9+
NORMAL: 1000,
10+
/** Endpoint going away (server shutdown) */
11+
GOING_AWAY: 1001,
12+
/** Protocol error - connection cannot be recovered */
13+
PROTOCOL_ERROR: 1002,
14+
/** Unsupported data type received - connection cannot be recovered */
15+
UNSUPPORTED_DATA: 1003,
16+
/** Abnormal closure - connection closed without close frame (network issues) */
17+
ABNORMAL: 1006,
18+
} as const;
19+
20+
/** HTTP status codes used for socket creation and connection logic */
21+
export const HttpStatusCode = {
22+
/** Authentication or permission denied */
23+
FORBIDDEN: 403,
24+
/** Endpoint not found */
25+
NOT_FOUND: 404,
26+
/** Resource permanently gone */
27+
GONE: 410,
28+
/** Protocol upgrade required */
29+
UPGRADE_REQUIRED: 426,
30+
} as const;
31+
32+
/**
33+
* WebSocket close codes indicating unrecoverable errors.
34+
* These appear in close events and should stop reconnection attempts.
35+
*/
36+
export const UNRECOVERABLE_WS_CLOSE_CODES = new Set<number>([
37+
WebSocketCloseCode.PROTOCOL_ERROR,
38+
WebSocketCloseCode.UNSUPPORTED_DATA,
39+
]);
40+
41+
/**
42+
* HTTP status codes indicating unrecoverable errors during handshake.
43+
* These appear during socket creation and should stop reconnection attempts.
44+
*/
45+
export const UNRECOVERABLE_HTTP_CODES = new Set<number>([
46+
HttpStatusCode.FORBIDDEN,
47+
HttpStatusCode.GONE,
48+
HttpStatusCode.UPGRADE_REQUIRED,
49+
]);
50+
51+
/** Close codes indicating intentional closure - do not reconnect */
52+
export const NORMAL_CLOSURE_CODES = new Set<number>([
53+
WebSocketCloseCode.NORMAL,
54+
WebSocketCloseCode.GOING_AWAY,
55+
]);

src/websocket/eventStreamConnection.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { type WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket";
22
import {
3-
type CloseEvent,
3+
type CloseEvent as WsCloseEvent,
44
type Event as WsEvent,
5-
type ErrorEvent,
6-
type MessageEvent,
5+
type ErrorEvent as WsErrorEvent,
6+
type MessageEvent as WsMessageEvent,
77
} from "ws";
88

9+
export type Event = Omit<WsEvent, "type" | "target">;
10+
export type CloseEvent = Omit<WsCloseEvent, "type" | "target">;
11+
export type ErrorEvent = Omit<WsErrorEvent, "type" | "target">;
12+
export type MessageEvent = Omit<WsMessageEvent, "type" | "target">;
13+
914
// Event payload types matching OneWayWebSocket
1015
export type ParsedMessageEvent<TData> = Readonly<
1116
| {
@@ -24,7 +29,7 @@ export type EventPayloadMap<TData> = {
2429
close: CloseEvent;
2530
error: ErrorEvent;
2631
message: ParsedMessageEvent<TData>;
27-
open: WsEvent;
32+
open: Event;
2833
};
2934

3035
export type EventHandler<TData, TEvent extends WebSocketEventType> = (

src/websocket/reconnectingWebSocket.ts

Lines changed: 64 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1+
import {
2+
WebSocketCloseCode,
3+
NORMAL_CLOSURE_CODES,
4+
UNRECOVERABLE_WS_CLOSE_CODES,
5+
UNRECOVERABLE_HTTP_CODES,
6+
} from "./codes";
7+
18
import type { WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket";
2-
import type { CloseEvent } from "ws";
39

410
import type { Logger } from "../logging/logger";
511

@@ -16,17 +22,16 @@ export type ReconnectingWebSocketOptions = {
1622
jitterFactor?: number;
1723
};
1824

19-
// 403 Forbidden, 410 Gone, 426 Upgrade Required, 1002/1003 Protocol errors
20-
const UNRECOVERABLE_CLOSE_CODES = new Set([403, 410, 426, 1002, 1003]);
21-
2225
export class ReconnectingWebSocket<TData = unknown>
2326
implements UnidirectionalStream<TData>
2427
{
2528
readonly #socketFactory: SocketFactory<TData>;
2629
readonly #logger: Logger;
2730
readonly #apiRoute: string;
2831
readonly #options: Required<ReconnectingWebSocketOptions>;
29-
readonly #eventHandlers = {
32+
readonly #eventHandlers: {
33+
[K in WebSocketEventType]: Set<EventHandler<TData, K>>;
34+
} = {
3035
open: new Set<EventHandler<TData, "open">>(),
3136
close: new Set<EventHandler<TData, "close">>(),
3237
error: new Set<EventHandler<TData, "error">>(),
@@ -86,18 +91,14 @@ export class ReconnectingWebSocket<TData = unknown>
8691
event: TEvent,
8792
callback: EventHandler<TData, TEvent>,
8893
): void {
89-
(this.#eventHandlers[event] as Set<EventHandler<TData, TEvent>>).add(
90-
callback,
91-
);
94+
this.#eventHandlers[event].add(callback);
9295
}
9396

9497
removeEventListener<TEvent extends WebSocketEventType>(
9598
event: TEvent,
9699
callback: EventHandler<TData, TEvent>,
97100
): void {
98-
(this.#eventHandlers[event] as Set<EventHandler<TData, TEvent>>).delete(
99-
callback,
100-
);
101+
this.#eventHandlers[event].delete(callback);
101102
}
102103

103104
reconnect(): void {
@@ -117,14 +118,7 @@ export class ReconnectingWebSocket<TData = unknown>
117118
}
118119

119120
// connect() will close any existing socket
120-
this.connect().catch((error) => {
121-
if (!this.#isDisposed) {
122-
this.#logger.warn(
123-
`Manual reconnection failed for ${this.#apiRoute}: ${error instanceof Error ? error.message : String(error)}`,
124-
);
125-
this.scheduleReconnect();
126-
}
127-
});
121+
this.connect().catch((error) => this.handleConnectionError(error));
128122
}
129123

130124
close(code?: number, reason?: string): void {
@@ -135,12 +129,10 @@ export class ReconnectingWebSocket<TData = unknown>
135129
// Fire close handlers synchronously before disposing
136130
if (this.#currentSocket) {
137131
this.executeHandlers("close", {
138-
code: code ?? 1000,
139-
reason: reason ?? "",
132+
code: code ?? WebSocketCloseCode.NORMAL,
133+
reason: reason ?? "Normal closure",
140134
wasClean: true,
141-
type: "close",
142-
target: this.#currentSocket,
143-
} as CloseEvent);
135+
});
144136
}
145137

146138
this.dispose(code, reason);
@@ -155,7 +147,10 @@ export class ReconnectingWebSocket<TData = unknown>
155147
try {
156148
// Close any existing socket before creating a new one
157149
if (this.#currentSocket) {
158-
this.#currentSocket.close(1000, "Replacing connection");
150+
this.#currentSocket.close(
151+
WebSocketCloseCode.NORMAL,
152+
"Replacing connection",
153+
);
159154
this.#currentSocket = null;
160155
}
161156

@@ -182,7 +177,7 @@ export class ReconnectingWebSocket<TData = unknown>
182177

183178
this.executeHandlers("close", event);
184179

185-
if (UNRECOVERABLE_CLOSE_CODES.has(event.code)) {
180+
if (UNRECOVERABLE_WS_CLOSE_CODES.has(event.code)) {
186181
this.#logger.error(
187182
`WebSocket connection closed with unrecoverable error code ${event.code}`,
188183
);
@@ -191,7 +186,7 @@ export class ReconnectingWebSocket<TData = unknown>
191186
}
192187

193188
// Don't reconnect on normal closure
194-
if (event.code === 1000 || event.code === 1001) {
189+
if (NORMAL_CLOSURE_CODES.has(event.code)) {
195190
return;
196191
}
197192

@@ -223,14 +218,7 @@ export class ReconnectingWebSocket<TData = unknown>
223218

224219
this.#reconnectTimeoutId = setTimeout(() => {
225220
this.#reconnectTimeoutId = null;
226-
this.connect().catch((error) => {
227-
if (!this.#isDisposed) {
228-
this.#logger.warn(
229-
`WebSocket connection failed for ${this.#apiRoute}: ${error instanceof Error ? error.message : String(error)}`,
230-
);
231-
this.scheduleReconnect();
232-
}
233-
});
221+
this.connect().catch((error) => this.handleConnectionError(error));
234222
}, delayMs);
235223

236224
this.#backoffMs = Math.min(this.#backoffMs * 2, this.#options.maxBackoffMs);
@@ -240,20 +228,56 @@ export class ReconnectingWebSocket<TData = unknown>
240228
event: TEvent,
241229
eventData: Parameters<EventHandler<TData, TEvent>>[0],
242230
): void {
243-
const handlers = this.#eventHandlers[event] as Set<
244-
EventHandler<TData, TEvent>
245-
>;
246-
for (const handler of handlers) {
231+
for (const handler of this.#eventHandlers[event]) {
247232
try {
248233
handler(eventData);
249234
} catch (error) {
250235
this.#logger.error(
251-
`Error in ${event} handler for ${this.#apiRoute}: ${error instanceof Error ? error.message : String(error)}`,
236+
`Error in ${event} handler for ${this.#apiRoute}`,
237+
error,
252238
);
253239
}
254240
}
255241
}
256242

243+
/**
244+
* Checks if the error is unrecoverable and disposes the connection,
245+
* otherwise schedules a reconnect.
246+
*/
247+
private handleConnectionError(error: unknown): void {
248+
if (this.#isDisposed) {
249+
return;
250+
}
251+
252+
if (this.isUnrecoverableHttpError(error)) {
253+
this.#logger.error(
254+
`Unrecoverable HTTP error during connection for ${this.#apiRoute}`,
255+
error,
256+
);
257+
this.dispose();
258+
return;
259+
}
260+
261+
this.#logger.warn(
262+
`WebSocket connection failed for ${this.#apiRoute}`,
263+
error,
264+
);
265+
this.scheduleReconnect();
266+
}
267+
268+
/**
269+
* Check if an error contains an unrecoverable HTTP status code.
270+
*/
271+
private isUnrecoverableHttpError(error: unknown): boolean {
272+
const errorMessage = error instanceof Error ? error.message : String(error);
273+
for (const code of UNRECOVERABLE_HTTP_CODES) {
274+
if (errorMessage.includes(String(code))) {
275+
return true;
276+
}
277+
}
278+
return false;
279+
}
280+
257281
private dispose(code?: number, reason?: string): void {
258282
if (this.#isDisposed) {
259283
return;

src/websocket/sseConnection.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,14 @@ import { EventSource } from "eventsource";
66
import { createStreamingFetchAdapter } from "../api/streamingFetchAdapter";
77
import { type Logger } from "../logging/logger";
88

9+
import { WebSocketCloseCode } from "./codes";
910
import { getQueryString } from "./utils";
1011

11-
import type {
12-
CloseEvent as WsCloseEvent,
13-
ErrorEvent as WsErrorEvent,
14-
Event as WsEvent,
15-
MessageEvent as WsMessageEvent,
16-
} from "ws";
17-
1812
import type {
1913
UnidirectionalStream,
2014
ParsedMessageEvent,
2115
EventHandler,
16+
ErrorEvent as WsErrorEvent,
2217
} from "./eventStreamConnection";
2318

2419
export type SseConnectionInit = {
@@ -66,7 +61,7 @@ export class SseConnection implements UnidirectionalStream<ServerSentEvent> {
6661

6762
private setupEventHandlers(): void {
6863
this.eventSource.addEventListener("open", () =>
69-
this.invokeCallbacks(this.callbacks.open, {} as WsEvent, "open"),
64+
this.invokeCallbacks(this.callbacks.open, {}, "open"),
7065
);
7166

7267
this.eventSource.addEventListener("data", (event: MessageEvent) => {
@@ -84,10 +79,10 @@ export class SseConnection implements UnidirectionalStream<ServerSentEvent> {
8479
this.invokeCallbacks(
8580
this.callbacks.close,
8681
{
87-
code: 1006,
82+
code: WebSocketCloseCode.ABNORMAL,
8883
reason: "Connection lost",
8984
wasClean: false,
90-
} as WsCloseEvent,
85+
},
9186
"close",
9287
);
9388
}
@@ -117,7 +112,7 @@ export class SseConnection implements UnidirectionalStream<ServerSentEvent> {
117112
return {
118113
error: error,
119114
message: errorMessage,
120-
} as WsErrorEvent;
115+
};
121116
}
122117

123118
public addEventListener<TEvent extends WebSocketEventType>(
@@ -158,7 +153,7 @@ export class SseConnection implements UnidirectionalStream<ServerSentEvent> {
158153
private parseMessage(
159154
event: MessageEvent,
160155
): ParsedMessageEvent<ServerSentEvent> {
161-
const wsEvent = { data: event.data } as WsMessageEvent;
156+
const wsEvent = { data: event.data };
162157
try {
163158
return {
164159
sourceEvent: wsEvent,
@@ -207,14 +202,16 @@ export class SseConnection implements UnidirectionalStream<ServerSentEvent> {
207202
this.invokeCallbacks(
208203
this.callbacks.close,
209204
{
210-
code: code ?? 1000,
205+
code: code ?? WebSocketCloseCode.NORMAL,
211206
reason: reason ?? "Normal closure",
212207
wasClean: true,
213-
} as WsCloseEvent,
208+
},
214209
"close",
215210
);
216211

217-
Object.values(this.callbacks).forEach((callbackSet) => callbackSet.clear());
212+
for (const callbackSet of Object.values(this.callbacks)) {
213+
callbackSet.clear();
214+
}
218215
this.messageWrappers.clear();
219216
}
220217
}

0 commit comments

Comments
 (0)