Skip to content

Instantly share code, notes, and snippets.

@Rychu-Pawel
Created May 22, 2025 10:01
Show Gist options
  • Save Rychu-Pawel/83402e46239f2db19890b323fb17dce9 to your computer and use it in GitHub Desktop.
Save Rychu-Pawel/83402e46239f2db19890b323fb17dce9 to your computer and use it in GitHub Desktop.
Axios idle timeout with axios-retry and abort controller
/*
This is a snippet of how I added an idle timeout to the Axios.
My use case includes having an external abort signal that should abort the whole request. I also want to have axios-retry configured.
*/
/* This is the idle manager class that includes most of the logic. The usage example is below. */
export type AxiosIdleTimeout = { mergedSignal: AbortSignal; idleTimeoutSignal: AbortSignal; resetIdleTimeout: () => void; stopIdleTimeout: () => void; }
export default class AxiosIdleTimeoutManager {
#uploadIdleTimeoutInMs: number;
#abortSignal: AbortSignal;
#currentTimeout: AxiosIdleTimeout | undefined;
constructor(uploadIdleTimeoutInMs: number, abortSignal: AbortSignal) {
this.#uploadIdleTimeoutInMs = uploadIdleTimeoutInMs;
this.#abortSignal = abortSignal;
}
get currentTimeout(): AxiosIdleTimeout | undefined {
return this.#currentTimeout;
}
configureNewIdleTimeout(): AxiosIdleTimeout {
if (this.#currentTimeout)
this.#currentTimeout.stopIdleTimeout();
const idleTimeoutController = new AbortController();
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const resetIdleTimeout = () => {
if (idleTimer)
clearTimeout(idleTimer);
if (this.#uploadIdleTimeoutInMs <= 0)
return;
idleTimer = setTimeout(() => {
idleTimeoutController.abort(new Error(`TIMEOUT: idle ${this.#uploadIdleTimeoutInMs}ms`));
}, this.#uploadIdleTimeoutInMs);
};
const combinedController = new AbortController();
const onAnyAbort = () => {
combinedController.abort();
stopIdleTimeout();
};
this.#abortSignal.addEventListener(`abort`, onAnyAbort);
idleTimeoutController.signal.addEventListener(`abort`, onAnyAbort);
const stopIdleTimeout = () => {
if (idleTimer)
clearTimeout(idleTimer);
this.#abortSignal.removeEventListener(`abort`, onAnyAbort);
idleTimeoutController.signal.removeEventListener(`abort`, onAnyAbort);
};
if (this.#abortSignal.aborted)
onAnyAbort();
this.#currentTimeout = {
mergedSignal: combinedController.signal,
idleTimeoutSignal: idleTimeoutController.signal,
resetIdleTimeout,
stopIdleTimeout,
};
return this.#currentTimeout;
}
}
/*
USAGE EXAMPLE
*/
async sendFileChunk(request: SendFileChunkRequest, chunkStreamProvider: ChunkStreamProvider, abortSignal: AbortSignal): Promise<void> {
// Create new instance of AxiosIdleTimeoutManager
const idleTimeoutManager = new AxiosIdleTimeoutManager(this.#uploadIdleTimeoutInMs, abortSignal);
try {
const md5checksum = await chunkStreamProvider.getMd5Checksum(`base64`);
const chunkBlob = await chunkStreamProvider.getChunkBlob();
// Configure new idle timeout
const idleTimeout = idleTimeoutManager.configureNewIdleTimeout();
const uploadProgressHandler = this.getDataTransferProgressHandler();
// Pass idle timeout manager to axios-retry so the timeout can be reconfigured for retried requests
this.configureRetryMechanism(chunkStreamProvider, idleTimeoutManager);
// Start the timeout
idleTimeoutManager.currentTimeout?.resetIdleTimeout();
await this.#axiosInstance.post(this.#uploadUrl, chunkBlob, {
maxBodyLength: Infinity,
maxContentLength: Infinity,
signal: idleTimeout.mergedSignal,
onUploadProgress: progressEvent => {
// Reset the timeout on every progress event
idleTimeoutManager.currentTimeout?.resetIdleTimeout();
uploadProgressHandler(progressEvent);
},
params: {
chunkpos: request.chunkStartPosition,
chunksize: request.chunkLengthInBytes,
md5: md5checksum,
},
});
}
catch (error) {
throw this.createApiError(error, `Error sending file chunk`, idleTimeoutManager.currentTimeout?.idleTimeoutSignal);
}
finally {
// Stop the timeout
idleTimeoutManager.currentTimeout?.stopIdleTimeout();
}
}
/* eslint-disable no-param-reassign */
private configureRetryMechanism(chunkStreamProvider: ChunkStreamProvider, idleTimeoutManager: AxiosIdleTimeoutManager) {
axiosRetry(this.#axiosInstance, {
retries: this.#retryLimit,
retryDelay: retryCount => retryCount * 1000,
retryCondition: e => {
// Make sure we retry on idle timeouts too
const isIdleTimeouted = e.code === `ERR_CANCELED` && Boolean(idleTimeoutManager.currentTimeout?.idleTimeoutSignal.aborted);
return isNetworkOrIdempotentRequestError(e) || isIdleTimeouted;
},
onRetry: async (_retryCount, _error, requestConfig) => {
// Configure new idle timeout for the new request
const newIdleTimeout = idleTimeoutManager.configureNewIdleTimeout();
// Assign new idle timeout signal to the new request
requestConfig.signal = newIdleTimeout.mergedSignal;
requestConfig.data = await chunkStreamProvider.getChunkBlob();
},
});
}
/*
UNIT TESTS FOR THE AxiosIdleTimeoutManager
*/
import test from "ava";
import sleep from "abortable-sleep";
const uploadIdleTimeoutInMs = 400;
test.serial(`Configures new timeout with correct values`, async t => {
// Arrange
const abortSignal = new AbortController().signal;
const sut = new AxiosIdleTimeoutManager(uploadIdleTimeoutInMs, abortSignal);
// Act
const idleTimeout = sut.configureNewIdleTimeout();
// Assert
t.is(idleTimeout, sut.currentTimeout!);
t.is(idleTimeout.idleTimeoutSignal.aborted, false);
t.is(idleTimeout.mergedSignal.aborted, false);
// Cleanup
idleTimeout.stopIdleTimeout();
});
test.serial(`Configures new timeout as aborted when abort controller is aborted`, async t => {
// Arrange
const controller = new AbortController();
// Act
controller.abort();
const abortSignal = controller.signal;
const sut = new AxiosIdleTimeoutManager(uploadIdleTimeoutInMs, abortSignal);
const idleTimeout = sut.configureNewIdleTimeout();
// Assert
t.is(idleTimeout.mergedSignal.aborted, true);
// Cleanup
idleTimeout.stopIdleTimeout();
});
test.serial(`Reassignes new idle timeout to current timeout`, async t => {
// Arrange
const abortSignal = new AbortController().signal;
const sut = new AxiosIdleTimeoutManager(uploadIdleTimeoutInMs, abortSignal);
// Act
const idleTimeout1 = sut.configureNewIdleTimeout();
const idleTimeout2 = sut.configureNewIdleTimeout();
// Assert
t.not(idleTimeout1, idleTimeout2);
t.is(idleTimeout2, sut.currentTimeout!);
// Cleanup
sut.currentTimeout?.stopIdleTimeout();
});
test.serial(`Idle timeout timeouts only after given timeout time`, async t => {
// Arrange
const abortSignal = new AbortController().signal;
const sut = new AxiosIdleTimeoutManager(uploadIdleTimeoutInMs, abortSignal);
// Act
const idleTimeout = sut.configureNewIdleTimeout();
idleTimeout.resetIdleTimeout();
await sleep(uploadIdleTimeoutInMs - 150);
const isMergedSignalAbortedBeforeTimeout = idleTimeout.mergedSignal.aborted;
const isTimeoutSignalAbortedBeforeTimeout = idleTimeout.idleTimeoutSignal.aborted;
await sleep(150 + 250);
const isMergedSignalAbortedAfterTimeout = idleTimeout.mergedSignal.aborted;
const isTimeoutSignalAbortedAfterTimeout = idleTimeout.idleTimeoutSignal.aborted;
// Assert
t.false(isMergedSignalAbortedBeforeTimeout);
t.false(isTimeoutSignalAbortedBeforeTimeout);
t.true(isMergedSignalAbortedAfterTimeout);
t.true(isTimeoutSignalAbortedAfterTimeout);
// Cleanup
sut.currentTimeout?.stopIdleTimeout();
});
test.serial(`Reset timeout resets the timeout`, async t => {
// Arrange
const abortSignal = new AbortController().signal;
const sut = new AxiosIdleTimeoutManager(uploadIdleTimeoutInMs, abortSignal);
// Act
const idleTimeout = sut.configureNewIdleTimeout();
await sleep(uploadIdleTimeoutInMs - 150);
idleTimeout.resetIdleTimeout();
const isMergedSignalAbortedBeforeReset1 = idleTimeout.mergedSignal.aborted;
const isTimeoutSignalAbortedBeforeReset1 = idleTimeout.idleTimeoutSignal.aborted;
await sleep(uploadIdleTimeoutInMs - 150);
idleTimeout.resetIdleTimeout();
const isMergedSignalAbortedBeforeReset2 = idleTimeout.mergedSignal.aborted;
const isTimeoutSignalAbortedBeforeReset2 = idleTimeout.idleTimeoutSignal.aborted;
await sleep(uploadIdleTimeoutInMs - 150);
const isMergedSignalAbortedBeforeTimeout = idleTimeout.mergedSignal.aborted;
const isTimeoutSignalAbortedBeforeTimeout = idleTimeout.idleTimeoutSignal.aborted;
await sleep(150 + 150);
const isMergedSignalAbortedAfterTimeout = idleTimeout.mergedSignal.aborted;
const isTimeoutSignalAbortedAfterTimeout = idleTimeout.idleTimeoutSignal.aborted;
// Assert
t.false(isMergedSignalAbortedBeforeReset1);
t.false(isTimeoutSignalAbortedBeforeReset1);
t.false(isMergedSignalAbortedBeforeReset2);
t.false(isTimeoutSignalAbortedBeforeReset2);
t.false(isMergedSignalAbortedBeforeTimeout);
t.false(isTimeoutSignalAbortedBeforeTimeout);
t.true(isMergedSignalAbortedAfterTimeout);
t.true(isTimeoutSignalAbortedAfterTimeout);
// Cleanup
sut.currentTimeout?.stopIdleTimeout();
});
test.serial(`Stop timeout stops the timeout`, async t => {
// Arrange
const abortSignal = new AbortController().signal;
const sut = new AxiosIdleTimeoutManager(uploadIdleTimeoutInMs, abortSignal);
// Act
const idleTimeout = sut.configureNewIdleTimeout();
await sleep(uploadIdleTimeoutInMs - 150);
const isMergedSignalAbortedBeforeTimeout = idleTimeout.mergedSignal.aborted;
const isTimeoutSignalAbortedBeforeTimeout = idleTimeout.idleTimeoutSignal.aborted;
await sleep(150 + 150);
const isMergedSignalAbortedAfterTimeout = idleTimeout.mergedSignal.aborted;
const isTimeoutSignalAbortedAfterTimeout = idleTimeout.idleTimeoutSignal.aborted;
// Assert
t.false(isMergedSignalAbortedBeforeTimeout);
t.false(isTimeoutSignalAbortedBeforeTimeout);
t.false(isMergedSignalAbortedAfterTimeout);
t.false(isTimeoutSignalAbortedAfterTimeout);
// Cleanup
sut.currentTimeout?.stopIdleTimeout();
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment