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
Show file tree
Hide file tree
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
Expand Up @@ -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:

Expand All @@ -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);

Expand All @@ -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
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/templates/core/axios/sendRequest.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/templates/core/fetch/sendRequest.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Up @@ -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
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
};
Expand Down
17 changes: 17 additions & 0 deletions test/e2e/client.axios.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions test/e2e/client.babel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions test/e2e/client.fetch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions test/e2e/client.node.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions test/e2e/client.xhr.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions test/e2e/scripts/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down