Skip to content

Commit a0bffbf

Browse files
author
jarvisjiang
committed
fix: ensure abort reason is always wrapped as Error
Previously, when abort() was called with a non-Error reason or when an external signal aborted with a non-Error value, the raw value would be returned directly from unwrapErr(). This was inconsistent and could cause type safety issues. Now both the abort() method and catch block wrap non-Error abort reasons into proper Error objects with name=AbortError, preserving the original reason in error.cause.
1 parent 4d61242 commit a0bffbf

File tree

2 files changed

+98
-4
lines changed

2 files changed

+98
-4
lines changed

src/fetch/fetch.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,16 @@ export function fetchT<T>(url: string | URL, init?: FetchInit): FetchTask<T> | F
500500
} catch (err) {
501501
cancelTimer?.();
502502
removeAbortListener?.();
503-
return Err(err as Error);
503+
504+
if (err instanceof Error) {
505+
return Err(err);
506+
}
507+
508+
// Non-Error type, most likely an abort reason
509+
const error = new Error(typeof err === 'string' ? err : String(err));
510+
error.name = ABORT_ERROR;
511+
error.cause = err;
512+
return Err(error);
504513
}
505514
};
506515

@@ -563,7 +572,16 @@ export function fetchT<T>(url: string | URL, init?: FetchInit): FetchTask<T> | F
563572
return {
564573
// eslint-disable-next-line @typescript-eslint/no-explicit-any
565574
abort(reason?: any): void {
566-
masterController.abort(reason);
575+
if (reason instanceof Error) {
576+
masterController.abort(reason);
577+
} else if (reason != null) {
578+
const error = new Error(typeof reason === 'string' ? reason : String(reason));
579+
error.name = ABORT_ERROR;
580+
error.cause = reason;
581+
masterController.abort(error);
582+
} else {
583+
masterController.abort();
584+
}
567585
},
568586

569587
get aborted(): boolean {

tests/fetch.test.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,10 @@ describe('fetchT', () => {
422422
setTimeout(() => fetchTask.abort('custom-cancel'), 50);
423423

424424
const res = await fetchTask.response;
425-
expect(res.unwrapErr()).toBe('custom-cancel');
425+
const err = res.unwrapErr() as Error;
426+
expect(err.name).toBe(ABORT_ERROR);
427+
expect(err.message).toBe('custom-cancel');
428+
expect(err.cause).toBe('custom-cancel');
426429
});
427430

428431
it('should abort stream fetch with default reason', async () => {
@@ -449,7 +452,10 @@ describe('fetchT', () => {
449452

450453
const res = await fetchTask.response;
451454
expect(res.isErr()).toBe(true);
452-
expect(res.unwrapErr()).toBe('stream-cancelled');
455+
const err = res.unwrapErr() as Error;
456+
expect(err.name).toBe(ABORT_ERROR);
457+
expect(err.message).toBe('stream-cancelled');
458+
expect(err.cause).toBe('stream-cancelled');
453459
});
454460

455461
it('should return FetchTask when abortable is true', () => {
@@ -467,6 +473,76 @@ describe('fetchT', () => {
467473
fetchTask.abort();
468474
expect(fetchTask.aborted).toBe(true);
469475
});
476+
477+
it('should abort fetch with Error reason', async () => {
478+
const fetchTask = fetchT(`${ baseUrl }/api/slow`, {
479+
abortable: true,
480+
});
481+
482+
const customError = new Error('custom-error');
483+
customError.name = 'CustomError';
484+
setTimeout(() => fetchTask.abort(customError), 50);
485+
486+
const res = await fetchTask.response;
487+
const err = res.unwrapErr() as Error;
488+
expect(err).toBe(customError);
489+
expect(err.name).toBe('CustomError');
490+
expect(err.message).toBe('custom-error');
491+
});
492+
493+
it('should handle external signal abort with string reason', async () => {
494+
const externalController = new AbortController();
495+
496+
const response = fetchT(`${ baseUrl }/api/slow`, {
497+
signal: externalController.signal,
498+
responseType: 'text',
499+
});
500+
501+
// Abort with a string reason via external controller
502+
setTimeout(() => externalController.abort('external-abort-reason'), 50);
503+
504+
const res = await response;
505+
expect(res.isErr()).toBe(true);
506+
const err = res.unwrapErr() as Error;
507+
expect(err.name).toBe(ABORT_ERROR);
508+
expect(err.message).toBe('external-abort-reason');
509+
expect(err.cause).toBe('external-abort-reason');
510+
});
511+
512+
it('should handle external signal abort with non-string reason', async () => {
513+
const externalController = new AbortController();
514+
515+
const response = fetchT(`${ baseUrl }/api/slow`, {
516+
signal: externalController.signal,
517+
responseType: 'text',
518+
});
519+
520+
// Abort with a non-string reason (number) via external controller
521+
setTimeout(() => externalController.abort(42), 50);
522+
523+
const res = await response;
524+
expect(res.isErr()).toBe(true);
525+
const err = res.unwrapErr() as Error;
526+
expect(err.name).toBe(ABORT_ERROR);
527+
expect(err.message).toBe('42');
528+
expect(err.cause).toBe(42);
529+
});
530+
531+
it('should abort fetch with non-string reason', async () => {
532+
const fetchTask = fetchT(`${ baseUrl }/api/slow`, {
533+
abortable: true,
534+
});
535+
536+
// Abort with a non-string reason (object)
537+
const reason = { code: 'CANCELLED', id: 123 };
538+
setTimeout(() => fetchTask.abort(reason), 50);
539+
540+
const res = await fetchTask.response;
541+
const err = res.unwrapErr() as Error;
542+
expect(err.name).toBe(ABORT_ERROR);
543+
expect(err.message).toBe('[object Object]');
544+
expect(err.cause).toBe(reason);
545+
});
470546
});
471547

472548
// ============ Error Handling Tests ============

0 commit comments

Comments
 (0)