Skip to main content

Request Cancellation Architecture

The frontend client in this project implements a robust request cancellation architecture to manage asynchronous operations, particularly network requests. This system ensures that when a component unmounts or a user navigates away, pending requests can be cleanly aborted, preventing memory leaks and "state update on unmounted component" warnings.

The CancelablePromise Wrapper

At the heart of this architecture is the CancelablePromise class located in frontend/src/client/core/CancelablePromise.ts. Since standard JavaScript Promises do not support cancellation natively, this project wraps the standard Promise to add state tracking and a cancellation hook.

The class maintains internal state flags to ensure that a promise can only transition to a final state (resolved, rejected, or cancelled) once:

class CancelablePromise<T> implements Promise<T> {
private _isResolved: boolean;
private _isRejected: boolean;
private _isCancelled: boolean;
readonly cancelHandlers: (() => void)[];
readonly promise: Promise<T>;
// ...
}

When cancel() is called on an instance, it iterates through a list of cancelHandlers—functions registered by the asynchronous operation to perform cleanup—and then rejects the promise with a specific CancelError.

The OnCancel Bridge

To allow the asynchronous logic inside the promise (the "executor") to respond to a cancellation request, the CancelablePromise provides an onCancel function to the executor. This is defined by the OnCancel interface:

interface OnCancel {
readonly isResolved: boolean;
readonly isRejected: boolean;
readonly isCancelled: boolean;

(cancelHandler: () => void): void;
}

The onCancel object serves two purposes:

  1. Registration: It allows the executor to register a cleanup function (e.g., onCancel(() => socket.close())).
  2. Inspection: It exposes the current state of the promise via properties like isCancelled, allowing the executor to skip expensive operations if the request is already aborted.

Integration with Axios and AbortController

The primary use case for this architecture is in frontend/src/client/core/request.ts, where the project integrates with the Axios library. The sendRequest function uses the native AbortController to signal Axios to stop a request:

export const sendRequest = async <T>(
// ... params
onCancel: OnCancel,
axiosClient: AxiosInstance
): Promise<AxiosResponse<T>> => {
const controller = new AbortController();

let requestConfig: AxiosRequestConfig = {
// ... config
signal: controller.signal,
};

// Register the abort signal with the CancelablePromise
onCancel(() => controller.abort());

return await axiosClient.request(requestConfig);
};

In the main request function, the code checks onCancel.isCancelled before even starting the network call, ensuring that if a cancellation happened during header resolution or interceptor execution, the request never hits the wire.

SDK and Service Layer Usage

The generated SDK (found in frontend/src/client/sdk.gen.ts) leverages this architecture by returning CancelablePromise from every service method. This provides a consistent API for developers using the client:

// Example of a generated service call
public static getItems(): CancelablePromise<Array<ItemPublic>> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/items/',
});
}

Design Tradeoffs and Constraints

Custom Implementation vs. Native AbortController

While modern browsers support AbortController directly, this project uses CancelablePromise to provide a more ergonomic API for the SDK. Instead of requiring developers to manage AbortController instances and pass signals manually through multiple layers of service calls, the cancellation capability is baked into the returned object itself.

State Guarding

The implementation of CancelablePromise includes strict guards in its onResolve and onReject handlers. If a promise is cancelled, any subsequent resolution or rejection from the underlying async operation is ignored. This prevents race conditions where a request might finish just as it is being aborted, ensuring the application only reacts to the CancelError.

Error Handling

When a request is cancelled, it throws a CancelError. Developers must be aware of this when wrapping service calls in try/catch blocks, as they may need to distinguish between a legitimate network failure and an intentional cancellation. The CancelError class includes an isCancelled property to facilitate this check.