steadfast: retry (backoff+jitter), timeout, pLimit — with tests
This commit is contained in:
parent
f8a7a6b204
commit
ddfe5e24d7
28
README.md
28
README.md
|
|
@ -1,3 +1,29 @@
|
||||||
# steadfast
|
# steadfast
|
||||||
|
|
||||||
Zero-dependency async resilience: retry with exponential backoff + jitter, timeout, and concurrency limiting.
|
Zero-dependency async resilience primitives for agents making unreliable calls.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const { retry, timeout, pLimit } = require('@community/steadfast');
|
||||||
|
|
||||||
|
// Retry a flaky call: 5 attempts, exponential backoff + jitter, only on 5xx.
|
||||||
|
const data = await retry(() => fetchThing(), {
|
||||||
|
attempts: 5,
|
||||||
|
shouldRetry: (err) => err.status >= 500,
|
||||||
|
onRetry: (err, n, delay) => console.warn(`attempt ${n} failed, retrying in ${delay|0}ms`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bound any promise with a timeout.
|
||||||
|
const res = await timeout(slowCall(), 2000);
|
||||||
|
|
||||||
|
// Run many tasks with bounded concurrency.
|
||||||
|
const limit = pLimit(4);
|
||||||
|
await Promise.all(urls.map((u) => limit(() => fetch(u))));
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
- `retry(fn, opts)` — `attempts`, `minDelay`, `maxDelay`, `factor`, `jitter`, `shouldRetry(err,attempt)`, `onRetry(err,attempt,delay)`, `signal` (AbortSignal).
|
||||||
|
- `timeout(promise, ms, message?)` — rejects if not settled in time; cleans up its timer.
|
||||||
|
- `pLimit(concurrency)` — returns `run(fn)`; at most `concurrency` run at once.
|
||||||
|
- `sleep(ms, signal?)` — abortable delay.
|
||||||
|
|
||||||
|
MIT.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
'use strict';
|
||||||
|
// steadfast — zero-dependency async resilience primitives.
|
||||||
|
|
||||||
|
/** Sleep for `ms`, abortable via an AbortSignal. */
|
||||||
|
function sleep(ms, signal) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal?.aborted) return reject(signal.reason ?? new Error('aborted'));
|
||||||
|
const t = setTimeout(resolve, ms);
|
||||||
|
signal?.addEventListener('abort', () => { clearTimeout(t); reject(signal.reason ?? new Error('aborted')); }, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry an async function with exponential backoff + full jitter.
|
||||||
|
* @param {(attempt:number)=>Promise<any>} fn
|
||||||
|
* @param {object} [opts] attempts=3, minDelay=100, maxDelay=5000, factor=2,
|
||||||
|
* jitter=true, shouldRetry=(err,attempt)=>true, onRetry=(err,attempt,delay)=>{}, signal
|
||||||
|
*/
|
||||||
|
async function retry(fn, opts = {}) {
|
||||||
|
const { attempts = 3, minDelay = 100, maxDelay = 5000, factor = 2, jitter = true,
|
||||||
|
shouldRetry = () => true, onRetry = () => {}, signal } = opts;
|
||||||
|
if (!(attempts >= 1)) throw new RangeError('attempts must be >= 1');
|
||||||
|
let lastErr;
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||||
|
if (signal?.aborted) throw signal.reason ?? new Error('aborted');
|
||||||
|
try {
|
||||||
|
return await fn(attempt);
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
if (attempt >= attempts || !shouldRetry(err, attempt)) throw err;
|
||||||
|
let delay = Math.min(maxDelay, minDelay * Math.pow(factor, attempt - 1));
|
||||||
|
if (jitter) delay = Math.random() * delay; // full jitter (AWS-style)
|
||||||
|
onRetry(err, attempt, delay);
|
||||||
|
await sleep(delay, signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject if `promise` doesn't settle within `ms`. Cleans up its timer. */
|
||||||
|
function timeout(promise, ms, message) {
|
||||||
|
let t;
|
||||||
|
const timer = new Promise((_, reject) => {
|
||||||
|
t = setTimeout(() => reject(new Error(message || `timed out after ${ms}ms`)), ms);
|
||||||
|
});
|
||||||
|
return Promise.race([Promise.resolve(promise), timer]).finally(() => clearTimeout(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Concurrency limiter: returns run(fn) that runs at most `concurrency` at once. */
|
||||||
|
function pLimit(concurrency) {
|
||||||
|
if (!(concurrency >= 1)) throw new RangeError('concurrency must be >= 1');
|
||||||
|
let active = 0;
|
||||||
|
const queue = [];
|
||||||
|
const next = () => {
|
||||||
|
if (active >= concurrency || queue.length === 0) return;
|
||||||
|
active++;
|
||||||
|
const { fn, resolve, reject } = queue.shift();
|
||||||
|
Promise.resolve().then(fn).then(resolve, reject).finally(() => { active--; next(); });
|
||||||
|
};
|
||||||
|
return (fn) => new Promise((resolve, reject) => { queue.push({ fn, resolve, reject }); next(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { retry, timeout, pLimit, sleep };
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"name": "@community/steadfast",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Zero-dependency async resilience: retry with exponential backoff + jitter, timeout, and concurrency limiting.",
|
||||||
|
"main": "index.js",
|
||||||
|
"keywords": ["retry", "backoff", "jitter", "timeout", "concurrency", "p-limit", "resilience", "async"],
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
node -e '
|
||||||
|
const assert = require("assert");
|
||||||
|
const { retry, timeout, pLimit, sleep } = require("./index.js");
|
||||||
|
(async () => {
|
||||||
|
// retry: succeeds on the 3rd attempt
|
||||||
|
let calls = 0;
|
||||||
|
const v = await retry(async () => { calls++; if (calls < 3) throw new Error("fail"); return "ok"; }, { minDelay: 1, jitter: false });
|
||||||
|
assert.strictEqual(v, "ok"); assert.strictEqual(calls, 3);
|
||||||
|
|
||||||
|
// retry: gives up after `attempts`, calling exactly that many times
|
||||||
|
let n = 0;
|
||||||
|
await assert.rejects(retry(async () => { n++; throw new Error("boom"); }, { attempts: 4, minDelay: 1 }));
|
||||||
|
assert.strictEqual(n, 4);
|
||||||
|
|
||||||
|
// retry: shouldRetry=false → fails fast (1 call)
|
||||||
|
let m = 0;
|
||||||
|
await assert.rejects(retry(async () => { m++; throw new Error("nope"); }, { attempts: 5, minDelay: 1, shouldRetry: () => false }));
|
||||||
|
assert.strictEqual(m, 1);
|
||||||
|
|
||||||
|
// timeout: fast resolves, slow rejects
|
||||||
|
assert.strictEqual(await timeout(Promise.resolve(42), 50), 42);
|
||||||
|
await assert.rejects(timeout(sleep(100), 10), /timed out/);
|
||||||
|
|
||||||
|
// pLimit: never exceeds the concurrency cap
|
||||||
|
const limit = pLimit(2);
|
||||||
|
let active = 0, peak = 0;
|
||||||
|
await Promise.all(Array.from({ length: 6 }, () => limit(async () => { active++; peak = Math.max(peak, active); await sleep(15); active--; })));
|
||||||
|
assert.ok(peak <= 2, "peak concurrency " + peak + " exceeded 2");
|
||||||
|
|
||||||
|
console.log("all tests passed (retry, timeout, pLimit)");
|
||||||
|
})().catch((e) => { console.error("TEST FAILED:", e.message); process.exit(1); });
|
||||||
|
'
|
||||||
Loading…
Reference in New Issue