Created
May 22, 2025 10:01
-
-
Save Rychu-Pawel/83402e46239f2db19890b323fb17dce9 to your computer and use it in GitHub Desktop.
Axios idle timeout with axios-retry and abort controller
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
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