fix: add stall detection to resumable downloader

Axios stream timeout only covers initial connection, not the body.
Once streaming starts, a stalled server would hang indefinitely.

- Added stallTimeout param (default 60s) to DoResumableDownloadParams
- Reset a timer on every received chunk; abort if no data arrives within the window
- Treat stall errors as retriable network errors in doResumableDownloadWithRetry
  so partial downloads can resume from where they left off
This commit is contained in:
Mark 2026-03-15 18:00:55 -04:00
parent 8bb8b414f8
commit 191e2b8a83
2 changed files with 28 additions and 1 deletions

View File

@ -18,6 +18,7 @@ export async function doResumableDownload({
url,
filepath,
timeout = 30000,
stallTimeout = 60000,
signal,
onProgress,
onComplete,
@ -88,11 +89,30 @@ export async function doResumableDownload({
let lastProgressTime = Date.now()
let lastDownloadedBytes = startByte
// Stall detection: abort if no data received within stallTimeout ms
let lastReceivedBytes = startByte
let stallTimer = setTimeout(() => {
cleanup(new Error(`Download stalled: no data received for ${stallTimeout}ms from ${url}`))
}, stallTimeout)
const resetStallTimer = () => {
clearTimeout(stallTimer)
stallTimer = setTimeout(() => {
cleanup(new Error(`Download stalled: no data received for ${stallTimeout}ms from ${url}`))
}, stallTimeout)
}
// Progress tracking stream to monitor data flow
const progressStream = new Transform({
transform(chunk: Buffer, _: any, callback: Function) {
downloadedBytes += chunk.length
// Reset stall timer on every chunk received
if (downloadedBytes > lastReceivedBytes) {
lastReceivedBytes = downloadedBytes
resetStallTimer()
}
// Update progress tracking
const now = Date.now()
if (onProgress && now - lastProgressTime >= 500) {
@ -118,6 +138,7 @@ export async function doResumableDownload({
// Handle errors and cleanup
const cleanup = (error?: Error) => {
clearTimeout(stallTimer)
progressStream.destroy()
response.data.destroy()
writeStream.destroy()
@ -136,6 +157,7 @@ export async function doResumableDownload({
})
writeStream.on('finish', async () => {
clearTimeout(stallTimer)
if (onProgress) {
onProgress({
downloadedBytes,
@ -191,7 +213,10 @@ export async function doResumableDownloadWithRetry({
const isAborted = error.name === 'AbortError' || error.code === 'ABORT_ERR'
const isNetworkError =
error.code === 'ECONNRESET' || error.code === 'ENOTFOUND' || error.code === 'ETIMEDOUT'
error.code === 'ECONNRESET' ||
error.code === 'ENOTFOUND' ||
error.code === 'ETIMEDOUT' ||
(error.message as string)?.includes('stalled')
onAttemptError?.(error, attempt)
if (isAborted) {

View File

@ -7,6 +7,8 @@ export type DoResumableDownloadParams = {
onProgress?: (progress: DoResumableDownloadProgress) => void
onComplete?: (url: string, path: string) => void | Promise<void>
forceNew?: boolean
/** How long (ms) to wait without receiving data before aborting. Defaults to 60000 (60s). */
stallTimeout?: number
}
export type DoResumableDownloadWithRetryParams = DoResumableDownloadParams & {