Published on

Retry 429 with backoff + pool

A FIFO pool keeps you under your share of the account concurrency limit. The retry wrapper handles the spikes you cannot predict.

netsuite-client.js
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
export function createPool(size) {
let active = 0
const waiting = []
const next = () => {
if (active >= size || waiting.length === 0) return
active++
const { task, resolve, reject } = waiting.shift()
task()
.then(resolve, reject)
.finally(() => {
active--
next()
})
}
return (task) =>
new Promise((resolve, reject) => {
waiting.push({ task, resolve, reject })
next()
})
}
const RETRYABLE = new Set([429, 502, 503, 504])
export async function withRetry(doRequest, { maxAttempts = 6, baseMs = 500, capMs = 30_000 } = {}) {
for (let attempt = 1; ; attempt++) {
let res
try {
res = await doRequest()
} catch (networkError) {
if (attempt >= maxAttempts) throw networkError
}
if (res && !RETRYABLE.has(res.status)) return res
if (res && attempt >= maxAttempts) return res
const retryAfter = Number(res?.headers.get('retry-after')) * 1000
const ceiling = Math.min(capMs, baseMs * 2 ** attempt)
await sleep(retryAfter || Math.random() * ceiling)
}
}

Usage:

const pool = createPool(4)
const results = await Promise.all(
ids.map((id) => pool(() => withRetry(() => fetch(urlFor(id), { headers: authHeaders() }))))
)

Build auth headers inside doRequest, so every attempt gets a fresh TBA nonce or a non-expired token. Only retry writes that are idempotent. Interactive explanation: NetSuite concurrency limits.