@nodii/retry 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jitter.js","sourceRoot":"","sources":["../src/jitter.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,mBAAmB,GACpB,MAAM,aAAa,CAAC;AAKrB;;;;;;;;;GASG;AACH,MAAM,UAAU,WAAW,CACzB,CAAS,EACT,QAAwB,EACxB,MAAgB,EAChB,GAAyE;IAEzE,IAAI,OAAO,QAAQ,KAAK,QAAQ,EAAE,CAAC;QACjC,sCAAsC;QACtC,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,QAAQ,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IACrD,CAAC;IACD,QAAQ,QAAQ,EAAE,CAAC;QACjB,KAAK,MAAM;YACT,OAAO,CAAC,CAAC;QACX,KAAK,MAAM;YACT,UAAU;YACV,OAAO,MAAM,EAAE,GAAG,CAAC,CAAC;QACtB,KAAK,OAAO;YACV,qEAAqE;YACrE,OAAO,CAAC,GAAG,CAAC,GAAG,MAAM,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;QACpC,KAAK,cAAc,CAAC,CAAC,CAAC;YACpB,2DAA2D;YAC3D,2DAA2D;YAC3D,MAAM,EAAE,GAAG,GAAG,CAAC,WAAW,CAAC;YAC3B,MAAM,EAAE,GAAG,GAAG,CAAC,eAAe,GAAG,CAAC,CAAC;YACnC,MAAM,IAAI,GAAG,EAAE,GAAG,EAAE,CAAC;YACrB,MAAM,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,MAAM,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;YAC/C,OAAO,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC;QACrC,CAAC;IACH,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,uBAAuB,CACrC,MAAmB,EACnB,OAAe,EACf,IAAoD;IAEpD,MAAM,MAAM,GAAG,mBAAmB,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpD,MAAM,QAAQ,GAAG,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE;QAC/D,WAAW,EAAE,MAAM,CAAC,WAAW;QAC/B,UAAU,EAAE,MAAM,CAAC,UAAU;QAC7B,eAAe,EAAE,IAAI,CAAC,eAAe,IAAI,MAAM,CAAC,WAAW;KAC5D,CAAC,CAAC;IACH,MAAM,OAAO,GAAG,MAAM,CAAC,mBAAmB;QACxC,CAAC,CAAC,QAAQ;QACV,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC;IAC1C,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;AAC1C,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAC7B,MAAmB,EACnB,KAAa,EACb,IAA4B;IAE5B,MAAM,MAAM,GAAG,IAAI,EAAE,MAAM,IAAI,IAAI,CAAC,MAAM,CAAC;IAC3C,MAAM,GAAG,GAAa,EAAE,CAAC;IACzB,IAAI,QAAQ,GAAG,MAAM,CAAC,WAAW,CAAC;IAClC,KAAK,IAAI,OAAO,GAAG,CAAC,EAAE,OAAO,IAAI,KAAK,EAAE,OAAO,EAAE,EAAE,CAAC;QAClD,MAAM,CAAC,GAAG,uBAAuB,CAAC,MAAM,EAAE,OAAO,EAAE;YACjD,MAAM;YACN,eAAe,EAAE,QAAQ;SAC1B,CAAC,CAAC;QACH,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACZ,QAAQ,GAAG,CAAC,CAAC;IACf,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
@@ -0,0 +1,95 @@
1
+ /**
2
+ * How the un-jittered delay grows with the attempt index.
3
+ *
4
+ * - `"exponential"` — `base · multiplier^(attempt-1)`. `multiplier: 1` gives a
5
+ * CONSTANT delay (the six fixed-sleep sites).
6
+ * - `"linear"` — `base · attempt`. Exists because
7
+ * `@nodii/outbox-dispatcher`'s claim query cools a row down by
8
+ * `interval '1 minute' * publish_attempts`, which is linear, not
9
+ * exponential. Modelling it as exponential would silently change the
10
+ * dispatcher's re-claim schedule.
11
+ */
12
+ export type GrowthKind = "exponential" | "linear";
13
+ /**
14
+ * Jitter strategies.
15
+ *
16
+ * `d` below is the growth-computed, cap-clamped delay for the attempt.
17
+ *
18
+ * - `"none"` — `d`. What 11 of the 14 surveyed sites do today.
19
+ * - `"equal"` — `d/2 + U(0, d/2)` → `[d/2, d)`. **The default** (see README
20
+ * § "Why equal jitter").
21
+ * - `"full"` — `U(0, d)` → `[0, d)`. Maximum dispersal, no floor.
22
+ * - `{ kind: "proportional", pct }` — `d · (1 + pct·U(-1, 1))` →
23
+ * `[d(1-pct), d(1+pct))`. The shape both live jitter sites use
24
+ * (role-catalog `pct: 0.125`, replica cold-start `pct: 0.2`), so their
25
+ * migration can be behaviour-preserving.
26
+ * - `"decorrelated"` — `min(cap, U(base, 3·previous))`, seeded at `base`.
27
+ * A self-contained recurrence: it IGNORES `growth` / `multiplier` entirely
28
+ * and derives each delay from the previous one. Nothing in the fleet does
29
+ * this today; it is offered, never defaulted.
30
+ */
31
+ export type JitterStrategy = "none" | "full" | "equal" | "decorrelated" | {
32
+ kind: "proportional";
33
+ pct: number;
34
+ };
35
+ export interface RetryPolicy {
36
+ /**
37
+ * Total attempts INCLUDING the first (so `3` = 1 initial + 2 retries, which
38
+ * is what `@nodii/onboarding` means by `MAX_ATTEMPTS = 3`).
39
+ *
40
+ * `0` = retry INDEFINITELY — `@nodii/replica-consumer`'s cold-start backfill
41
+ * default, where giving up means silently dropping the backfill. The cap
42
+ * then bounds each DELAY, never the attempt count.
43
+ */
44
+ maxAttempts: number;
45
+ /** Delay before the FIRST retry, ms. Must be finite and >= 0. */
46
+ baseDelayMs: number;
47
+ /** Growth factor for `"exponential"`. `1` = constant. Ignored by `"linear"`. */
48
+ multiplier: number;
49
+ /** Ceiling on a single delay, ms. `Infinity` = uncapped. */
50
+ maxDelayMs: number;
51
+ /** How the un-jittered delay grows with the attempt index. */
52
+ growth: GrowthKind;
53
+ /** Jitter applied to the capped delay. */
54
+ jitter: JitterStrategy;
55
+ /**
56
+ * When `true`, jitter may push the delay ABOVE `maxDelayMs`.
57
+ *
58
+ * Both live jitter sites apply jitter AFTER the cap and never re-clamp, so
59
+ * `@nodii/role-catalog` can sleep 33.75s against a declared 30s cap and
60
+ * `@nodii/replica-consumer` can sleep 36s against the same declared cap.
61
+ * That is a bug: a cap that the code is allowed to exceed is not a cap. The
62
+ * DEFAULT here is `false` (clamp last — the cap holds unconditionally).
63
+ *
64
+ * Set `true` only to make a migration byte-for-byte behaviour-preserving
65
+ * against one of those two sites, and say so in the migration note.
66
+ */
67
+ allowJitterAboveCap: boolean;
68
+ }
69
+ /**
70
+ * Fleet default. Deliberately conservative: equal jitter (a growing FLOOR plus
71
+ * dispersal), a 30s ceiling (the value 3 of the 4 capped sites already use),
72
+ * and a finite attempt budget so a misconfigured caller cannot spin forever.
73
+ */
74
+ export declare const DEFAULT_POLICY: RetryPolicy;
75
+ /** Thrown by {@link resolvePolicy} on a policy that cannot produce sane delays. */
76
+ export declare class InvalidRetryPolicyError extends Error {
77
+ constructor(message: string);
78
+ }
79
+ /**
80
+ * Fill a partial policy from {@link DEFAULT_POLICY} and VALIDATE it.
81
+ *
82
+ * Validation is not decoration: an unvalidated `multiplier: 0` silently turns
83
+ * every retry after the first into a zero-delay hot loop against a dependency
84
+ * that is already failing.
85
+ */
86
+ export declare function resolvePolicy(partial?: Partial<RetryPolicy>): RetryPolicy;
87
+ /**
88
+ * The un-jittered, cap-clamped delay for a 1-based `attempt`.
89
+ *
90
+ * `attempt` is the index of the RETRY about to be scheduled: `1` is the delay
91
+ * between the first failure and the second try, so `attempt === 1` yields
92
+ * `baseDelayMs`.
93
+ */
94
+ export declare function baseDelayForAttempt(policy: RetryPolicy, attempt: number): number;
95
+ //# sourceMappingURL=policy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../src/policy.ts"],"names":[],"mappings":"AAqBA;;;;;;;;;;GAUG;AACH,MAAM,MAAM,UAAU,GAAG,aAAa,GAAG,QAAQ,CAAC;AAElD;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,cAAc,GACtB,MAAM,GACN,MAAM,GACN,OAAO,GACP,cAAc,GACd;IAAE,IAAI,EAAE,cAAc,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1C,MAAM,WAAW,WAAW;IAC1B;;;;;;;OAOG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,iEAAiE;IACjE,WAAW,EAAE,MAAM,CAAC;IACpB,gFAAgF;IAChF,UAAU,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,UAAU,EAAE,MAAM,CAAC;IACnB,8DAA8D;IAC9D,MAAM,EAAE,UAAU,CAAC;IACnB,0CAA0C;IAC1C,MAAM,EAAE,cAAc,CAAC;IACvB;;;;;;;;;;;OAWG;IACH,mBAAmB,EAAE,OAAO,CAAC;CAC9B;AAED;;;;GAIG;AACH,eAAO,MAAM,cAAc,EAAE,WAQ5B,CAAC;AAEF,mFAAmF;AACnF,qBAAa,uBAAwB,SAAQ,KAAK;gBACpC,OAAO,EAAE,MAAM;CAI5B;AAUD;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG,WAAW,CAuDzE;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,WAAW,EACnB,OAAO,EAAE,MAAM,GACd,MAAM,CAuBR"}
package/dist/policy.js ADDED
@@ -0,0 +1,121 @@
1
+ // The retry policy — the ONE shape that covers the union of what the fleet's
2
+ // hand-rolled backoffs actually do.
3
+ //
4
+ // The requirements here are not invented. They are the union of the 14
5
+ // hand-rolled implementations found inside ts/* on origin/main (see README
6
+ // § "The survey"), which between them need:
7
+ //
8
+ // - exponential growth (approval 250ms×2, role-catalog 100ms×2,
9
+ // replica cold-start 1000ms×2, replica apply 100ms×2)
10
+ // - LINEAR growth (outbox-dispatcher's SQL cooldown is
11
+ // `interval '1 minute' * publish_attempts`, i.e.
12
+ // 1,2,3,… minutes — NOT expressible as base·mult^n)
13
+ // - CONSTANT growth (six fixed sleeps: 200ms, 250ms, pollMs, ttl/2, …)
14
+ // - a delay cap (60s / 30s / uncapped)
15
+ // - a max-attempts cap (3, 5, 8, 30 — and 0 = "retry indefinitely",
16
+ // which replica-consumer's cold start relies on)
17
+ // - no jitter (11 of 14 sites)
18
+ // - PROPORTIONAL jitter (role-catalog ±12.5%, replica cold-start ±20%)
19
+ //
20
+ // Anything not in that list is not in this type.
21
+ /**
22
+ * Fleet default. Deliberately conservative: equal jitter (a growing FLOOR plus
23
+ * dispersal), a 30s ceiling (the value 3 of the 4 capped sites already use),
24
+ * and a finite attempt budget so a misconfigured caller cannot spin forever.
25
+ */
26
+ export const DEFAULT_POLICY = {
27
+ maxAttempts: 5,
28
+ baseDelayMs: 100,
29
+ multiplier: 2,
30
+ maxDelayMs: 30_000,
31
+ growth: "exponential",
32
+ jitter: "equal",
33
+ allowJitterAboveCap: false,
34
+ };
35
+ /** Thrown by {@link resolvePolicy} on a policy that cannot produce sane delays. */
36
+ export class InvalidRetryPolicyError extends Error {
37
+ constructor(message) {
38
+ super(`invalid retry policy: ${message}`);
39
+ this.name = "InvalidRetryPolicyError";
40
+ }
41
+ }
42
+ function requireFiniteNonNegative(name, v) {
43
+ if (typeof v !== "number" || Number.isNaN(v) || v < 0) {
44
+ throw new InvalidRetryPolicyError(`${name} must be a number >= 0 (got ${v})`);
45
+ }
46
+ }
47
+ /**
48
+ * Fill a partial policy from {@link DEFAULT_POLICY} and VALIDATE it.
49
+ *
50
+ * Validation is not decoration: an unvalidated `multiplier: 0` silently turns
51
+ * every retry after the first into a zero-delay hot loop against a dependency
52
+ * that is already failing.
53
+ */
54
+ export function resolvePolicy(partial) {
55
+ const p = { ...DEFAULT_POLICY, ...partial };
56
+ if (typeof p.maxAttempts !== "number" ||
57
+ !Number.isInteger(p.maxAttempts) ||
58
+ p.maxAttempts < 0) {
59
+ throw new InvalidRetryPolicyError(`maxAttempts must be an integer >= 0 (0 = unbounded) (got ${p.maxAttempts})`);
60
+ }
61
+ requireFiniteNonNegative("baseDelayMs", p.baseDelayMs);
62
+ if (!Number.isFinite(p.baseDelayMs)) {
63
+ throw new InvalidRetryPolicyError("baseDelayMs must be finite");
64
+ }
65
+ requireFiniteNonNegative("maxDelayMs", p.maxDelayMs);
66
+ if (typeof p.multiplier !== "number" ||
67
+ Number.isNaN(p.multiplier) ||
68
+ p.multiplier < 1) {
69
+ throw new InvalidRetryPolicyError(`multiplier must be >= 1 (got ${p.multiplier}) — a multiplier < 1 SHRINKS the delay each attempt, turning backoff into a hot loop`);
70
+ }
71
+ if (p.growth !== "exponential" && p.growth !== "linear") {
72
+ throw new InvalidRetryPolicyError(`growth must be "exponential" | "linear" (got ${String(p.growth)})`);
73
+ }
74
+ if (typeof p.jitter === "object") {
75
+ if (p.jitter === null || p.jitter.kind !== "proportional") {
76
+ throw new InvalidRetryPolicyError(`unknown jitter strategy ${JSON.stringify(p.jitter)}`);
77
+ }
78
+ const { pct } = p.jitter;
79
+ if (typeof pct !== "number" || Number.isNaN(pct) || pct < 0 || pct > 1) {
80
+ throw new InvalidRetryPolicyError(`proportional jitter pct must be a FRACTION in [0, 1] (got ${pct}) — note @nodii/replica-consumer carries two conflicting units for this field: start.ts uses 0.2 and define-replica.ts/init.ts use 25`);
81
+ }
82
+ }
83
+ else if (p.jitter !== "none" &&
84
+ p.jitter !== "full" &&
85
+ p.jitter !== "equal" &&
86
+ p.jitter !== "decorrelated") {
87
+ throw new InvalidRetryPolicyError(`unknown jitter strategy ${String(p.jitter)}`);
88
+ }
89
+ return p;
90
+ }
91
+ /**
92
+ * The un-jittered, cap-clamped delay for a 1-based `attempt`.
93
+ *
94
+ * `attempt` is the index of the RETRY about to be scheduled: `1` is the delay
95
+ * between the first failure and the second try, so `attempt === 1` yields
96
+ * `baseDelayMs`.
97
+ */
98
+ export function baseDelayForAttempt(policy, attempt) {
99
+ if (!Number.isInteger(attempt) || attempt < 1) {
100
+ throw new InvalidRetryPolicyError(`attempt must be an integer >= 1 (got ${attempt})`);
101
+ }
102
+ // A zero base is a zero delay at every attempt — `0 · multiplier^n` is 0 for
103
+ // any finite factor. Handled FIRST because `multiplier^n` overflows to
104
+ // Infinity for large n and `0 * Infinity` is NaN, which the guard below
105
+ // would otherwise resolve to the CAP. (Caught by its own test: the
106
+ // counting-only sites — replica-consumer + telemetry apply retries — declare
107
+ // baseDelayMs 0 because their spacing comes from Redis PEL redelivery, and
108
+ // silently promoting that to a 30s cap would be a fabricated sleep.)
109
+ if (policy.baseDelayMs === 0)
110
+ return 0;
111
+ const raw = policy.growth === "linear"
112
+ ? policy.baseDelayMs * attempt
113
+ : policy.baseDelayMs * policy.multiplier ** (attempt - 1);
114
+ // Genuine overflow: `base · mult^n` exceeds Number.MAX_VALUE for large n.
115
+ // The sequence is monotone non-decreasing, so anything past the cap IS the
116
+ // cap.
117
+ if (!Number.isFinite(raw))
118
+ return policy.maxDelayMs;
119
+ return Math.min(raw, policy.maxDelayMs);
120
+ }
121
+ //# sourceMappingURL=policy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy.js","sourceRoot":"","sources":["../src/policy.ts"],"names":[],"mappings":"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"}
package/dist/run.d.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { type ShouldRetry } from "./classify.js";
2
+ import { type RandomFn } from "./jitter.js";
3
+ import { type RetryPolicy } from "./policy.js";
4
+ /**
5
+ * Injectable sleep — THE clock seam. Tests pass a recorder that returns
6
+ * immediately, so a suite that exercises a 30s cap still finishes in
7
+ * milliseconds. A test suite that actually waits 30s is a test suite that
8
+ * gets muted.
9
+ */
10
+ export type SleepFn = (ms: number, signal?: AbortSignal) => Promise<void>;
11
+ /** Default sleep: `setTimeout`, `unref`'d where available, abort-aware. */
12
+ export declare const defaultSleep: SleepFn;
13
+ /** State passed into each attempt. */
14
+ export interface AttemptContext {
15
+ /** 1-based index of the attempt about to run. */
16
+ attempt: number;
17
+ /**
18
+ * Is this the LAST attempt the budget allows? Always `false` when
19
+ * `maxAttempts === 0` (unbounded). Correct at the boundary — see
20
+ * `./attempts.ts` for why that sentence needs saying.
21
+ */
22
+ isFinalAttempt: boolean;
23
+ /** Retries left after this one; `Infinity` when unbounded. */
24
+ remaining: number;
25
+ /** The error that ended the previous attempt, if any. */
26
+ previousError: unknown;
27
+ }
28
+ /** Payload for the `onRetry` telemetry hook. */
29
+ export interface RetryEvent {
30
+ /** 1-based index of the attempt that just FAILED. */
31
+ attempt: number;
32
+ /** 1-based index of the attempt that will run after the delay. */
33
+ nextAttempt: number;
34
+ /** The delay actually slept, ms (post-jitter, post-clamp). */
35
+ delayMs: number;
36
+ /** The error that triggered the retry. */
37
+ error: unknown;
38
+ }
39
+ /** Payload for the `onExhausted` hook. */
40
+ export interface ExhaustedEvent {
41
+ /** Attempts actually made. */
42
+ attempts: number;
43
+ /** The final error. */
44
+ error: unknown;
45
+ }
46
+ export interface RetryOptions {
47
+ /** Partial policy; unset fields come from `DEFAULT_POLICY`. */
48
+ policy?: Partial<RetryPolicy>;
49
+ /** Terminal-vs-transient seam. Default {@link retryAll}. */
50
+ shouldRetry?: ShouldRetry;
51
+ /** Fired BEFORE each backoff sleep. Telemetry only — it cannot veto. */
52
+ onRetry?: (event: RetryEvent) => void | Promise<void>;
53
+ /** Fired once when the attempt budget is spent. */
54
+ onExhausted?: (event: ExhaustedEvent) => void | Promise<void>;
55
+ /** Clock seam. Default {@link defaultSleep}. */
56
+ sleep?: SleepFn;
57
+ /** RNG seam for jitter. Default `Math.random`. */
58
+ random?: RandomFn;
59
+ /** Aborts the backoff sleep AND stops further attempts. */
60
+ signal?: AbortSignal;
61
+ /**
62
+ * Wrap the final error in {@link RetryExhaustedError}. Default `false` —
63
+ * the LAST error is rethrown as-is so caller `instanceof` checks survive.
64
+ */
65
+ wrapExhausted?: boolean;
66
+ }
67
+ /**
68
+ * Run `fn` under a retry policy.
69
+ *
70
+ * Guarantees, each of which has a test that goes red when the guard is broken:
71
+ * - exactly `maxAttempts` invocations of `fn` in the worst case
72
+ * (`maxAttempts: 0` = unbounded);
73
+ * - exactly `attempts - 1` sleeps, whose durations match
74
+ * `backoffSequence(policy, …)`;
75
+ * - NO sleep after the final attempt (a trailing sleep is a wasted
76
+ * shutdown-delay and hides the failure by the length of the cap);
77
+ * - `shouldRetry === false` short-circuits with ZERO further sleeps;
78
+ * - `NonRetryableError` bypasses `shouldRetry` entirely;
79
+ * - an `onRetry` that throws never converts a retryable failure into a
80
+ * crash (telemetry must not be load-bearing).
81
+ */
82
+ export declare function retry<T>(fn: (ctx: AttemptContext) => T | Promise<T>, opts?: RetryOptions): Promise<T>;
83
+ //# sourceMappingURL=run.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAY,MAAM,eAAe,CAAC;AAE3D,OAAO,EAAE,KAAK,QAAQ,EAA2B,MAAM,aAAa,CAAC;AAErE,OAAO,EAAE,KAAK,WAAW,EAAiB,MAAM,aAAa,CAAC;AAE9D;;;;;GAKG;AACH,MAAM,MAAM,OAAO,GAAG,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAE1E,2EAA2E;AAC3E,eAAO,MAAM,YAAY,EAAE,OAkBvB,CAAC;AAEL,sCAAsC;AACtC,MAAM,WAAW,cAAc;IAC7B,iDAAiD;IACjD,OAAO,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,cAAc,EAAE,OAAO,CAAC;IACxB,8DAA8D;IAC9D,SAAS,EAAE,MAAM,CAAC;IAClB,yDAAyD;IACzD,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,gDAAgD;AAChD,MAAM,WAAW,UAAU;IACzB,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,kEAAkE;IAClE,WAAW,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC;IAChB,0CAA0C;IAC1C,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,0CAA0C;AAC1C,MAAM,WAAW,cAAc;IAC7B,8BAA8B;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,uBAAuB;IACvB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,YAAY;IAC3B,+DAA+D;IAC/D,MAAM,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC;IAC9B,4DAA4D;IAC5D,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,wEAAwE;IACxE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD,mDAAmD;IACnD,WAAW,CAAC,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9D,gDAAgD;IAChD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,kDAAkD;IAClD,MAAM,CAAC,EAAE,QAAQ,CAAC;IAClB,2DAA2D;IAC3D,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,KAAK,CAAC,CAAC,EAC3B,EAAE,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,EAC3C,IAAI,GAAE,YAAiB,GACtB,OAAO,CAAC,CAAC,CAAC,CAgFZ"}
package/dist/run.js ADDED
@@ -0,0 +1,113 @@
1
+ import { retryAll } from "./classify.js";
2
+ import { NonRetryableError, RetryExhaustedError } from "./errors.js";
3
+ import { jitteredDelayForAttempt } from "./jitter.js";
4
+ import { isFinalAttempt, remainingAttempts } from "./attempts.js";
5
+ import { resolvePolicy } from "./policy.js";
6
+ /** Default sleep: `setTimeout`, `unref`'d where available, abort-aware. */
7
+ export const defaultSleep = (ms, signal) => new Promise((resolve) => {
8
+ if (signal?.aborted) {
9
+ resolve();
10
+ return;
11
+ }
12
+ const t = setTimeout(resolve, ms);
13
+ // Node/Bun timers expose unref(); browsers do not. Never let a pending
14
+ // backoff hold a process open past its work.
15
+ t.unref?.();
16
+ signal?.addEventListener("abort", () => {
17
+ clearTimeout(t);
18
+ resolve();
19
+ }, { once: true });
20
+ });
21
+ /**
22
+ * Run `fn` under a retry policy.
23
+ *
24
+ * Guarantees, each of which has a test that goes red when the guard is broken:
25
+ * - exactly `maxAttempts` invocations of `fn` in the worst case
26
+ * (`maxAttempts: 0` = unbounded);
27
+ * - exactly `attempts - 1` sleeps, whose durations match
28
+ * `backoffSequence(policy, …)`;
29
+ * - NO sleep after the final attempt (a trailing sleep is a wasted
30
+ * shutdown-delay and hides the failure by the length of the cap);
31
+ * - `shouldRetry === false` short-circuits with ZERO further sleeps;
32
+ * - `NonRetryableError` bypasses `shouldRetry` entirely;
33
+ * - an `onRetry` that throws never converts a retryable failure into a
34
+ * crash (telemetry must not be load-bearing).
35
+ */
36
+ export async function retry(fn, opts = {}) {
37
+ const policy = resolvePolicy(opts.policy);
38
+ const shouldRetry = opts.shouldRetry ?? retryAll;
39
+ const sleep = opts.sleep ?? defaultSleep;
40
+ const random = opts.random ?? Math.random;
41
+ let previousError = undefined;
42
+ let previousDelayMs = policy.baseDelayMs;
43
+ // ONE termination guard, deliberately. An earlier draft also bounded the
44
+ // loop with `attempt <= maxAttempts`; mutation testing proved that condition
45
+ // unreachable — `isFinalAttempt` always breaks first — i.e. it was dead code
46
+ // that no test could kill. Two guards for one invariant means one of them is
47
+ // untested by construction, so the redundant one is gone. `isFinalAttempt`
48
+ // is `false` forever when `maxAttempts === 0`, which IS the unbounded case.
49
+ for (let attempt = 1;; attempt++) {
50
+ const final = isFinalAttempt(attempt, policy.maxAttempts);
51
+ try {
52
+ return await fn({
53
+ attempt,
54
+ isFinalAttempt: final,
55
+ remaining: remainingAttempts(attempt, policy.maxAttempts),
56
+ previousError,
57
+ });
58
+ }
59
+ catch (err) {
60
+ previousError = err;
61
+ // The operation's own terminal verdict outranks the caller's predicate.
62
+ if (err instanceof NonRetryableError)
63
+ throw err;
64
+ if (final)
65
+ break;
66
+ if (!shouldRetry(err, { attempt, maxAttempts: policy.maxAttempts })) {
67
+ throw err;
68
+ }
69
+ if (opts.signal?.aborted)
70
+ throw err;
71
+ const delayMs = jitteredDelayForAttempt(policy, attempt, {
72
+ random,
73
+ previousDelayMs,
74
+ });
75
+ previousDelayMs = delayMs;
76
+ if (opts.onRetry) {
77
+ try {
78
+ await opts.onRetry({
79
+ attempt,
80
+ nextAttempt: attempt + 1,
81
+ delayMs,
82
+ error: err,
83
+ });
84
+ }
85
+ catch {
86
+ // Telemetry is never load-bearing: a failing metrics sink must not
87
+ // turn a retryable error into a crash. Same posture as
88
+ // replica-consumer's guarded audit emit.
89
+ }
90
+ }
91
+ await sleep(delayMs, opts.signal);
92
+ // Aborted DURING the sleep — stop, surface the last real error rather
93
+ // than inventing an abort error the caller has no handler for.
94
+ if (opts.signal?.aborted)
95
+ throw err;
96
+ }
97
+ }
98
+ // Budget spent.
99
+ const attempts = policy.maxAttempts;
100
+ if (opts.onExhausted) {
101
+ try {
102
+ await opts.onExhausted({ attempts, error: previousError });
103
+ }
104
+ catch {
105
+ // as above
106
+ }
107
+ }
108
+ if (opts.wrapExhausted) {
109
+ throw new RetryExhaustedError(attempts, previousError);
110
+ }
111
+ throw previousError;
112
+ }
113
+ //# sourceMappingURL=run.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run.js","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,10 +1,39 @@
1
1
  {
2
2
  "name": "@nodii/retry",
3
- "version": "0.0.0",
4
- "description": "Placeholder claim \u2014 real package ships from cognion-nucleus/nodii-libs via trusted publishing.",
5
- "main": "index.js",
6
- "license": "UNLICENSED",
3
+ "version": "0.1.0",
4
+ "description": "The retry / exponential-backoff / jitter policy for the Nodii microservice stack, as a real package. Consolidates 14 hand-rolled backoff implementations that had drifted across the shared libraries themselves (250ms/2x/60s no-jitter, 100ms/2x/30s ±12.5%, 1000ms/2x/30s ±20%, a linear 1-min-per-attempt SQL cooldown, and six fixed sleeps) into one injectable-clock policy with a shouldRetry seam, an onRetry telemetry hook, and a BullMQ attemptsMade post-increment adapter.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
7
19
  "publishConfig": {
8
20
  "access": "public"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "typecheck": "tsc --noEmit",
25
+ "test": "bun test"
26
+ },
27
+ "devDependencies": {
28
+ "@types/bun": "^1.3.13",
29
+ "typescript": "^5.9.3"
30
+ },
31
+ "engines": {
32
+ "bun": ">=1.0.0"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/cognion-nucleus/nodii-libs.git",
37
+ "directory": "ts/retry"
9
38
  }
10
- }
39
+ }
package/index.js DELETED
@@ -1 +0,0 @@
1
- // placeholder — real package ships from nodii-libs release.yml