Error Handling
The WebSocket client provides comprehensive error handling with type-safe error classes and centralized reporting.
Error Classes
Import error classes from the client package:
import {
ValidationError,
TimeoutError,
ServerError,
ConnectionClosedError,
StateError,
} from "bun-ws-router/client";
ValidationError
Thrown when message validation fails (outbound or inbound).
class ValidationError extends Error {
constructor(
message: string,
public readonly issues: Array<{ path: string[]; message: string }>
);
}
Occurs when:
- Outbound: Payload doesn't match schema before sending
- Inbound: Server reply has wrong type or fails schema validation
Example:
try {
await client.request(Hello, { name: "test" }, HelloOk);
} catch (err) {
if (err instanceof ValidationError) {
console.error("Validation failed:", err.message);
err.issues.forEach((issue) => {
console.error(` ${issue.path.join(".")}: ${issue.message}`);
});
}
}
TimeoutError
Thrown when request times out.
class TimeoutError extends Error {
constructor(public readonly timeoutMs: number);
}
Occurs when:
- No reply received within
timeoutMs
correlationId
never matched
Example:
try {
await client.request(Hello, { name: "test" }, HelloOk, {
timeoutMs: 5000,
});
} catch (err) {
if (err instanceof TimeoutError) {
console.warn(`Request timed out after ${err.timeoutMs}ms`);
// Retry or show user feedback
}
}
ServerError
Thrown when server sends error response.
class ServerError extends Error {
constructor(
message: string,
public readonly code: ErrorCode,
public readonly context?: Record<string, unknown>
);
}
Occurs when:
- Server sends
ERROR
message with matchingcorrelationId
Example:
try {
await client.request(DeleteItem, { id: 123 }, DeleteItemOk);
} catch (err) {
if (err instanceof ServerError) {
console.error(`Server error: ${err.code}`, err.context);
if (err.code === "RESOURCE_NOT_FOUND") {
console.warn("Item not found");
} else if (err.code === "AUTHENTICATION_FAILED") {
redirectToLogin();
}
}
}
ConnectionClosedError
Thrown when connection closes before reply.
class ConnectionClosedError extends Error {}
Occurs when:
- Connection closes while waiting for request reply
- Server disconnects before responding
Example:
try {
await client.request(Hello, { name: "test" }, HelloOk);
} catch (err) {
if (err instanceof ConnectionClosedError) {
console.warn("Connection closed before reply");
// Reconnect or show offline UI
}
}
StateError
Thrown when operation is invalid in current state.
class StateError extends Error {}
Occurs when:
- Request aborted via
AbortSignal
request()
called withqueue: "off"
while disconnected- Pending request limit exceeded
Example:
const controller = new AbortController();
const promise = client.request(Hello, { name: "test" }, HelloOk, {
signal: controller.signal,
});
// Cancel request
controller.abort();
try {
await promise;
} catch (err) {
if (err instanceof StateError) {
console.log("Request aborted");
}
}
Error Patterns
Fire-and-Forget (send)
send()
never throws - use return value:
const sent = client.send(ChatMessage, { text: "Hello!" });
if (!sent) {
console.warn("Message dropped (offline, queue full, or invalid)");
// Show user feedback, don't retry
}
Returns false
when:
queue === "off"
while offline- Queue overflow with
queue === "drop-newest"
- Payload validation fails
Request/Response
request()
never throws synchronously - always returns Promise:
try {
const reply = await client.request(Hello, { name: "Anna" }, HelloOk, {
timeoutMs: 5000,
});
console.log("Success:", reply.payload);
} catch (err) {
if (err instanceof TimeoutError) {
console.warn("Timeout - retry or show feedback");
} else if (err instanceof ServerError) {
console.error("Server error:", err.code);
} else if (err instanceof ConnectionClosedError) {
console.warn("Disconnected - reconnecting...");
} else if (err instanceof ValidationError) {
console.error("Invalid data:", err.issues);
} else if (err instanceof StateError) {
console.log("Request cancelled or limit exceeded");
}
}
Connection Errors
connect()
rejects on connection failure:
try {
await client.connect();
console.log("Connected!");
} catch (err) {
console.error("Connection failed:", err.message);
// Show offline UI or retry
}
close()
never rejects - always safe:
// Always safe, never throws
await client.close({ code: 1000, reason: "Done" });
Centralized Error Reporting
Use onError()
for non-fatal internal errors:
client.onError((error, context) => {
switch (context.type) {
case "parse":
// Invalid JSON from server
console.warn("Parse error:", error.message);
Sentry.captureException(error, { tags: { type: "ws-parse" } });
break;
case "validation":
// Message validation failed
console.warn("Validation error:", error.message, context.details);
Sentry.captureException(error, {
tags: { type: "ws-validation" },
extra: context.details,
});
break;
case "overflow":
// Queue overflow (message dropped)
console.warn("Queue overflow:", error.message);
metrics.increment("ws.queue.overflow");
break;
case "unknown":
// Other internal errors
console.warn("Unknown error:", error.message, context.details);
Sentry.captureException(error, { tags: { type: "ws-unknown" } });
break;
}
});
Fires for:
- Parse failures (invalid JSON)
- Validation failures (invalid messages)
- Queue overflow (message dropped)
- Unknown internal errors
Does NOT fire for:
request()
rejections (caller handles with try/catch)- Handler errors (automatically logged to
console.error
)
Integration Examples
Sentry
import * as Sentry from "@sentry/browser";
client.onError((error, context) => {
Sentry.captureException(error, {
tags: {
source: "websocket",
errorType: context.type,
},
extra: context.details,
});
});
DataDog
import { datadogLogs } from "@datadog/browser-logs";
client.onError((error, context) => {
datadogLogs.logger.error("WebSocket error", {
error: error.message,
type: context.type,
details: context.details,
});
});
Custom Metrics
const errorCounts = new Map<string, number>();
client.onError((error, context) => {
const count = errorCounts.get(context.type) || 0;
errorCounts.set(context.type, count + 1);
// Send to analytics
analytics.track("websocket_error", {
type: context.type,
message: error.message,
count: count + 1,
});
});
Handler Errors
Handler errors are automatically logged to console.error
and don't stop other handlers:
client.on(HelloOk, (msg) => {
throw new Error("Handler error");
// Logged to console.error
// Other handlers still execute
});
client.on(HelloOk, (msg) => {
console.log("This still runs!");
});
Error Recovery
Retry Pattern
async function sendWithRetry(
schema: AnyMessageSchema,
payload: any,
maxRetries = 3,
) {
for (let i = 0; i < maxRetries; i++) {
try {
const reply = await client.request(schema, payload, ReplySchema, {
timeoutMs: 5000,
});
return reply;
} catch (err) {
if (err instanceof TimeoutError && i < maxRetries - 1) {
console.warn(`Retry ${i + 1}/${maxRetries}`);
await sleep(1000 * (i + 1)); // Exponential backoff
continue;
}
throw err;
}
}
}
Graceful Degradation
client.onUnhandled((msg) => {
console.warn("Unhandled message type:", msg.type);
// Show generic notification instead of failing
if (msg.type.startsWith("NOTIFY_")) {
showNotification(msg.payload?.text || "New notification");
}
});
Connection State Recovery
client.onState((state) => {
switch (state) {
case "open":
console.log("Connected - hiding offline UI");
hideOfflineUI();
break;
case "reconnecting":
console.log("Reconnecting - showing spinner");
showReconnectingUI();
break;
case "closed":
console.log("Disconnected - showing offline UI");
showOfflineUI();
break;
}
});
Best Practices
Always Handle Request Errors
// ✅ Good
try {
const reply = await client.request(Hello, { name: "test" }, HelloOk);
console.log(reply.payload);
} catch (err) {
if (err instanceof TimeoutError) {
showError("Request timed out");
} else if (err instanceof ServerError) {
showError(`Server error: ${err.code}`);
}
}
// ❌ Bad - unhandled rejection
const reply = await client.request(Hello, { name: "test" }, HelloOk);
Check send() Return Value
// ✅ Good
const sent = client.send(ChatMessage, { text: "Hi" });
if (!sent) {
showWarning("Message not sent (offline)");
}
// ❌ Bad - ignoring failure
client.send(ChatMessage, { text: "Hi" });
Use Centralized Error Reporting
// ✅ Good - centralized
client.onError((error, context) => {
logError(error, context);
});
// ❌ Bad - scattered try/catch everywhere
try {
client.send(...);
} catch (err) {
logError(err); // Won't catch internal errors
}
Validate User Input
// ✅ Good - validate before sending
function sendMessage(text: string) {
if (!text.trim()) {
showError("Message cannot be empty");
return;
}
const sent = client.send(ChatMessage, { text });
if (!sent) {
showError("Failed to send (offline)");
}
}
// ❌ Bad - no validation
function sendMessage(text: string) {
client.send(ChatMessage, { text }); // May fail validation
}