Request cancellation with AbortController
The Aurelia Fetch Client fully supports the native AbortController API for cancelling HTTP requests. Understanding how to properly use AbortController is crucial for building responsive applications and managing resource cleanup.
Basic Request Cancellation
Simple Abort Example
import { IHttpClient } from '@aurelia/fetch-client';
import { resolve } from '@aurelia/kernel';
export class CancellableRequestService {
private http = resolve(IHttpClient);
async fetchDataWithCancellation(): Promise<{ data: any; abort: () => void }> {
const controller = new AbortController();
const dataPromise = this.http.get('/api/large-dataset', {
signal: controller.signal
}).then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
});
return {
data: dataPromise,
abort: () => controller.abort()
};
}
// Usage example
async loadDataWithTimeout() {
const { data, abort } = await this.fetchDataWithCancellation();
// Auto-cancel after 10 seconds
const timeoutId = setTimeout(() => {
abort();
console.log('Request cancelled due to timeout');
}, 10000);
try {
const result = await data;
clearTimeout(timeoutId);
return result;
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
console.log('Request was cancelled');
return null;
}
throw error;
}
}
}
Component Integration
export class SearchComponent {
private http = resolve(IHttpClient);
private currentSearchController: AbortController | null = null;
async search(query: string): Promise<any[]> {
// Cancel previous search if still running
if (this.currentSearchController) {
this.currentSearchController.abort();
}
// Create new controller for this search
this.currentSearchController = new AbortController();
try {
const response = await this.http.get(`/api/search?q=${encodeURIComponent(query)}`, {
signal: this.currentSearchController.signal
});
if (!response.ok) {
throw new Error(`Search failed: ${response.statusText}`);
}
const results = await response.json();
this.currentSearchController = null; // Clear completed request
return results;
} catch (error) {
if (error.name === 'AbortError') {
console.log('Search cancelled');
return [];
}
this.currentSearchController = null;
throw error;
}
}
// Clean up on component destruction
dispose() {
if (this.currentSearchController) {
this.currentSearchController.abort();
this.currentSearchController = null;
}
}
}
AbortController with Interceptors
Interceptor Handling
Interceptors properly handle aborted requests through the error chain:
export class AbortAwareInterceptorService {
private http = resolve(IHttpClient);
constructor() {
this.setupAbortHandling();
}
private setupAbortHandling() {
this.http.configure(config => config.withInterceptor({
request(request) {
console.log(`Starting request: ${request.method} ${request.url}`);
// Check if request is already aborted
if (request.signal?.aborted) {
console.log('Request already aborted before sending');
throw new DOMException('Request was aborted', 'AbortError');
}
return request;
},
response(response, request) {
console.log(`Request completed: ${request?.url} -> ${response.status}`);
return response;
},
responseError(error, request) {
if (error.name === 'AbortError') {
console.log(`Request cancelled: ${request?.url}`);
// You can return a default response to recover from cancellation
// return new Response('{"cancelled": true}', { status: 499 });
// Or let the error propagate (recommended)
throw error;
}
console.error(`Request failed: ${request?.url}`, error);
throw error;
}
}));
}
}
Critical Issue: AbortController + Retry Bug
⚠️ Important Limitation: There is a critical bug in the current retry mechanism when used with AbortController. When a request with an AbortSignal is retried, the retry attempts inherit the same (potentially aborted) signal, causing all retry attempts to immediately fail.
The Problem
// ❌ This is broken in the current implementation:
const controller = new AbortController();
http.configure(config => config.withRetry({ maxRetries: 3 }));
const promise = http.get('/api/data', { signal: controller.signal });
// If you abort here, all 3 retry attempts will immediately fail
// because they inherit the aborted signal
controller.abort();
Workaround Solutions
Solution 1: Conditional Retry (Recommended)
export class AbortSafeRetryService {
private http = resolve(IHttpClient);
constructor() {
this.setupAbortSafeRetry();
}
private setupAbortSafeRetry() {
this.http.configure(config => config.withRetry({
maxRetries: 3,
strategy: RetryStrategy.exponential,
// Don't retry aborted requests
doRetry: (response, request) => {
// Check if the request was aborted
if (request.signal?.aborted) {
console.log('Skipping retry for aborted request');
return false;
}
// Only retry server errors
return response.status >= 500;
}
}));
}
}
Solution 2: Manual Retry with Fresh AbortController
export class ManualRetryService {
private http = resolve(IHttpClient);
async fetchWithRetry<T>(
url: string,
options: RequestInit = {},
maxRetries = 3
): Promise<T> {
let lastError: Error;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
// Create fresh AbortController for each attempt
const controller = new AbortController();
try {
const response = await this.http.get(url, {
...options,
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
lastError = error as Error;
// Don't retry aborted requests
if (error.name === 'AbortError') {
throw error;
}
// Don't retry client errors
if (error.message.includes('HTTP 4')) {
throw error;
}
if (attempt < maxRetries) {
// Wait before retry (exponential backoff)
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw new Error(`Request failed after ${maxRetries} attempts: ${lastError!.message}`);
}
}
Advanced Cancellation Patterns
Timeout with Custom Error Messages
export class TimeoutService {
private http = resolve(IHttpClient);
async fetchWithTimeout<T>(
url: string,
timeoutMs: number,
options: RequestInit = {}
): Promise<T> {
const controller = new AbortController();
// Set up timeout
const timeoutId = setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
const response = await this.http.get(url, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error(`Request timeout after ${timeoutMs}ms: ${url}`);
}
throw error;
}
}
}
Race Conditions and Multiple Requests
export class RaceConditionService {
private http = resolve(IHttpClient);
private activeControllers = new Map<string, AbortController>();
async fetchExclusive(key: string, url: string): Promise<any> {
// Cancel any existing request with this key
const existingController = this.activeControllers.get(key);
if (existingController) {
existingController.abort();
}
// Create new controller for this request
const controller = new AbortController();
this.activeControllers.set(key, controller);
try {
const response = await this.http.get(url, {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Clean up successful request
this.activeControllers.delete(key);
return data;
} catch (error) {
// Clean up failed/cancelled request
this.activeControllers.delete(key);
if (error.name === 'AbortError') {
console.log(`Request cancelled for key: ${key}`);
return null;
}
throw error;
}
}
// Cancel all active requests
cancelAll() {
for (const [key, controller] of this.activeControllers.entries()) {
controller.abort();
}
this.activeControllers.clear();
}
// Cancel specific request
cancel(key: string) {
const controller = this.activeControllers.get(key);
if (controller) {
controller.abort();
this.activeControllers.delete(key);
}
}
}
File Upload Cancellation
export class CancellableUploadService {
private http = resolve(IHttpClient);
async uploadFileWithCancellation(
file: File,
onProgress?: (percentage: number) => void
): Promise<{ result: Promise<any>; cancel: () => void }> {
const controller = new AbortController();
const formData = new FormData();
formData.append('file', file);
// For upload progress, we need to use XMLHttpRequest
const uploadPromise = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
// Handle abort signal
controller.signal.addEventListener('abort', () => {
xhr.abort();
});
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable && onProgress) {
const percentage = Math.round((event.loaded / event.total) * 100);
onProgress(percentage);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const result = JSON.parse(xhr.responseText);
resolve(result);
} catch (error) {
resolve(xhr.responseText);
}
} else {
reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
}
});
xhr.addEventListener('error', () => {
reject(new Error('Upload failed due to network error'));
});
xhr.addEventListener('abort', () => {
reject(new DOMException('Upload cancelled', 'AbortError'));
});
xhr.open('POST', '/api/files/upload');
xhr.send(formData);
});
return {
result: uploadPromise,
cancel: () => controller.abort()
};
}
// Usage example
async uploadWithUserCancellation(file: File) {
const { result, cancel } = await this.uploadFileWithCancellation(
file,
(percentage) => {
console.log(`Upload progress: ${percentage}%`);
// Show cancel button to user
this.showCancelButton(() => {
cancel();
console.log('Upload cancelled by user');
});
}
);
try {
const uploadResult = await result;
this.hideCancelButton();
return uploadResult;
} catch (error) {
this.hideCancelButton();
if (error.name === 'AbortError') {
console.log('Upload was cancelled');
return null;
}
throw error;
}
}
private showCancelButton(onCancel: () => void) {
// Implementation depends on your UI framework
}
private hideCancelButton() {
// Implementation depends on your UI framework
}
}
Best Practices
Error Handling
Always check for AbortError specifically:
try {
const response = await http.get('/api/data', { signal: controller.signal });
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
// Handle cancellation (usually not an error)
console.log('Request was cancelled');
return null; // or some default value
}
// Handle actual errors
console.error('Request failed:', error);
throw error;
}
Resource Cleanup
Always clean up AbortControllers when components are destroyed:
export class ComponentWithRequests {
private activeControllers: AbortController[] = [];
async makeRequest(url: string) {
const controller = new AbortController();
this.activeControllers.push(controller);
try {
const response = await this.http.get(url, { signal: controller.signal });
return await response.json();
} finally {
// Remove from active list when done
const index = this.activeControllers.indexOf(controller);
if (index > -1) {
this.activeControllers.splice(index, 1);
}
}
}
// Call this when component is destroyed
dispose() {
// Cancel all active requests
this.activeControllers.forEach(controller => controller.abort());
this.activeControllers.length = 0;
}
}
Avoid Common Pitfalls
Don't reuse AbortControllers: Create a new one for each request
Handle AbortError gracefully: It's usually not an actual error condition
Clean up timeouts: Always clear timeout IDs when requests complete
Be aware of the retry bug: Use workarounds when combining AbortController with retries
Integration with Aurelia Lifecycle
import { IDisposable } from '@aurelia/kernel';
export class AutoCleanupService implements IDisposable {
private http = resolve(IHttpClient);
private activeRequests = new Set<AbortController>();
async makeRequest(url: string): Promise<any> {
const controller = new AbortController();
this.activeRequests.add(controller);
try {
const response = await this.http.get(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Request failed:', error);
}
throw error;
} finally {
this.activeRequests.delete(controller);
}
}
// Aurelia will call this automatically when the service is disposed
dispose(): void {
console.log(`Cancelling ${this.activeRequests.size} active requests`);
for (const controller of this.activeRequests) {
controller.abort();
}
this.activeRequests.clear();
}
}
Understanding these patterns and limitations will help you build robust, cancellable HTTP operations in your Aurelia applications while avoiding the current retry mechanism bug.
Last updated
Was this helpful?