Skip to content
Merged
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
11 changes: 9 additions & 2 deletions src/interceptors/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,15 @@ export function defaultResponseInterceptor(axios: AxiosCacheInstance): ResponseI
await axios.storage.remove(id, config);
}

// Rejects all other requests waiting for this response
replyDeferred(id, 'reject', error);
// Handle canceled requests differently from other errors
// Canceled requests should not propagate the error to waiting deduplicated requests
// Instead, resolve the deferred so waiting requests can make their own network call
if (error.code === 'ERR_CANCELED') {
replyDeferred(id, 'resolve');
} else {
// Rejects all other requests waiting for this response
replyDeferred(id, 'reject', error);
}

throw error;
}
Expand Down
180 changes: 180 additions & 0 deletions test/interceptors/abort-request.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import assert from 'node:assert';
import { describe, it } from 'node:test';
import { setTimeout } from 'node:timers/promises';
import Axios from 'axios';
import type { CacheAxiosResponse, InternalCacheRequestConfig } from '../../src/cache/axios.js';
import { setupCache } from '../../src/cache/create.js';

describe('Aborted Request Handling', () => {
it('Second request should succeed after first request is aborted', async () => {
const instance = Axios.create({});
const axios = setupCache(instance, {
interpretHeader: false
});

let requestCount = 0;

// Mock adapter that simulates a network request
axios.defaults.adapter = async (config: InternalCacheRequestConfig) => {
requestCount++;

// Simulate network delay
await setTimeout(100);

return {
data: { success: true },
status: 200,
statusText: 'OK',
headers: {},
config,
request: { config }
};
};

// Create an AbortController
const abortController = new AbortController();

// Start the first request with abort signal
const req1Promise = axios.get('https://test.com/products', {
signal: abortController.signal
});

// Abort the request after a short delay
setTimeout(10).then(() => {
abortController.abort();
});

// Wait for the abort to happen
let req1Error: any;
try {
await req1Promise;
} catch (error) {
req1Error = error;
}

// First request should be aborted
assert.ok(req1Error);
assert.equal(req1Error.code, 'ERR_CANCELED');

// Second request with same parameters should succeed
const req2 = await axios.get('https://test.com/products');

assert.equal(req2.data.success, true);
assert.equal(req2.status, 200);

// The second request should have made a network call since the first was aborted
assert.equal(requestCount, 2);
});

it('Second request made immediately after aborting should succeed (issue reproduction)', async () => {
const instance = Axios.create({});
const axios = setupCache(instance, {
interpretHeader: false,
debug: (_msg) => {
// Uncomment to see debug logs like in the issue
// console.log(JSON.stringify(msg, null, 2));
}
});

let requestCount = 0;

// Mock adapter that simulates a network request
axios.defaults.adapter = async (config: InternalCacheRequestConfig) => {
requestCount++;

// Simulate network delay
await setTimeout(50);

return {
data: { success: true, requestId: requestCount },
status: 200,
statusText: 'OK',
headers: {},
config,
request: { config }
};
};

// Reproduce the exact scenario from the issue
const abortController = new AbortController();
const req1 = axios.get('https://dummyjson.com/products', {
signal: abortController.signal
});

// After 10ms, abort the first request AND make the second request immediately
// This is the key part - both happen in the same setTimeout
const req2Promise = new Promise<CacheAxiosResponse>((resolve, reject) => {
setTimeout(10).then(() => {
abortController.abort();
const req2 = axios.get('https://dummyjson.com/products');
req2.then(resolve).catch(reject);
});
});

// First request should fail with abort error
await assert.rejects(req1, { code: 'ERR_CANCELED' });

// Second request should succeed (this is where the bug would show)
const req2 = await req2Promise;
assert.equal(req2.data.success, true);
assert.equal(req2.status, 200);
});

it('Multiple concurrent requests after aborted request should all succeed', async () => {
const instance = Axios.create({});
const axios = setupCache(instance, {
interpretHeader: false
});

let requestCount = 0;

// Mock adapter
axios.defaults.adapter = async (config: InternalCacheRequestConfig) => {
requestCount++;
await setTimeout(100);

return {
data: { success: true, count: requestCount },
status: 200,
statusText: 'OK',
headers: {},
config,
request: { config }
};
};

// First request with abort
const abortController = new AbortController();
const req1Promise = axios.get('https://test.com/api', {
signal: abortController.signal
});

setTimeout(10).then(() => {
abortController.abort();
});

await assert.rejects(req1Promise, { code: 'ERR_CANCELED' });

// Make multiple concurrent requests after abort
const [req2, req3, req4] = await Promise.all([
axios.get('https://test.com/api'),
axios.get('https://test.com/api'),
axios.get('https://test.com/api')
]);

// All should succeed
assert.equal(req2.data.success, true);
assert.equal(req3.data.success, true);
assert.equal(req4.data.success, true);

// First request was aborted (1 call)
// Second batch should be deduplicated (1 call)
// Total: 2 calls
assert.equal(requestCount, 2);

// The second and third should be cached from the second request
assert.equal(req2.cached, false);
assert.ok(req3.cached);
assert.ok(req4.cached);
});
});
18 changes: 8 additions & 10 deletions test/interceptors/response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,19 +324,17 @@ describe('Response Interceptor', () => {
assert.equal(error.code, 'ERR_CANCELED');
}

// p2 should also fail with the same cancellation error
// because it was waiting on the same deferred that was cancelled
try {
await promise;
assert.fail('should have thrown an error');
} catch (error: any) {
assert.equal(error.code, 'ERR_CANCELED');
}
// p2 should succeed by making its own network call
// This is the fix for the issue where canceled requests were incorrectly
// propagating errors to other waiting requests
const result = await promise;
assert.equal(result.cached, false); // Made a new network call
assert.ok(result.data);

const storage = await axios.storage.get(id);

// Cache should be empty since the request was cancelled
assert.equal(storage.state, 'empty');
// Cache should now be populated from the successful second request
assert.equal(storage.state, 'cached');
Comment on lines +327 to +337
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test name "Cancelled deferred propagates error to all waiting requests" is now misleading since the test validates that waiting requests should succeed (not receive the error). Consider updating the test name to reflect the new behavior, such as "Cancelled request does not propagate error to waiting deduplicated requests" or "Waiting requests succeed after first request is cancelled".

Copilot uses AI. Check for mistakes.
});

it('Response gets cached even if there is a pending request without deferred.', async () => {
Expand Down
Loading