diff --git a/packages/socket-mode/package.json b/packages/socket-mode/package.json index 165fd2676..030b1288f 100644 --- a/packages/socket-mode/package.json +++ b/packages/socket-mode/package.json @@ -1,6 +1,6 @@ { "name": "@slack/socket-mode", - "version": "3.0.0-rc.1", + "version": "3.0.0-rc.2", "description": "Official library for using the Slack Platform's Socket Mode API", "author": "Slack Technologies, LLC", "license": "MIT", diff --git a/packages/socket-mode/src/SlackWebSocket.ts b/packages/socket-mode/src/SlackWebSocket.ts index ee9869219..3fcb24a13 100644 --- a/packages/socket-mode/src/SlackWebSocket.ts +++ b/packages/socket-mode/src/SlackWebSocket.ts @@ -3,7 +3,7 @@ import { channel } from 'node:diagnostics_channel'; import type { EventEmitter } from 'eventemitter3'; import { CloseEvent, type Dispatcher, ErrorEvent, MessageEvent, ping, WebSocket } from 'undici'; -import { websocketErrorWithOriginal } from './errors'; +import { SMWebsocketError } from './errors'; import log, { type Logger, LogLevel } from './logger'; import type { SocketModeDispatcher } from './SocketModeOptions'; @@ -151,7 +151,7 @@ export class SlackWebSocket { } this.logger.error(`WebSocket error occurred: ${event.message}`); this.disconnect(); - this.options.client.emit('error', websocketErrorWithOriginal(event.error ?? new Error(event.message))); + this.options.client.emit('error', new SMWebsocketError(event.error ?? new Error(event.message))); }; this.websocket.addEventListener('error', this.errorHandler); diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index 2a5449570..9f07c58c4 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -1,8 +1,9 @@ import { - ErrorCode as APICallErrorCode, type AppsConnectionsOpenResponse, addAppMetadata, - type WebAPICallError, + WebAPIHTTPError, + WebAPIPlatformError, + WebAPIRequestError, WebClient, type WebClientOptions, } from '@slack/web-api'; @@ -11,7 +12,7 @@ import { EventEmitter } from 'eventemitter3'; import { type RequestInit, fetch as undiciFetch } from 'undici'; import packageJson from '../package.json'; -import { sendWhileDisconnectedError, sendWhileNotReadyError, websocketErrorWithOriginal } from './errors'; +import { SMSendWhileDisconnectedError, SMSendWhileNotReadyError, SMWebsocketError } from './errors'; import log, { type Logger, LogLevel } from './logger'; import { SlackWebSocket, WS_READY_STATES } from './SlackWebSocket'; import type { SocketModeDispatcher, SocketModeOptions } from './SocketModeOptions'; @@ -279,16 +280,15 @@ export class SocketModeClient extends EventEmitter { } catch (error) { // TODO: Python catches rate limit errors when interacting with this API: https://github.com/slackapi/python-slack-sdk/blob/main/slack_sdk/socket_mode/client.py#L51 this.logger.error(`Failed to retrieve a new WSS URL (error: ${error})`); - const err = error as WebAPICallError; let isRecoverable = true; if ( - err.code === APICallErrorCode.PlatformError && - (Object.values(UnrecoverableSocketModeStartError) as string[]).includes(err.data.error) + error instanceof WebAPIPlatformError && + (Object.values(UnrecoverableSocketModeStartError) as string[]).includes(error.data.error) ) { isRecoverable = false; - } else if (err.code === APICallErrorCode.RequestError) { + } else if (error instanceof WebAPIRequestError) { isRecoverable = false; - } else if (err.code === APICallErrorCode.HTTPError) { + } else if (error instanceof WebAPIHTTPError) { isRecoverable = false; } if (this.autoReconnectEnabled && isRecoverable) { @@ -410,10 +410,10 @@ export class SocketModeClient extends EventEmitter { ); if (this.websocket === undefined) { this.logger.error('Failed to send a message as the client is not connected'); - reject(sendWhileDisconnectedError()); + reject(new SMSendWhileDisconnectedError()); } else if (!this.websocket.isActive()) { this.logger.error('Failed to send a message as the client has no active connection'); - reject(sendWhileNotReadyError()); + reject(new SMSendWhileNotReadyError()); } else { this.emit('outgoing_message', message); @@ -422,7 +422,7 @@ export class SocketModeClient extends EventEmitter { this.websocket.send(flatMessage, (error) => { if (error) { this.logger.error(`Failed to send a WebSocket message (error: ${error})`); - return reject(websocketErrorWithOriginal(error)); + return reject(new SMWebsocketError(error)); } return resolve(); }); diff --git a/packages/socket-mode/src/errors.test.ts b/packages/socket-mode/src/errors.test.ts new file mode 100644 index 000000000..c93407171 --- /dev/null +++ b/packages/socket-mode/src/errors.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + ErrorCode, + SMNoReplyReceivedError, + SMPlatformError, + SMSendWhileDisconnectedError, + SMSendWhileNotReadyError, + SMWebsocketError, +} from './errors'; + +describe('error classes', () => { + describe('SMWebsocketError', () => { + it('should be an instance of Error and SMWebsocketError', () => { + const original = new Error('connection reset'); + const err = new SMWebsocketError(original); + assert.ok(err instanceof Error); + assert.ok(err instanceof SMWebsocketError); + assert.equal(err.code, ErrorCode.WebsocketError); + assert.equal(err.original, original); + assert.equal(err.cause, original); + assert.equal(err.message, 'connection reset'); + assert.equal(err.name, 'SMWebsocketError'); + }); + }); + + describe('SMPlatformError', () => { + it('should be an instance of Error and SMPlatformError', () => { + const event = { error: { msg: 'not_authed' } }; + const err = new SMPlatformError(event); + assert.ok(err instanceof Error); + assert.ok(err instanceof SMPlatformError); + assert.equal(err.code, ErrorCode.SendMessagePlatformError); + assert.equal(err.data, event); + assert.equal(err.message, 'An API error occurred: not_authed'); + assert.equal(err.name, 'SMPlatformError'); + }); + }); + + describe('SMNoReplyReceivedError', () => { + it('should be an instance of Error and SMNoReplyReceivedError', () => { + const err = new SMNoReplyReceivedError(); + assert.ok(err instanceof Error); + assert.ok(err instanceof SMNoReplyReceivedError); + assert.equal(err.code, ErrorCode.NoReplyReceivedError); + assert.equal(err.name, 'SMNoReplyReceivedError'); + assert.ok(err.message.includes('no server acknowledgement')); + }); + }); + + describe('SMSendWhileDisconnectedError', () => { + it('should be an instance of Error and SMSendWhileDisconnectedError', () => { + const err = new SMSendWhileDisconnectedError(); + assert.ok(err instanceof Error); + assert.ok(err instanceof SMSendWhileDisconnectedError); + assert.equal(err.code, ErrorCode.SendWhileDisconnectedError); + assert.equal(err.name, 'SMSendWhileDisconnectedError'); + assert.ok(err.message.includes('not connected')); + }); + }); + + describe('SMSendWhileNotReadyError', () => { + it('should be an instance of Error and SMSendWhileNotReadyError', () => { + const err = new SMSendWhileNotReadyError(); + assert.ok(err instanceof Error); + assert.ok(err instanceof SMSendWhileNotReadyError); + assert.equal(err.code, ErrorCode.SendWhileNotReadyError); + assert.equal(err.name, 'SMSendWhileNotReadyError'); + assert.ok(err.message.includes('not ready')); + }); + }); +}); diff --git a/packages/socket-mode/src/errors.ts b/packages/socket-mode/src/errors.ts index 6078e1a9f..d8a7d3d1b 100644 --- a/packages/socket-mode/src/errors.ts +++ b/packages/socket-mode/src/errors.ts @@ -1,5 +1,5 @@ /** - * All errors produced by this package adhere to this interface + * @deprecated Use `instanceof` checks with specific error classes (e.g. `SMWebsocketError`) instead. */ export interface CodedError extends Error { code: string; @@ -24,93 +24,61 @@ export type SMCallError = | SMSendWhileDisconnectedError | SMSendWhileNotReadyError; -export interface SMPlatformError extends CodedError { - code: ErrorCode.SendMessagePlatformError; +export class SMPlatformError extends Error { + readonly code = ErrorCode.SendMessagePlatformError; // biome-ignore lint/suspicious/noExplicitAny: errors can be anything - data: any; -} + readonly data: any; -export interface SMWebsocketError extends CodedError { - code: ErrorCode.WebsocketError; - original: Error; + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything + constructor(event: any & { error: { msg: string } }) { + super(`An API error occurred: ${event.error.msg}`); + this.name = 'SMPlatformError'; + Object.setPrototypeOf(this, new.target.prototype); + this.data = event; + } } -export interface SMNoReplyReceivedError extends CodedError { - code: ErrorCode.NoReplyReceivedError; -} +export class SMWebsocketError extends Error { + readonly code = ErrorCode.WebsocketError; + readonly original: Error; -export interface SMSendWhileDisconnectedError extends CodedError { - code: ErrorCode.SendWhileDisconnectedError; + constructor(original: Error) { + super(original.message, { cause: original }); + this.name = 'SMWebsocketError'; + Object.setPrototypeOf(this, new.target.prototype); + this.original = original; + } } -export interface SMSendWhileNotReadyError extends CodedError { - code: ErrorCode.SendWhileNotReadyError; -} +export class SMNoReplyReceivedError extends Error { + readonly code = ErrorCode.NoReplyReceivedError; -/** - * Factory for producing a {@link CodedError} from a generic error - */ -function errorWithCode(error: Error, code: ErrorCode): CodedError { - // NOTE: might be able to return something more specific than a CodedError with conditional typing - const codedError = error as Partial; - codedError.code = code; - return codedError as CodedError; -} - -/** - * A factory to create SMWebsocketError objects. - */ -export function websocketErrorWithOriginal(original: Error): SMWebsocketError { - const error = errorWithCode(new Error(original.message), ErrorCode.WebsocketError) as Partial; - error.original = original; - return error as SMWebsocketError; -} - -/** - * A factory to create SMPlatformError objects. - */ -export function platformErrorFromEvent( - // biome-ignore lint/suspicious/noExplicitAny: errors can be anything - event: any & { error: { msg: string } }, -): SMPlatformError { - const error = errorWithCode( - new Error(`An API error occurred: ${event.error.msg}`), - ErrorCode.SendMessagePlatformError, - ) as Partial; - error.data = event; - return error as SMPlatformError; -} - -// TODO: Is the below factory needed still? -/** - * A factory to create SMNoReplyReceivedError objects. - */ -export function noReplyReceivedError(): SMNoReplyReceivedError { - return errorWithCode( - new Error( + constructor() { + super( 'Message sent but no server acknowledgement was received. This may be caused by the client ' + 'changing connection state rather than any issue with the specific message. Check before resending.', - ), - ErrorCode.NoReplyReceivedError, - ) as SMNoReplyReceivedError; + ); + this.name = 'SMNoReplyReceivedError'; + Object.setPrototypeOf(this, new.target.prototype); + } } -/** - * A factory to create SMSendWhileDisconnectedError objects. - */ -export function sendWhileDisconnectedError(): SMSendWhileDisconnectedError { - return errorWithCode( - new Error('Failed to send a WebSocket message as the client is not connected'), - ErrorCode.NoReplyReceivedError, - ) as SMSendWhileDisconnectedError; +export class SMSendWhileDisconnectedError extends Error { + readonly code = ErrorCode.SendWhileDisconnectedError; + + constructor() { + super('Failed to send a WebSocket message as the client is not connected'); + this.name = 'SMSendWhileDisconnectedError'; + Object.setPrototypeOf(this, new.target.prototype); + } } -/** - * A factory to create SMSendWhileNotReadyError objects. - */ -export function sendWhileNotReadyError(): SMSendWhileNotReadyError { - return errorWithCode( - new Error('Failed to send a WebSocket message as the client is not ready'), - ErrorCode.NoReplyReceivedError, - ) as SMSendWhileNotReadyError; +export class SMSendWhileNotReadyError extends Error { + readonly code = ErrorCode.SendWhileNotReadyError; + + constructor() { + super('Failed to send a WebSocket message as the client is not ready'); + this.name = 'SMSendWhileNotReadyError'; + Object.setPrototypeOf(this, new.target.prototype); + } }