diff --git a/CHANGELOG.md b/CHANGELOG.md index de7fe37..48b277c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ log. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **A response that arrives with no pending call left is now visible in the + log.** The line is written through the configured logger on every such + response, names the service, the method and the request id, and never the + arguments, the request or the response object (which echoes the whole + request back) or an error text. It tells a late reply apart from a service + that never answered, and after a restart it names the backlog the previous + process left behind. + + A reply that could not be written to the caller's queue is reported by + `@imqueue/core` itself, as part of its write-failure episode reporting, so + this package adds no reporting of its own there. A call that got no + response within `callTimeout` is not logged here either: the caller + receives the `IMQ_RPC_CALL_TIMEOUT` rejection and decides how to report it. + +### Changed + +- **`@logged()` now names the class and the method instead of dumping the + error.** The line reads `Class.method() failed, code `. The code is + never taken from the error as it is: only an allow-listed code is printed — + an `IMQ_`-prefixed framework code, a system `E…` code, a small integer, a + known redis reply code or one of a few known redis-client failure messages + mapped to codes of our own; everything else, the error's class name + included, is reported as `unknown`. The caught value itself, its message and its + stack are no longer printed: an application error may carry personal data, + and an imq error carries the call arguments in its properties. The method + name now also reaches the line under standard (TC39) decorators, where it was + previously unavailable. Everything else is unchanged, including which logger + is resolved, `doNotThrow`, the re-thrown value and the fact that a throwing + logger replaces the original error. + ## [3.4.4] - 2026-07-26 ### Changed diff --git a/src/IMQClient.ts b/src/IMQClient.ts index 83b2713..cb70c7a 100644 --- a/src/IMQClient.ts +++ b/src/IMQClient.ts @@ -48,6 +48,7 @@ import { fileExists, mkdir, writeFile, + logSafe, SIGNALS, } from './helpers/index.js'; import { EventEmitter } from 'node:events'; @@ -162,6 +163,7 @@ export abstract class IMQClient extends EventEmitter { private readonly signalHandlers: Array<[string, (...args: any[]) => void]> = []; private readonly logger: ILogger; + private resolvers: { [id: string]: [ (data: AnyJson, res: IMQRPCResponse) => void, @@ -388,6 +390,8 @@ export abstract class IMQClient extends EventEmitter { // extends the budget accordingly timer = setTimeout(() => { delete this.resolvers[id]; + // not logged here: the caller receives the + // rejection below and decides how to report it doReject( IMQError( 'IMQ_RPC_CALL_TIMEOUT', @@ -468,6 +472,18 @@ export abstract class IMQClient extends EventEmitter { // current redis mock, BTW it was tested manually on real // redis run if (!this.resolvers[message.to]) { + // a response nobody is waiting for any more: either it came + // after the call had been given up on, or the call was made + // by a process which is gone. Neither the response nor its + // error is logged - the response echoes the request back + logSafe( + this.logger, + 'warn', + `${this.serviceName}: response to request ${ + message?.to + } has no pending call, method ${message?.request?.method}`, + ); + // when there is no resolvers it means // we have message in queue which was initiated // by some process which is broken. So we provide an diff --git a/src/decorators/logged.ts b/src/decorators/logged.ts index 7912181..ad8773a 100644 --- a/src/decorators/logged.ts +++ b/src/decorators/logged.ts @@ -22,6 +22,29 @@ * to get commercial licensing options. */ import { type ILogger } from '@imqueue/core'; +import { errorCode } from '../helpers/index.js'; + +/** + * Name of the class a decorated method was called on, or `unknown` when it + * cannot be told. + * + * @param self - the `this` of the decorated call + * @returns the class name + * + * @remarks + * On a static method `this` is the class itself, so its own name is the answer + * there — going through the constructor would report `Function`. + */ +function className(self: any): string { + try { + const name = + typeof self === 'function' ? self.name : self?.constructor?.name; + + return typeof name === 'string' && name ? name : 'unknown'; + } catch { + return 'unknown'; + } +} /** * Names of the `ILogger` methods {@link logged} can use to record a caught @@ -64,12 +87,14 @@ export interface LoggedDecoratorOptions { /** * Creates a `@logged()` method decorator that wraps the decorated method in a - * try/catch and logs any error it throws. The logger is resolved in this - * order: an explicitly passed logger, then a `logger` defined on the instance - * or on the class, and finally the global `console`. By default the error is - * re-thrown after being logged; pass `{ doNotThrow: true }` to swallow it. The - * returned decorator is dual-mode: it works both as a standard (TC39) and as a - * legacy method decorator. + * try/catch and logs any error it throws. The logged line names the class, the + * method and an allow-listed failure code — never the error object itself, + * whose message, stack and properties may carry application data. The logger + * is resolved in this order: an explicitly passed logger, then a `logger` + * defined on the instance or on the class, and finally the global `console`. + * By default the error is re-thrown after being logged; pass + * `{ doNotThrow: true }` to swallow it. The returned decorator is dual-mode: + * it works both as a standard (TC39) and as a legacy method decorator. * * @param options - a logger to use, or the * logged-decorator options @@ -82,7 +107,7 @@ export function logged(options?: ILogger | LoggedDecoratorOptions): any { : 'error'; const doThrow = !options || !(options as LoggedDecoratorOptions).doNotThrow; - const wrap = (original: (...args: any[]) => any) => + const wrap = (original: (...args: any[]) => any, method?: string) => async function (this: any, ...args: any[]): Promise { try { if (original) { @@ -103,7 +128,25 @@ export function logged(options?: ILogger | LoggedDecoratorOptions): any { ? ((this.constructor as any).logger as ILogger) : console; - (logger as any)[level](err); + // the caught value itself is never printed: an application + // error may carry personal data, and an imq error carries the + // call arguments in its own properties. Only the class, the + // method and the failure code go out + let where = 'unknown.unknown()'; + let code = 'unknown'; + + try { + where = `${className(this)}.${method || 'unknown'}()`; + code = errorCode(err); + } catch { + // extraction must never replace the original error, so + // the fallbacks above stand + } + + // deliberately not contained: a throwing logger replaces the + // original error today, and changing that would be a change + // of behaviour rather than of logging + (logger as any)[level](`${where} failed, code ${code}`); if (doThrow) { throw err; @@ -117,10 +160,10 @@ export function logged(options?: ILogger | LoggedDecoratorOptions): any { // (target, propertyKey, descriptor). return function (target: any, context: any, descriptor?: any): any { if (context && typeof context === 'object' && 'kind' in context) { - return wrap(target); + return wrap(target, context.name && String(context.name)); } - descriptor.value = wrap(descriptor.value); + descriptor.value = wrap(descriptor.value, context && String(context)); return descriptor; }; diff --git a/src/helpers/index.ts b/src/helpers/index.ts index a3fa473..4df7e6f 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -24,3 +24,4 @@ export * from './signature.js'; export * from './os-uuid.js'; export * from './pid.js'; export * from './fs.js'; +export * from './logging.js'; diff --git a/src/helpers/logging.ts b/src/helpers/logging.ts new file mode 100644 index 0000000..f7cfdf8 --- /dev/null +++ b/src/helpers/logging.ts @@ -0,0 +1,158 @@ +/*! + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ +import { type ILogger } from '@imqueue/core'; + +/** + * Writes the given line through the given logger, containing any logger + * failure. + * + * @param logger - logger to write the line with + * @param level - logger method to use + * @param message - the line, which must never carry call arguments, request + * or response objects or an error text + * + * @remarks + * Never throws: a broken logger must not be able to change what the caller + * does. + */ +export function logSafe( + logger: ILogger, + level: 'info' | 'warn' | 'error', + message: string, +): void { + try { + logger[level](message); + } catch { + // a failing logger must never influence behaviour + } +} + +/** + * Redis error replies this helper is allowed to quote. The leading token of a + * Redis error reply is a protocol constant, never data — but only these are + * recognised, so an arbitrary upper-case first word of some other error's + * message can never reach the log. + */ +const REDIS_REPLY_CODES: Set = new Set([ + 'ASK', + 'BUSY', + 'BUSYGROUP', + 'CLUSTERDOWN', + 'CROSSSLOT', + 'ERR', + 'EXECABORT', + 'LOADING', + 'MASTERDOWN', + 'MISCONF', + 'MOVED', + 'NOAUTH', + 'NOGROUP', + 'NOPERM', + 'NOPROTO', + 'NOREPLICAS', + 'NOSCRIPT', + 'NOTBUSY', + 'OOM', + 'READONLY', + 'TRYAGAIN', + 'UNBLOCKED', + 'UNKILLABLE', + 'WRONGPASS', + 'WRONGTYPE', +]); + +/** + * Transport failures the redis client reports by message only, mapped to a + * code of our own. The patterns are fixed library strings, so nothing from an + * application error can match them. + */ +const CLIENT_MESSAGE_CODES: Array<[RegExp, string]> = [ + [/^Connection is closed/i, 'CONNECTION_CLOSED'], + [/^Stream connection ended/i, 'STREAM_ENDED'], + [/^Reached the max retries per request limit/i, 'MAX_RETRIES'], + [/^Command timed out/i, 'COMMAND_TIMEOUT'], +]; + +/** + * Shape of an `err.code` this helper accepts: an `IMQ_`-prefixed code of the + * framework itself, or a system errno such as `ECONNREFUSED`. + */ +const SAFE_CODE = /^(IMQ_[A-Z0-9_]{1,48}|E[A-Z]{2,15})$/; + +/** Upper bound of a numeric `err.code`, so that no long number can pass */ +const MAX_NUMERIC_CODE = 65535; + +/** + * Extracts a loggable failure code from an unknown thrown value. + * + * @param err - the caught value, of any shape + * @returns the code, or `unknown` when none can be told safely + * + * @remarks + * Deliberately conservative: only an allow-listed code can come out of here, + * because an application error reaches this helper too and anything of its own + * may carry personal data. Recognised are a framework or system `code`, a + * small numeric `code`, the leading token of a known Redis error reply and a + * known redis-client failure message. Everything else — including the error's + * message, its stack and its class name — yields `unknown`. Never throws. + */ +export function errorCode(err: unknown): string { + try { + const code = (err as { code?: unknown } | undefined)?.code; + + if ( + typeof code === 'number' && + Number.isInteger(code) && + code >= 0 && + code <= MAX_NUMERIC_CODE + ) { + return String(code); + } + + if ( + typeof code === 'string' && + (SAFE_CODE.test(code) || REDIS_REPLY_CODES.has(code)) + ) { + return code; + } + + const message = (err as { message?: unknown } | undefined)?.message; + + if (typeof message === 'string') { + const reply = message.split(' ', 1)[0]; + + if (REDIS_REPLY_CODES.has(reply)) { + return reply; + } + + for (const [pattern, mapped] of CLIENT_MESSAGE_CODES) { + if (pattern.test(message)) { + return mapped; + } + } + } + + return 'unknown'; + } catch { + return 'unknown'; + } +} diff --git a/test/IMQClient.callTimeout.spec.ts b/test/IMQClient.callTimeout.spec.ts index 1623536..da28b34 100644 --- a/test/IMQClient.callTimeout.spec.ts +++ b/test/IMQClient.callTimeout.spec.ts @@ -147,3 +147,117 @@ describe('IMQClient call timeout', () => { assert.equal(await call, 'late'); }); }); + +describe('IMQClient response visibility', () => { + let client: TimeoutClient; + + afterEach(async () => { + mock.timers.reset(); + await client?.destroy(); + mock.restoreAll(); + }); + + it('should warn about every response no call is waiting for', async () => { + const warn = mock.fn(); + const capturing: any = { + info: () => {}, + warn, + error: () => {}, + log: () => {}, + }; + + client = new TimeoutClient({ logger: capturing }); + await client.start(); + + const imq: any = (client as any).imq; + + imq.emit('message', { + to: 'TL3', + request: { from: 'C', method: 'ping', args: ['secret-argument'] }, + data: 'late-pong', + }); + imq.emit('message', { + to: 'TL6', + request: { from: 'C', method: 'ping', args: [] }, + data: 'late-pong', + }); + + const lines = warn.mock.calls + .map((one: any) => String(one.arguments[0])) + .filter((one: string) => /has no pending call/.test(one)); + + // one line per unmatched response: each carries its own request id, + // and the flow is bounded by the backlog left behind by a restart + assert.equal(lines.length, 2); + assert.match(lines[0], /TL3/); + assert.match(lines[1], /TL6/); + assert.match(lines[0], /ping/); + assert.equal(lines[0].includes('secret-argument'), false); + assert.equal(lines[0].includes('late-pong'), false); + }); + + it('should keep routing an unmatched response on a broken logger', async () => { + const broken: any = { + info: () => {}, + warn: () => { + throw new Error('logger is broken'); + }, + error: () => {}, + log: () => {}, + }; + + client = new TimeoutClient({ logger: broken }); + await client.start(); + + const imq: any = (client as any).imq; + const routed: any[] = []; + + client.on('ping', (message: any) => routed.push(message)); + + imq.emit('message', { + to: 'TL7', + request: { from: 'C', method: 'ping', args: [] }, + data: 'late-pong', + }); + + assert.equal( + routed.length, + 1, + 'the EventEmitter hand-off must survive a throwing logger', + ); + }); + + it('should stay quiet about a response its call still waits for', async () => { + const warn = mock.fn(); + const capturing: any = { + info: () => {}, + warn, + error: () => {}, + log: () => {}, + }; + + client = new TimeoutClient({ logger: capturing }); + await client.start(); + + const imq: any = (client as any).imq; + + mock.method(imq, 'send', async (to: string, request: any) => { + const id = 'TL4'; + + setImmediate(() => + imq.emit('message', { to: id, request, data: 'pong' }), + ); + + return id; + }); + + assert.equal(await client.ping(), 'pong'); + assert.equal( + warn.mock.calls + .map((one: any) => String(one.arguments[0])) + .filter((one: string) => /has no pending call/.test(one)) + .length, + 0, + ); + }); +}); diff --git a/test/IMQService.replyFailure.spec.ts b/test/IMQService.replyFailure.spec.ts index a5c9b48..4ae963d 100644 --- a/test/IMQService.replyFailure.spec.ts +++ b/test/IMQService.replyFailure.spec.ts @@ -75,4 +75,82 @@ describe('IMQService reply-publish failure', () => { process.removeListener('unhandledRejection', unhandled as any); }, ); + + it('should keep returning the message id of the sent response', async () => { + const logger: any = { + info: () => {}, + warn: () => {}, + error: () => {}, + log: () => {}, + }; + const order: string[] = []; + + service = new ReplyFailService({ + logger, + afterCall: (async () => { + order.push('afterCall'); + }) as any, + }); + await service.start(); + + mock.method(service.imq, 'send', async () => { + order.push('send'); + + return 'the-sent-id'; + }); + + const { send: sendResponse } = await import('../index.js'); + const request: IMQRPCRequest = { + from: 'ReplyFailClient', + method: 'ping', + args: [], + }; + const id = await sendResponse( + request, + { to: 'request-id', data: null, error: null, request }, + service, + ); + + assert.equal(id, 'the-sent-id'); + assert.deepEqual(order, ['send', 'afterCall']); + }); + + it('should call core send without an error handler of its own', async () => { + const logger: any = { + info: () => {}, + warn: () => {}, + error: () => {}, + log: () => {}, + }; + + service = new ReplyFailService({ logger }); + await service.start(); + + const seen: any[] = []; + + mock.method(service.imq, 'send', async (...args: any[]) => { + seen.push(args); + + return 'sent-id'; + }); + + const { send: sendResponse } = await import('../index.js'); + const request: IMQRPCRequest = { + from: 'ReplyFailClient', + method: 'ping', + args: [], + }; + + await sendResponse( + request, + { to: 'request-id', data: null, error: null, request }, + service, + ); + + // a rejected response write is core's to report: rpc passes no + // fourth-argument error handler, exactly as it always did + assert.equal(seen.length, 1); + assert.equal(seen[0].length, 2); + assert.equal(seen[0][0], 'ReplyFailClient'); + }); }); diff --git a/test/decorators/logged.spec.ts b/test/decorators/logged.spec.ts index 264cb3d..d9e7115 100644 --- a/test/decorators/logged.spec.ts +++ b/test/decorators/logged.spec.ts @@ -27,7 +27,10 @@ describe('decorators/logged()', () => { } catch (e) { assert.equal(e, error); assert.equal(stub.mock.callCount() === 1, true); - assert.equal(stub.mock.calls[0].arguments[0], error); + assert.equal( + stub.mock.calls[0].arguments[0], + 'A.fail() failed, code unknown', + ); } finally { stub.mock.restore(); } @@ -53,7 +56,10 @@ describe('decorators/logged()', () => { const res = await new B().fail(); assert.equal(res, undefined); assert.equal(myLogger.warn.mock.callCount() === 1, true); - assert.equal(myLogger.warn.mock.calls[0].arguments[0], error); + assert.equal( + myLogger.warn.mock.calls[0].arguments[0], + 'B.fail() failed, code unknown', + ); }); it('should accept ILogger directly and rethrow by default', async () => { @@ -79,7 +85,10 @@ describe('decorators/logged()', () => { } catch (e) { assert.equal(e, error); assert.equal(myLogger.error.mock.callCount() === 1, true); - assert.equal(myLogger.error.mock.calls[0].arguments[0], error); + assert.equal( + myLogger.error.mock.calls[0].arguments[0], + 'C.fail() failed, code unknown', + ); } }); @@ -102,7 +111,10 @@ describe('decorators/logged()', () => { } catch (e) { assert.equal(e, error); assert.equal(myLogger.error.mock.callCount() === 1, true); - assert.equal(myLogger.error.mock.calls[0].arguments[0], error); + assert.equal( + myLogger.error.mock.calls[0].arguments[0], + 'E.fail() failed, code unknown', + ); } }); @@ -123,10 +135,160 @@ describe('decorators/logged()', () => { } catch (e) { assert.equal(e, error); assert.equal(protologger.error.mock.callCount() === 1, true); - assert.equal(protologger.error.mock.calls[0].arguments[0], error); + assert.equal( + protologger.error.mock.calls[0].arguments[0], + 'F.fail() failed, code unknown', + ); } }); + it('should never log the error object, its message or its stack', async () => { + const error = new Error('customer 12345 ssn 000-00-0000'); + const myLogger = { error: mock.fn() } as any; + + class G { + public logger = myLogger; + // @ts-ignore + @logged() + public fail() { + throw error; + } + } + + try { + await new G().fail(); + assert.fail('should throw'); + } catch (e) { + assert.equal(e, error, 'the original error must be re-thrown'); + } + + const line = String(myLogger.error.mock.calls[0].arguments[0]); + + assert.equal(myLogger.error.mock.calls[0].arguments.length, 1); + assert.match(line, /G\.fail\(\)/); + assert.equal(line.includes('12345'), false); + assert.equal(line.includes('000-00-0000'), false); + assert.equal(line.includes('at '), false); + }); + + it('should log the code of an error which carries one', async () => { + const error = Object.assign(new Error('nope'), { + code: 'IMQ_RPC_CALL_TIMEOUT', + }); + const myLogger = { error: mock.fn() } as any; + + class H { + public logger = myLogger; + // @ts-ignore + @logged() + public fail() { + throw error; + } + } + + await assert.rejects(new H().fail() as any); + assert.equal( + myLogger.error.mock.calls[0].arguments[0], + 'H.fail() failed, code IMQ_RPC_CALL_TIMEOUT', + ); + }); + + it('should keep names in the legacy decorator form', async () => { + const error = new Error('legacy'); + const myLogger = { error: mock.fn() } as any; + + class I { + public logger = myLogger; + public fail(): void { + throw error; + } + } + + const descriptor = { + value: I.prototype.fail, + } as PropertyDescriptor; + + (logged() as any)(I.prototype, 'fail', descriptor); + I.prototype.fail = descriptor.value; + + await assert.rejects(new I().fail() as any); + assert.equal( + myLogger.error.mock.calls[0].arguments[0], + 'I.fail() failed, code unknown', + ); + }); + + it('should keep the current behaviour of a throwing logger', async () => { + const error = new Error('original'); + const loggerError = new Error('logger is broken'); + const myLogger = { + error: () => { + throw loggerError; + }, + } as any; + + class J { + public logger = myLogger; + // @ts-ignore + @logged({ doNotThrow: true }) + public fail() { + throw error; + } + } + + await assert.rejects( + new J().fail() as any, + (err: any) => err === loggerError, + ); + }); + + it('should name the class of a static method, not Function', async () => { + const myLogger = { error: mock.fn() } as any; + + class K { + public static logger = myLogger; + + public static fail(): void { + throw new Error('static boom'); + } + } + + const descriptor = { value: K.fail } as PropertyDescriptor; + + (logged() as any)(K, 'fail', descriptor); + (K as any).fail = descriptor.value; + + await assert.rejects((K as any).fail()); + assert.equal( + myLogger.error.mock.calls[0].arguments[0], + 'K.fail() failed, code unknown', + ); + }); + + it('should name the class of a static method in the TC39 form', async () => { + const myLogger = { error: mock.fn() } as any; + + class L { + public static logger = myLogger; + + public static fail(): void { + throw new Error('static boom'); + } + } + + const wrapped = (logged() as any)(L.fail, { + kind: 'method', + name: 'fail', + static: true, + }); + + await assert.rejects(wrapped.call(L)); + assert.equal( + myLogger.error.mock.calls[0].arguments[0], + 'L.fail() failed, code unknown', + ); + }); + it('should pass through successful return value', async () => { class D { // @ts-ignore diff --git a/test/helpers/logging.spec.ts b/test/helpers/logging.spec.ts new file mode 100644 index 0000000..1d3eccb --- /dev/null +++ b/test/helpers/logging.spec.ts @@ -0,0 +1,102 @@ +/*! + * Logging helpers unit tests + */ +import { describe, it, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { errorCode, logSafe } from '../../src/helpers/index.js'; + +const capturing = (): any => { + const warn = mock.fn(); + + return { + warn, + lines: (): string[] => + warn.mock.calls.map((one: any) => String(one.arguments[0])), + logger: { log: () => {}, info: () => {}, warn, error: () => {} } as any, + }; +}; + +describe('logSafe()', () => { + it('writes every line, repeats included', () => { + const cap = capturing(); + + logSafe(cap.logger, 'warn', 'one'); + logSafe(cap.logger, 'warn', 'one'); + logSafe(cap.logger, 'warn', 'two'); + + assert.deepEqual(cap.lines(), ['one', 'one', 'two']); + }); + + it('writes through the requested level', () => { + const error = mock.fn(); + const logger: any = { + log: () => {}, + info: () => {}, + warn: () => {}, + error, + }; + + logSafe(logger, 'error', 'boom'); + + assert.equal(error.mock.callCount(), 1); + }); + + it('never throws when the logger throws', () => { + const broken: any = { + warn: () => { + throw new Error('logger is broken'); + }, + }; + + assert.doesNotThrow(() => logSafe(broken, 'warn', 'line')); + }); +}); + +describe('errorCode()', () => { + it('prefers an explicit code', () => { + assert.equal( + errorCode({ code: 'IMQ_RPC_CALL_TIMEOUT' }), + 'IMQ_RPC_CALL_TIMEOUT', + ); + assert.equal(errorCode({ code: 42 }), '42'); + }); + + it('reads the leading redis reply code', () => { + assert.equal(errorCode(new Error('WRONGTYPE nope')), 'WRONGTYPE'); + }); + + it('never returns the message itself', () => { + assert.equal( + errorCode(new Error('customer 12345 ssn 000-00-0000')), + 'unknown', + ); + assert.equal(errorCode(new Error('CUSTOMER 12345 secret')), 'unknown'); + }); + + it('never returns a code outside the allow-list', () => { + assert.equal(errorCode({ code: 'SSN-000-00-0000' }), 'unknown'); + assert.equal(errorCode({ code: 123456789 }), 'unknown'); + assert.equal(errorCode({ name: 'Customer_12345' }), 'unknown'); + }); + + it('maps a known client failure message to a code', () => { + assert.equal( + errorCode(new Error('Connection is closed.')), + 'CONNECTION_CLOSED', + ); + }); + + it('never throws on odd values', () => { + assert.equal(errorCode(undefined), 'unknown'); + assert.equal(errorCode(null), 'unknown'); + assert.equal(errorCode({}), 'unknown'); + assert.equal( + errorCode({ + get code(): string { + throw new Error('nope'); + }, + }), + 'unknown', + ); + }); +});