Skip to content

Commit ce85289

Browse files
committed
feat: optimize AbortError
1 parent 68a1b6f commit ce85289

File tree

11 files changed

+483
-45
lines changed

11 files changed

+483
-45
lines changed

src/AbortError.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,46 @@ test('catchAbortError', () => {
3333
expect(() => catchAbortError(new AbortError())).not.toThrow();
3434
expect(() => catchAbortError(new Error())).toThrow();
3535
});
36+
37+
test('AbortError with custom message', () => {
38+
const error = new AbortError('Custom abort message');
39+
expect(error.message).toBe('Custom abort message');
40+
expect(error.name).toBe('AbortError');
41+
});
42+
43+
test('AbortError default message', () => {
44+
const error = new AbortError();
45+
expect(error.message).toBe('The operation has been aborted');
46+
expect(error.name).toBe('AbortError');
47+
});
48+
49+
test('AbortError with captureStackTrace disabled', () => {
50+
const error = new AbortError('Test message', false);
51+
expect(error.message).toBe('Test message');
52+
expect(error.name).toBe('AbortError');
53+
expect(error.stack).toBe(undefined);
54+
55+
expect(isAbortError(error)).toBe(true);
56+
expect(error).toBeInstanceOf(Error);
57+
expect(error).toBeInstanceOf(AbortError);
58+
});
59+
60+
test('AbortError with captureStackTrace enabled', () => {
61+
const error = new AbortError('Test message', true);
62+
expect(error.message).toBe('Test message');
63+
expect(error.name).toBe('AbortError');
64+
expect(error.stack).toContain('AbortError: Test message');
65+
expect(error.stack).toContain('src/AbortError.test.ts');
66+
67+
expect(isAbortError(error)).toBe(true);
68+
expect(error).toBeInstanceOf(Error);
69+
expect(error).toBeInstanceOf(AbortError);
70+
});
71+
72+
test('throwIfAborted with custom reason', () => {
73+
const abortController = new AbortController();
74+
const customReason = new Error('Custom reason');
75+
abortController.abort(customReason);
76+
77+
expect(() => throwIfAborted(abortController.signal)).toThrow(AbortError);
78+
});

src/AbortError.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,23 @@
44
* **Warning**: do not use `instanceof` with this class. Instead, use
55
* `isAbortError` function.
66
*/
7-
export class AbortError extends Error {
8-
constructor() {
9-
super('The operation has been aborted');
7+
export class AbortError implements Error {
8+
name: 'AbortError' = 'AbortError';
9+
stack?: string;
1010

11-
this.message = 'The operation has been aborted';
11+
constructor(
12+
public message = 'The operation has been aborted',
13+
captureStackTrace = true,
14+
) {
15+
if (captureStackTrace) {
16+
Error.captureStackTrace?.(this, this.constructor);
17+
}
1218

13-
this.name = 'AbortError';
19+
Object.setPrototypeOf(this, Error.prototype);
20+
}
1421

15-
if (typeof Error.captureStackTrace === 'function') {
16-
Error.captureStackTrace(this, this.constructor);
17-
}
22+
static [Symbol.hasInstance](instance: unknown) {
23+
return isAbortError(instance);
1824
}
1925
}
2026

src/all.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,3 +206,94 @@ test('empty', async () => {
206206
expect(signal.addEventListener).toHaveBeenCalledTimes(0);
207207
expect(signal.removeEventListener).toHaveBeenCalledTimes(0);
208208
});
209+
210+
test('abort with custom reason', async () => {
211+
const abortController = new AbortController();
212+
const signal = abortController.signal;
213+
214+
const customReason = new Error('Custom abort reason');
215+
216+
const deferred1 = defer<string>();
217+
const deferred2 = defer<number>();
218+
219+
let innerSignal: AbortSignal;
220+
221+
const promise = all(signal, signal => {
222+
innerSignal = signal;
223+
return [deferred1.promise, deferred2.promise];
224+
});
225+
226+
abortController.abort(customReason);
227+
228+
expect(innerSignal!.aborted).toBe(true);
229+
230+
// When external signal is aborted with custom reason,
231+
// promises should reject with AbortError
232+
deferred1.reject(new AbortError());
233+
deferred2.reject(new AbortError());
234+
235+
// The result should be AbortError (first rejection), not custom reason
236+
await expect(promise).rejects.toMatchObject({
237+
name: 'AbortError',
238+
});
239+
});
240+
241+
test('abort before all with custom reason', async () => {
242+
const abortController = new AbortController();
243+
const signal = abortController.signal;
244+
245+
const customReason = new Error('Custom abort reason');
246+
abortController.abort(customReason);
247+
248+
const executor = jest.fn((signal: AbortSignal) => [Promise.resolve('test')]);
249+
250+
await expect(all(signal, executor)).rejects.toBe(customReason);
251+
252+
expect(executor).not.toHaveBeenCalled();
253+
});
254+
255+
test('innerSignal receives custom reason on external abort', async () => {
256+
const abortController = new AbortController();
257+
const signal = abortController.signal;
258+
259+
const customReason = new Error('Custom abort reason');
260+
261+
const deferred1 = defer<string>();
262+
263+
let innerSignal: AbortSignal;
264+
265+
all(signal, signal => {
266+
innerSignal = signal;
267+
return [deferred1.promise];
268+
});
269+
270+
abortController.abort(customReason);
271+
272+
expect(innerSignal!.aborted).toBe(true);
273+
expect(innerSignal!.reason).toBe(customReason);
274+
});
275+
276+
test('innerSignal receives descriptive reason on promise rejection', async () => {
277+
const abortController = new AbortController();
278+
const signal = abortController.signal;
279+
280+
const deferred1 = defer<string>();
281+
const deferred2 = defer<number>();
282+
283+
let innerSignal: AbortSignal;
284+
285+
all(signal, signal => {
286+
innerSignal = signal;
287+
return [deferred1.promise, deferred2.promise];
288+
});
289+
290+
deferred1.reject(new Error('test'));
291+
292+
await nextTick();
293+
294+
expect(innerSignal!.aborted).toBe(true);
295+
expect(innerSignal!.reason).toMatchObject({
296+
name: 'AbortError',
297+
message: 'One of the promises passed to all() rejected',
298+
});
299+
});

src/all.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ export function all<T>(
143143
): Promise<T[]> {
144144
return new Promise((resolve, reject) => {
145145
if (signal.aborted) {
146-
reject(new AbortError());
146+
reject(signal.reason ?? new AbortError());
147147
return;
148148
}
149149

@@ -157,7 +157,7 @@ export function all<T>(
157157
}
158158

159159
const abortListener = () => {
160-
innerAbortController.abort();
160+
innerAbortController.abort(signal.reason ?? new AbortError());
161161
};
162162

163163
signal.addEventListener('abort', abortListener);
@@ -189,7 +189,12 @@ export function all<T>(
189189
settled();
190190
},
191191
reason => {
192-
innerAbortController.abort();
192+
innerAbortController.abort(
193+
new AbortError(
194+
'One of the promises passed to all() rejected',
195+
false,
196+
),
197+
);
193198

194199
if (
195200
rejection == null ||

src/execute.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,3 +317,99 @@ test('async abort callback rejection', async () => {
317317
expect(signal.addEventListener).toHaveBeenCalledTimes(1);
318318
expect(signal.removeEventListener).toHaveBeenCalledTimes(1);
319319
});
320+
321+
test('abort with custom reason', async () => {
322+
const abortController = new AbortController();
323+
const signal = abortController.signal;
324+
325+
const customReason = new Error('Custom abort reason');
326+
const callback = jest.fn((reason?: unknown) => {
327+
expect(reason).toBe(customReason);
328+
});
329+
330+
let result: PromiseSettledResult<string> | undefined;
331+
332+
execute<string>(signal, (resolve, reject) => {
333+
return callback;
334+
}).then(
335+
value => {
336+
result = {status: 'fulfilled', value};
337+
},
338+
reason => {
339+
result = {status: 'rejected', reason};
340+
},
341+
);
342+
343+
abortController.abort(customReason);
344+
345+
await nextTick();
346+
347+
expect(callback).toHaveBeenCalledTimes(1);
348+
expect(result).toMatchObject({
349+
status: 'rejected',
350+
reason: customReason,
351+
});
352+
});
353+
354+
test('abort before execute with custom reason', async () => {
355+
const abortController = new AbortController();
356+
const signal = abortController.signal;
357+
358+
const customReason = new Error('Custom abort reason');
359+
abortController.abort(customReason);
360+
361+
const executor = jest.fn(
362+
(
363+
resolve: (value: string) => void,
364+
reject: (reason?: any) => void,
365+
): (() => void | PromiseLike<void>) => {
366+
return () => {};
367+
},
368+
);
369+
370+
await expect(execute(signal, executor)).rejects.toBe(customReason);
371+
372+
expect(executor).not.toHaveBeenCalled();
373+
});
374+
375+
test('async abort callback with custom reason', async () => {
376+
const abortController = new AbortController();
377+
const signal = abortController.signal;
378+
379+
const customReason = new Error('Custom abort reason');
380+
const callbackDeferred = defer<void>();
381+
382+
const callback = jest.fn((reason?: unknown) => {
383+
expect(reason).toBe(customReason);
384+
return callbackDeferred.promise;
385+
});
386+
387+
let result: PromiseSettledResult<string> | undefined;
388+
389+
execute<string>(signal, (resolve, reject) => {
390+
return callback;
391+
}).then(
392+
value => {
393+
result = {status: 'fulfilled', value};
394+
},
395+
reason => {
396+
result = {status: 'rejected', reason};
397+
},
398+
);
399+
400+
abortController.abort(customReason);
401+
402+
await nextTick();
403+
404+
expect(result).toBeUndefined();
405+
406+
callbackDeferred.resolve();
407+
408+
await nextTick();
409+
410+
expect(result).toMatchObject({
411+
status: 'rejected',
412+
reason: customReason,
413+
});
414+
expect(callback).toHaveBeenCalledTimes(1);
415+
});

src/execute.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ export function execute<T>(
1515
executor: (
1616
resolve: (value: T) => void,
1717
reject: (reason?: any) => void,
18-
) => () => void | PromiseLike<void>,
18+
) => (reason?: unknown) => void | PromiseLike<void>,
1919
): Promise<T> {
2020
return new Promise<T>((resolve, reject) => {
2121
if (signal.aborted) {
22-
reject(new AbortError());
22+
reject(signal.reason ?? new AbortError());
2323
return;
2424
}
2525

@@ -47,15 +47,15 @@ export function execute<T>(
4747
);
4848

4949
if (!finished) {
50-
const listener = () => {
51-
const callbackResult = callback();
50+
const abortListener = () => {
51+
const callbackResult = callback(signal.reason);
5252

5353
if (callbackResult == null) {
54-
reject(new AbortError());
54+
reject(signal.reason ?? new AbortError());
5555
} else {
5656
callbackResult.then(
5757
() => {
58-
reject(new AbortError());
58+
reject(signal.reason ?? new AbortError());
5959
},
6060
reason => {
6161
reject(reason);
@@ -66,10 +66,10 @@ export function execute<T>(
6666
finish();
6767
};
6868

69-
signal.addEventListener('abort', listener);
69+
signal.addEventListener('abort', abortListener);
7070

7171
removeAbortListener = () => {
72-
signal.removeEventListener('abort', listener);
72+
signal.removeEventListener('abort', abortListener);
7373
};
7474
}
7575
});

src/proactiveRetry.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {isAbortError, catchAbortError} from './AbortError';
1+
import {isAbortError, catchAbortError, AbortError} from './AbortError';
22
import {delay} from './delay';
33
import {execute} from './execute';
44

@@ -48,7 +48,9 @@ export function proactiveRetry<T>(
4848
const promises = new Map</* attempt */ number, Promise<T>>();
4949

5050
function handleFulfilled(value: T) {
51-
innerAbortController.abort();
51+
innerAbortController.abort(
52+
new AbortError('One of the proactiveRetry() attempts fulfilled', false),
53+
);
5254
promises.clear();
5355

5456
resolve(value);
@@ -71,7 +73,12 @@ export function proactiveRetry<T>(
7173
try {
7274
onError(err, attempt);
7375
} catch (err) {
74-
innerAbortController.abort();
76+
innerAbortController.abort(
77+
new AbortError(
78+
'Error was thrown from proactiveRetry() onError callback',
79+
false,
80+
),
81+
);
7582
promises.clear();
7683

7784
reject(err);
@@ -103,8 +110,8 @@ export function proactiveRetry<T>(
103110

104111
makeAttempts(innerAbortController.signal).catch(catchAbortError);
105112

106-
return () => {
107-
innerAbortController.abort();
113+
return reason => {
114+
innerAbortController.abort(reason ?? new AbortError());
108115
};
109116
});
110117
}

0 commit comments

Comments
 (0)