Skip to content

feat: Add reason parameter support to CancelablePromise.cancel() #1850

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions docs/canceling-requests.md
Original file line number Diff line number Diff line change
@@ -2,7 +2,9 @@

The generated clients support canceling of requests, this works by canceling the promise that
is returned from the request. Each method inside a service (operation) returns a `CancelablePromise`
object. This promise can be canceled by calling the `cancel()` method.
object. This promise can be canceled by calling the `cancel()` method. This method takes an optional `reason` parameter
which can help e.g. differentiate timeouts from repeated request aborts. The promise will then be rejected with this
reason.

Below is an example of canceling the request after a certain timeout:

@@ -17,6 +19,9 @@ const getAllUsers = async () => {
if (!request.isResolved() && !request.isRejected()) {
console.warn('Canceling request due to timeout');
request.cancel();

// Or providing your custom error:
// request.cancel(new MyTimeoutError());
}
}, 1000);

@@ -32,11 +37,12 @@ interface CancelablePromise<TResult> extends Promise<TResult> {
readonly isResolved: boolean;
readonly isRejected: boolean;
readonly isCancelled: boolean;
cancel: () => void;
cancel: (reason?: any) => void;
}
```

- `isResolved`: Indicates if the promise was resolved.
- `isRejected`: Indicates if the promise was rejected.
- `isCancelled`: Indicates if the promise was canceled.
- `cancel()`: Cancels the promise (and request) and throws a rejection error: `Request aborted`.
- `cancel()`: Cancels the promise (and request) and throws either the specified reason or a general error:
`Request aborted`.
12 changes: 6 additions & 6 deletions src/templates/core/CancelablePromise.hbs
Original file line number Diff line number Diff line change
@@ -17,14 +17,14 @@ export interface OnCancel {
readonly isRejected: boolean;
readonly isCancelled: boolean;

(cancelHandler: () => void): void;
(cancelHandler: (reason?: any) => void): void;
}

export class CancelablePromise<T> implements Promise<T> {
#isResolved: boolean;
#isRejected: boolean;
#isCancelled: boolean;
readonly #cancelHandlers: (() => void)[];
readonly #cancelHandlers: ((reason?: any) => void)[];
readonly #promise: Promise<T>;
#resolve?: (value: T | PromiseLike<T>) => void;
#reject?: (reason?: any) => void;
@@ -60,7 +60,7 @@ export class CancelablePromise<T> implements Promise<T> {
if (this.#reject) this.#reject(reason);
};

const onCancel = (cancelHandler: () => void): void => {
const onCancel = (cancelHandler: (reason?: any) => void): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
@@ -104,23 +104,23 @@ export class CancelablePromise<T> implements Promise<T> {
return this.#promise.finally(onFinally);
}

public cancel(): void {
public cancel(reason?: any): void {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isCancelled = true;
if (this.#cancelHandlers.length) {
try {
for (const cancelHandler of this.#cancelHandlers) {
cancelHandler();
cancelHandler(reason);
}
} catch (error) {
console.warn('Cancellation threw an error', error);
return;
}
}
this.#cancelHandlers.length = 0;
if (this.#reject) this.#reject(new CancelError('Request aborted'));
if (this.#reject) this.#reject(reason || new CancelError('Request aborted'));
}

public get isCancelled(): boolean {
2 changes: 1 addition & 1 deletion src/templates/core/axios/sendRequest.hbs
Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@ export const sendRequest = async <T>(
cancelToken: source.token,
};

onCancel(() => source.cancel('The user aborted a request.'));
onCancel((reason) => source.cancel(reason?.toString?.() ?? 'The user aborted a request.'));

try {
return await axiosClient.request(requestConfig);
2 changes: 1 addition & 1 deletion src/templates/core/fetch/sendRequest.hbs
Original file line number Diff line number Diff line change
@@ -20,7 +20,7 @@ export const sendRequest = async (
request.credentials = config.CREDENTIALS;
}

onCancel(() => controller.abort());
onCancel((reason) => controller.abort(reason));

return await fetch(url, request);
};
2 changes: 1 addition & 1 deletion src/templates/core/node/sendRequest.hbs
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@ export const sendRequest = async (
signal: controller.signal as AbortSignal,
};

onCancel(() => controller.abort());
onCancel((reason) => controller.abort(reason));

return await fetch(url, request);
};
28 changes: 14 additions & 14 deletions test/__snapshots__/index.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -87,14 +87,14 @@ export interface OnCancel {
readonly isRejected: boolean;
readonly isCancelled: boolean;

(cancelHandler: () => void): void;
(cancelHandler: (reason?: any) => void): void;
}

export class CancelablePromise<T> implements Promise<T> {
#isResolved: boolean;
#isRejected: boolean;
#isCancelled: boolean;
readonly #cancelHandlers: (() => void)[];
readonly #cancelHandlers: ((reason?: any) => void)[];
readonly #promise: Promise<T>;
#resolve?: (value: T | PromiseLike<T>) => void;
#reject?: (reason?: any) => void;
@@ -130,7 +130,7 @@ export class CancelablePromise<T> implements Promise<T> {
if (this.#reject) this.#reject(reason);
};

const onCancel = (cancelHandler: () => void): void => {
const onCancel = (cancelHandler: (reason?: any) => void): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
@@ -174,23 +174,23 @@ export class CancelablePromise<T> implements Promise<T> {
return this.#promise.finally(onFinally);
}

public cancel(): void {
public cancel(reason?: any): void {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isCancelled = true;
if (this.#cancelHandlers.length) {
try {
for (const cancelHandler of this.#cancelHandlers) {
cancelHandler();
cancelHandler(reason);
}
} catch (error) {
console.warn('Cancellation threw an error', error);
return;
}
}
this.#cancelHandlers.length = 0;
if (this.#reject) this.#reject(new CancelError('Request aborted'));
if (this.#reject) this.#reject(reason ?? new CancelError('Request aborted'));
}

public get isCancelled(): boolean {
@@ -451,7 +451,7 @@ export const sendRequest = async (
request.credentials = config.CREDENTIALS;
}

onCancel(() => controller.abort());
onCancel((reason) => controller.abort(reason));

return await fetch(url, request);
};
@@ -3180,14 +3180,14 @@ export interface OnCancel {
readonly isRejected: boolean;
readonly isCancelled: boolean;

(cancelHandler: () => void): void;
(cancelHandler: (reason?: any) => void): void;
}

export class CancelablePromise<T> implements Promise<T> {
#isResolved: boolean;
#isRejected: boolean;
#isCancelled: boolean;
readonly #cancelHandlers: (() => void)[];
readonly #cancelHandlers: ((reason?: any) => void)[];
readonly #promise: Promise<T>;
#resolve?: (value: T | PromiseLike<T>) => void;
#reject?: (reason?: any) => void;
@@ -3223,7 +3223,7 @@ export class CancelablePromise<T> implements Promise<T> {
if (this.#reject) this.#reject(reason);
};

const onCancel = (cancelHandler: () => void): void => {
const onCancel = (cancelHandler: (reason?: any) => void): void => {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
@@ -3267,23 +3267,23 @@ export class CancelablePromise<T> implements Promise<T> {
return this.#promise.finally(onFinally);
}

public cancel(): void {
public cancel(reason?: any): void {
if (this.#isResolved || this.#isRejected || this.#isCancelled) {
return;
}
this.#isCancelled = true;
if (this.#cancelHandlers.length) {
try {
for (const cancelHandler of this.#cancelHandlers) {
cancelHandler();
cancelHandler(reason);
}
} catch (error) {
console.warn('Cancellation threw an error', error);
return;
}
}
this.#cancelHandlers.length = 0;
if (this.#reject) this.#reject(new CancelError('Request aborted'));
if (this.#reject) this.#reject(reason ?? new CancelError('Request aborted'));
}

public get isCancelled(): boolean {
@@ -3544,7 +3544,7 @@ export const sendRequest = async (
request.credentials = config.CREDENTIALS;
}

onCancel(() => controller.abort());
onCancel((reason) => controller.abort(reason));

return await fetch(url, request);
};
17 changes: 17 additions & 0 deletions test/e2e/client.axios.spec.ts
Original file line number Diff line number Diff line change
@@ -84,6 +84,23 @@ describe('client.axios', () => {
expect(error).toContain('Request aborted');
});

it('can abort the request with custom reason', async () => {
const reason = 'Timed out!';
let error;
try {
const { ApiClient } = require('./generated/client/axios/index.js');
const client = new ApiClient();
const promise = client.simple.getCallWithoutParametersAndResponse();
setTimeout(() => {
promise.cancel(new Error(reason));
}, 10);
await promise;
} catch (e) {
error = (e as Error).message;
}
expect(error).toContain(reason);
});

it('should throw known error (500)', async () => {
let error;
try {
19 changes: 19 additions & 0 deletions test/e2e/client.babel.spec.ts
Original file line number Diff line number Diff line change
@@ -101,6 +101,25 @@ describe('client.babel', () => {
expect(error).toContain('Request aborted');
});

it('can abort the request with custom error', async () => {
const reason = 'Timed out!';
let error;
try {
await browser.evaluate(async errMsg => {
const { ApiClient } = (window as any).api;
const client = new ApiClient();
const promise = client.simple.getCallWithoutParametersAndResponse();
setTimeout(() => {
promise.cancel(new Error(errMsg));
}, 10);
await promise;
}, reason);
} catch (e) {
error = (e as Error).message;
}
expect(error).toContain(reason);
});

it('should throw known error (500)', async () => {
const error = await browser.evaluate(async () => {
try {
19 changes: 19 additions & 0 deletions test/e2e/client.fetch.spec.ts
Original file line number Diff line number Diff line change
@@ -99,6 +99,25 @@ describe('client.fetch', () => {
expect(error).toContain('Request aborted');
});

it('can abort the request with custom reason', async () => {
const reason = 'Timeout!';
let error;
try {
await browser.evaluate(async errMsg => {
const { ApiClient } = (window as any).api;
const client = new ApiClient();
const promise = client.simple.getCallWithoutParametersAndResponse();
setTimeout(() => {
promise.cancel(new Error(errMsg));
}, 10);
await promise;
}, reason);
} catch (e) {
error = (e as Error).message;
}
expect(error).toContain(reason);
});

it('should throw known error (500)', async () => {
const error = await browser.evaluate(async () => {
try {
17 changes: 17 additions & 0 deletions test/e2e/client.node.spec.ts
Original file line number Diff line number Diff line change
@@ -84,6 +84,23 @@ describe('client.node', () => {
expect(error).toContain('Request aborted');
});

it('can abort the request with custom reason', async () => {
const reason = 'Timed out!';
let error;
try {
const { ApiClient } = require('./generated/client/node/index.js');
const client = new ApiClient();
const promise = client.simple.getCallWithoutParametersAndResponse();
setTimeout(() => {
promise.cancel(new Error(reason));
}, 10);
await promise;
} catch (e) {
error = (e as Error).message;
}
expect(error).toContain(reason);
});

it('should throw known error (500)', async () => {
let error;
try {
19 changes: 19 additions & 0 deletions test/e2e/client.xhr.spec.ts
Original file line number Diff line number Diff line change
@@ -99,6 +99,25 @@ describe('client.xhr', () => {
expect(error).toContain('Request aborted');
});

it('can abort the request with custom reason', async () => {
const reason = 'Timed out!';
let error;
try {
await browser.evaluate(async errMsg => {
const { ApiClient } = (window as any).api;
const client = new ApiClient();
const promise = client.simple.getCallWithoutParametersAndResponse();
setTimeout(() => {
promise.cancel(new Error(errMsg));
}, 10);
await promise;
}, reason);
} catch (e) {
error = (e as Error).message;
}
expect(error).toContain(reason);
});

it('should throw known error (500)', async () => {
const error = await browser.evaluate(async () => {
try {
4 changes: 2 additions & 2 deletions test/e2e/scripts/browser.ts
Original file line number Diff line number Diff line change
@@ -23,8 +23,8 @@ const stop = async () => {
await _browser.close();
};

const evaluate = async (fn: EvaluateFn) => {
return await _page.evaluate(fn);
const evaluate = async (fn: EvaluateFn, ...args: any[]) => {
return await _page.evaluate(fn, ...args);
};

// eslint-disable-next-line @typescript-eslint/ban-types