From b1ffb934b90c5f49f9ee33dcca66dfc57f618b46 Mon Sep 17 00:00:00 2001 From: "Odin H.O. Urdland" Date: Fri, 31 Oct 2025 10:51:30 +0100 Subject: [PATCH] errors: Replace pre-instantiated singleton errors with error classes BREAKING change. Singleton errors captured stack traces at module load time, making them useless for debugging. Error classes provide stack traces for each error occurrence. --- README.md | 74 +++++++++++++++++++++++++++++++----------- src/Semaphore.ts | 4 +-- src/errors.ts | 29 +++++++++++++++-- src/tryAcquire.ts | 6 ++-- src/withTimeout.ts | 8 ++--- test/mutex.ts | 8 ++--- test/semaphoreSuite.ts | 8 ++--- test/tryAcquire.ts | 10 +++--- test/withTimeout.ts | 10 +++--- 9 files changed, 108 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index b4ca708..93d0d7a 100644 --- a/README.md +++ b/README.md @@ -178,11 +178,11 @@ mutex.isLocked(); ### Cancelling pending locks Pending locks can be cancelled by calling `cancel()` on the mutex. This will reject -all pending locks with `E_CANCELED`: +all pending locks with `CanceledError`: Promise style: ```typescript -import {E_CANCELED} from 'async-mutex'; +import {CanceledError} from 'async-mutex'; mutex .runExclusive(() => { @@ -192,7 +192,7 @@ mutex // ... }) .catch(e => { - if (e === E_CANCELED) { + if (e instanceof CanceledError) { // ... } }); @@ -200,21 +200,21 @@ mutex async/await: ```typescript -import {E_CANCELED} from 'async-mutex'; +import {CanceledError} from 'async-mutex'; try { await mutex.runExclusive(() => { // ... }); } catch (e) { - if (e === E_CANCELED) { + if (e instanceof CanceledError) { // ... } } ``` This works with `acquire`, too: -if `acquire` is used for locking, the resulting promise will reject with `E_CANCELED`. +if `acquire` is used for locking, the resulting promise will reject with `CanceledError`. The error that is thrown can be customized by passing a different error to the `Mutex` constructor: @@ -380,11 +380,11 @@ semaphore.setValue(); ### Cancelling pending locks Pending locks can be cancelled by calling `cancel()` on the semaphore. This will reject -all pending locks with `E_CANCELED`: +all pending locks with `CanceledError`: Promise style: ```typescript -import {E_CANCELED} from 'async-mutex'; +import {CanceledError} from 'async-mutex'; semaphore .runExclusive(() => { @@ -394,7 +394,7 @@ semaphore // ... }) .catch(e => { - if (e === E_CANCELED) { + if (e instanceof CanceledError) { // ... } }); @@ -402,21 +402,21 @@ semaphore async/await: ```typescript -import {E_CANCELED} from 'async-mutex'; +import {CanceledError} from 'async-mutex'; try { await semaphore.runExclusive(() => { // ... }); } catch (e) { - if (e === E_CANCELED) { + if (e instanceof CanceledError) { // ... } } ``` This works with `acquire`, too: -if `acquire` is used for locking, the resulting promise will reject with `E_CANCELED`. +if `acquire` is used for locking, the resulting promise will reject with `CanceledError`. The error that is thrown can be customized by passing a different error to the `Semaphore` constructor: @@ -463,7 +463,7 @@ to both semaphores and mutexes and changes the behavior of `acquire` and `runExclusive` accordingly. ```typescript -import {withTimeout, E_TIMEOUT} from 'async-mutex'; +import {withTimeout, TimeoutError} from 'async-mutex'; const mutexWithTimeout = withTimeout(new Mutex(), 100); const semaphoreWithTimeout = withTimeout(new Semaphore(5), 100); @@ -473,7 +473,7 @@ The API of the decorated mutex or semaphore is unchanged. The second argument of `withTimeout` is the timeout in milliseconds. After the timeout is exceeded, the promise returned by `acquire` and `runExclusive` will -reject with `E_TIMEOUT`. The latter will not run the provided callback in case +reject with `TimeoutError`. The latter will not run the provided callback in case of an timeout. The third argument of `withTimeout` is optional and can be used to @@ -489,11 +489,11 @@ const semaphoreWithTimeout = withTimeout(new Semaphore(5), 100, new Error('new f A shortcut exists for the case where you do not want to wait for a lock to be available at all. The `tryAcquire` decorator can be applied to both mutexes and semaphores and changes the behavior of `acquire` and `runExclusive` to -immediately throw `E_ALREADY_LOCKED` if the mutex is not available. +immediately throw `AlreadyLockedError` if the mutex is not available. Promise style: ```typescript -import {tryAcquire, E_ALREADY_LOCKED} from 'async-mutex'; +import {tryAcquire, AlreadyLockedError} from 'async-mutex'; tryAcquire(semaphoreOrMutex) .runExclusive(() => { @@ -503,7 +503,7 @@ tryAcquire(semaphoreOrMutex) // ... }) .catch(e => { - if (e === E_ALREADY_LOCKED) { + if (e instanceof AlreadyLockedError) { // ... } }); @@ -511,14 +511,14 @@ tryAcquire(semaphoreOrMutex) async/await: ```typescript -import {tryAcquire, E_ALREADY_LOCKED} from 'async-mutex'; +import {tryAcquire, AlreadyLockedError} from 'async-mutex'; try { await tryAcquire(semaphoreOrMutex).runExclusive(() => { // ... }); } catch (e) { - if (e === E_ALREADY_LOCKED) { + if (e instanceof AlreadyLockedError) { // ... } } @@ -533,6 +533,42 @@ tryAcquire(semaphoreOrMutex, new Error('new fancy error')) // ... }); ``` + +## Migration from v0.x to v1.0 + +**v1.0 introduces breaking changes** to improve error handling with proper stack traces. + +### Error Checking + +**Before (v0.x):** +```javascript +import { E_TIMEOUT, E_CANCELED, E_ALREADY_LOCKED } from 'async-mutex'; + +try { + await mutex.runExclusive(() => { /* ... */ }); +} catch (e) { + if (e === E_TIMEOUT) { } // ❌ No longer works + if (e === E_CANCELED) { } // ❌ No longer works + if (e === E_ALREADY_LOCKED) { } // ❌ No longer works +} +``` + +**After (v1.0+):** +```javascript +import { TimeoutError, CanceledError, AlreadyLockedError } from 'async-mutex'; + +try { + await mutex.runExclusive(() => { /* ... */ }); +} catch (e) { + if (e instanceof TimeoutError) { } // ✓ Use instanceof + if (e instanceof CanceledError) { } // ✓ Use instanceof + if (e instanceof AlreadyLockedError) { } // ✓ Use instanceof +} +``` + +The old singleton errors had unusable stack traces (captured at module load time). Error classes +provide accurate, helpful stack traces for debugging. + # License Feel free to use this library under the conditions of the MIT license. diff --git a/src/Semaphore.ts b/src/Semaphore.ts index b912f2d..a036fef 100644 --- a/src/Semaphore.ts +++ b/src/Semaphore.ts @@ -1,4 +1,4 @@ -import { E_CANCELED } from './errors'; +import { CanceledError } from './errors'; import SemaphoreInterface from './SemaphoreInterface'; @@ -19,7 +19,7 @@ interface Waiter { } class Semaphore implements SemaphoreInterface { - constructor(private _value: number, private _cancelError: Error = E_CANCELED) {} + constructor(private _value: number, private _cancelError: Error = new CanceledError()) {} acquire(weight = 1, priority = 0): Promise<[number, SemaphoreInterface.Releaser]> { if (weight <= 0) throw new Error(`invalid weight ${weight}: must be positive`); diff --git a/src/errors.ts b/src/errors.ts index 05d84ea..1b59dbd 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,3 +1,26 @@ -export const E_TIMEOUT = new Error('timeout while waiting for mutex to become available'); -export const E_ALREADY_LOCKED = new Error('mutex already locked'); -export const E_CANCELED = new Error('request for lock canceled'); +export class TimeoutError extends Error { + constructor() { + super('timeout while waiting for mutex to become available'); + this.name = 'TimeoutError'; + // Fix prototype chain for ES5 + Object.setPrototypeOf(this, TimeoutError.prototype); + } +} + +export class AlreadyLockedError extends Error { + constructor() { + super('mutex already locked'); + this.name = 'AlreadyLockedError'; + // Fix prototype chain for ES5 + Object.setPrototypeOf(this, AlreadyLockedError.prototype); + } +} + +export class CanceledError extends Error { + constructor() { + super('request for lock canceled'); + this.name = 'CanceledError'; + // Fix prototype chain for ES5 + Object.setPrototypeOf(this, CanceledError.prototype); + } +} diff --git a/src/tryAcquire.ts b/src/tryAcquire.ts index b4ccb47..173acd3 100644 --- a/src/tryAcquire.ts +++ b/src/tryAcquire.ts @@ -1,4 +1,4 @@ -import { E_ALREADY_LOCKED } from './errors'; +import { AlreadyLockedError } from './errors'; import MutexInterface from './MutexInterface'; import SemaphoreInterface from './SemaphoreInterface'; import { withTimeout } from './withTimeout'; @@ -8,8 +8,8 @@ export function tryAcquire(semaphore: SemaphoreInterface, alreadyAcquiredError?: // eslint-disable-next-lisne @typescript-eslint/explicit-module-boundary-types export function tryAcquire( sync: MutexInterface | SemaphoreInterface, - alreadyAcquiredError = E_ALREADY_LOCKED + alreadyAcquiredError?: Error ): typeof sync { // eslint-disable-next-line @typescript-eslint/no-explicit-any - return withTimeout(sync as any, 0, alreadyAcquiredError); + return withTimeout(sync as any, 0, alreadyAcquiredError ?? new AlreadyLockedError()); } diff --git a/src/withTimeout.ts b/src/withTimeout.ts index 77e232f..6096cd0 100644 --- a/src/withTimeout.ts +++ b/src/withTimeout.ts @@ -1,11 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { E_TIMEOUT } from './errors'; +import { TimeoutError } from './errors'; import MutexInterface from './MutexInterface'; import SemaphoreInterface from './SemaphoreInterface'; export function withTimeout(mutex: MutexInterface, timeout: number, timeoutError?: Error): MutexInterface; export function withTimeout(semaphore: SemaphoreInterface, timeout: number, timeoutError?: Error): SemaphoreInterface; -export function withTimeout(sync: MutexInterface | SemaphoreInterface, timeout: number, timeoutError = E_TIMEOUT): any { +export function withTimeout(sync: MutexInterface | SemaphoreInterface, timeout: number, timeoutError?: Error): any { return { acquire: (weightOrPriority?: number, priority?: number): Promise => { let weight: number | undefined; @@ -24,7 +24,7 @@ export function withTimeout(sync: MutexInterface | SemaphoreInterface, timeout: const handle = setTimeout(() => { isTimeout = true; - reject(timeoutError); + reject(timeoutError ?? new TimeoutError()); }, timeout); try { @@ -91,7 +91,7 @@ export function withTimeout(sync: MutexInterface | SemaphoreInterface, timeout: } return new Promise((resolve, reject) => { - const handle = setTimeout(() => reject(timeoutError), timeout); + const handle = setTimeout(() => reject(timeoutError ?? new TimeoutError()), timeout); (isSemaphore(sync) ? sync.waitForUnlock(weight, priority) : sync.waitForUnlock(priority) diff --git a/test/mutex.ts b/test/mutex.ts index d945209..bb6fe92 100644 --- a/test/mutex.ts +++ b/test/mutex.ts @@ -2,7 +2,7 @@ import * as assert from 'assert'; import { InstalledClock, install } from '@sinonjs/fake-timers'; -import { E_CANCELED } from '../src/errors'; +import { CanceledError } from '../src/errors'; import Mutex from '../src/Mutex'; import MutexInterface from '../src/MutexInterface'; import { withTimer } from './util'; @@ -191,7 +191,7 @@ export const mutexSuite = (factory: (cancelError?: Error) => MutexInterface): vo assert.strictEqual(v, 3); }); - test('cancel rejects all pending locks witth E_CANCELED', async () => { + test('cancel rejects all pending locks with CanceledError', async () => { await mutex.acquire(); const ticket = mutex.acquire(); @@ -199,8 +199,8 @@ export const mutexSuite = (factory: (cancelError?: Error) => MutexInterface): vo mutex.cancel(); - await assert.rejects(ticket, E_CANCELED); - await assert.rejects(result, E_CANCELED); + await assert.rejects(ticket, (err: unknown): boolean => err instanceof CanceledError); + await assert.rejects(result, (err: unknown): boolean => err instanceof CanceledError); }); test('cancel rejects with a custom error if provided', async () => { diff --git a/test/semaphoreSuite.ts b/test/semaphoreSuite.ts index c75a179..437c9ef 100644 --- a/test/semaphoreSuite.ts +++ b/test/semaphoreSuite.ts @@ -2,7 +2,7 @@ import * as assert from 'assert'; import { InstalledClock, install } from '@sinonjs/fake-timers'; -import { E_CANCELED } from '../src/errors'; +import { CanceledError } from '../src/errors'; import SemaphoreInterface from '../src/SemaphoreInterface'; import { withTimer } from './util'; @@ -408,7 +408,7 @@ export const semaphoreSuite = (factory: (maxConcurrency: number, err?: Error) => semaphore.release(); }); - test('cancel rejects all pending locks with E_CANCELED', async () => { + test('cancel rejects all pending locks with CanceledError', async () => { await semaphore.acquire(); await semaphore.acquire(); @@ -417,8 +417,8 @@ export const semaphoreSuite = (factory: (maxConcurrency: number, err?: Error) => semaphore.cancel(); - await assert.rejects(ticket, E_CANCELED); - await assert.rejects(result, E_CANCELED); + await assert.rejects(ticket, (err: unknown) => err instanceof CanceledError); + await assert.rejects(result, (err: unknown) => err instanceof CanceledError); }); test('cancel rejects with a custom error if provided', async () => { diff --git a/test/tryAcquire.ts b/test/tryAcquire.ts index dd69a56..310bc74 100644 --- a/test/tryAcquire.ts +++ b/test/tryAcquire.ts @@ -2,7 +2,7 @@ import * as assert from 'assert'; import { InstalledClock, install } from '@sinonjs/fake-timers'; -import { E_ALREADY_LOCKED } from '../src/errors'; +import { AlreadyLockedError } from '../src/errors'; import Mutex from '../src/Mutex'; import Semaphore from '../src/Semaphore'; import { tryAcquire } from '../src/tryAcquire'; @@ -31,7 +31,7 @@ suite('tryAcquire', () => { await assert.rejects(ticket, error); }); - test('acquire rejects with E_ALREADY_LOCKER if no error is provided', async () => { + test('acquire rejects with AlreadyLockedError if no error is provided', async () => { const mutex = tryAcquire(new Mutex()); await mutex.acquire(); @@ -40,7 +40,7 @@ suite('tryAcquire', () => { await clock.tickAsync(0); - await assert.rejects(ticket, E_ALREADY_LOCKED); + await assert.rejects(ticket, (err: unknown) => err instanceof AlreadyLockedError); }); test('acquire locks the mutex if it is not already locked', async () => { @@ -76,7 +76,7 @@ suite('tryAcquire', () => { await assert.rejects(ticket, error); }); - test('acquire rejects with E_ALREADY_LOCKER if no error is provided', async () => { + test('acquire rejects with AlreadyLockedError if no error is provided', async () => { const semaphore = tryAcquire(new Semaphore(2)); await semaphore.acquire(); @@ -87,7 +87,7 @@ suite('tryAcquire', () => { await clock.tickAsync(0); - await assert.rejects(ticket, E_ALREADY_LOCKED); + await assert.rejects(ticket, (err: unknown) => err instanceof AlreadyLockedError); }); test('acquire locks the semaphore if it is not already locked', async () => { diff --git a/test/withTimeout.ts b/test/withTimeout.ts index 1603655..d4194d9 100644 --- a/test/withTimeout.ts +++ b/test/withTimeout.ts @@ -2,7 +2,7 @@ import * as assert from 'assert'; import { InstalledClock, install } from '@sinonjs/fake-timers'; -import { E_TIMEOUT } from './../src/errors'; +import { TimeoutError } from './../src/errors'; import Mutex from '../src/Mutex'; import MutexInterface from '../src/MutexInterface'; import Semaphore from '../src/Semaphore'; @@ -59,7 +59,7 @@ suite('withTimeout', () => { assert.strictEqual(flag, true); }); - test('runExclusive rejects with E_TIMEOUT if no error is specified', async () => { + test('runExclusive rejects with TimeoutError if no error is specified', async () => { const mutex = withTimeout(new Mutex(), 100); mutex.acquire().then((release) => setTimeout(release, 150)); @@ -68,7 +68,7 @@ suite('withTimeout', () => { await clock.tickAsync(110); - await assert.rejects(result, E_TIMEOUT); + await assert.rejects(result, (err: unknown) => err instanceof TimeoutError); }); test('runExclusive does not run the callback if timeout is exceeded', async () => { @@ -194,7 +194,7 @@ suite('withTimeout', () => { await assert.rejects(result, error); }); - test('runExclusive rejects with E_TIMEOUT if no error is specified', async () => { + test('runExclusive rejects with TimeoutError if no error is specified', async () => { const semaphore = withTimeout(new Semaphore(2), 0); semaphore.acquire(); @@ -205,7 +205,7 @@ suite('withTimeout', () => { await clock.tickAsync(110); - await assert.rejects(result, E_TIMEOUT); + await assert.rejects(result, (err: unknown) => err instanceof TimeoutError); }); test('runExclusive does not run the callback if timeout is exceeded', async () => {