Skip to content
Merged
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
40 changes: 36 additions & 4 deletions src/utils/fetch-with-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,27 @@ function headersToObject(headers: Headers): Record<string, string> {
}

/**
* Creates an AbortSignal that times out after the specified duration
* Creates an AbortSignal that aborts after timeoutMs. Returns the signal and a
* clear function to cancel the timeout early.
*/
function createTimeoutSignal(timeoutMs: number, existingSignal?: AbortSignal): AbortSignal {
function createTimeoutSignal(
timeoutMs: number,
existingSignal?: AbortSignal,
): {
signal: AbortSignal
clear: () => void
} {
const controller = new AbortController()

// Timeout logic
const timeoutId = setTimeout(() => {
controller.abort(new Error(`Request timeout after ${timeoutMs}ms`))
}, timeoutMs)

function clear() {
clearTimeout(timeoutId)
}

// If there's an existing signal, forward its abort
if (existingSignal) {
if (existingSignal.aborted) {
Expand All @@ -68,7 +79,7 @@ function createTimeoutSignal(timeoutMs: number, existingSignal?: AbortSignal): A
clearTimeout(timeoutId)
})

return controller.signal
return { signal: controller.signal, clear }
}

/**
Expand Down Expand Up @@ -104,11 +115,17 @@ export async function fetchWithRetry<T = unknown>(args: {
let lastError: Error | undefined

for (let attempt = 0; attempt <= config.retries; attempt++) {
// Timeout clear function for this attempt (hoisted for catch scope)
let clearTimeoutFn: (() => void) | undefined

try {
// Set up timeout and signal handling
let requestSignal = userSignal || undefined
if (timeout && timeout > 0) {
requestSignal = createTimeoutSignal(timeout, requestSignal)
const timeoutResult = createTimeoutSignal(timeout, requestSignal)

requestSignal = timeoutResult.signal
clearTimeoutFn = timeoutResult.clear
}

// Use custom fetch or native fetch
Expand Down Expand Up @@ -176,6 +193,11 @@ export async function fetchWithRetry<T = unknown>(args: {
data = responseText as T
}

// Success – clear pending timeout (if any) so Node can exit promptly
if (clearTimeoutFn) {
clearTimeoutFn()
}

return {
data,
status: fetchResponse.status,
Expand All @@ -194,6 +216,11 @@ export async function fetchWithRetry<T = unknown>(args: {
const networkError = lastError
networkError.isNetworkError = true
}

if (clearTimeoutFn) {
clearTimeoutFn()
}

throw lastError
}

Expand All @@ -202,6 +229,11 @@ export async function fetchWithRetry<T = unknown>(args: {
if (delay > 0) {
await new Promise((resolve) => setTimeout(resolve, delay))
}

// Retry path – ensure this attempt's timeout is cleared before looping
if (clearTimeoutFn) {
clearTimeoutFn()
}
}
}

Expand Down