@ 百分点/ 反应
百度包的零依赖性韧性原始:断路器,有受限密钥注册的符号-bucket速率限制器,回放-jitter,用可注射故障分级器重试,取消时有时钟超时,LRU/TTL/SWR缓存,和受限货币池.
每一次和 en依赖的行为都是由被注射的 Clock 或者说 RandomSource 没有 Date.now() 或者说 Math.random() 在任何逻辑路径中——因此原始人被检验为完全具有决定力. 货包遵循工厂功能公约(createCircuitBreaker(), (中文(简体) ), createTokenBucket(),(...)和 Result 歧视工会的错误模式 @centient。 。 。 .
安装
npm install @centient/resilience
或作为单体中的工作空间依赖:
pnpm add @centient/resilience --workspace
设计:注入时钟和随机性
塑造此软件包的单一规则 : 逻辑路径从不直接读取墙钟或全球RNG. 相反,它需要一个 Clock (单位:千美元)() => number,划时代)和涉及焦急的,a RandomSource (单位:千美元)() => number 内 [0, 1)) (中文(简体) ).
import { systemClock, createManualClock, systemRandom, fixedRandom } from "@centient/resilience";
// Production
const clock = systemClock; // the only place Date.now() is read
// Tests — advance time by hand, deterministically
const t = createManualClock(0);
t.advance(5_000); // 5 seconds pass
源码测试执行规则: Date.now() 可能只出现在 clock.ts, (中文(简体) ), Math.random() 仅在 random.ts。 。 。 .
结果类型
失败为普通结果的操作( 被拒绝的费率限制获取, 缓存误差升为出错) 返回 a Result<T, E> 而不是扔东西。 抛出被保留给程序员出错(无效配置).
import { ok, err, isOk, type Result } from "@centient/resilience";
const r: Result<number, string> = ok(42);
if (isOk(r)) console.log(r.value); // 42
断路器
A 类 CLOSED -> OPEN -> HALF_OPEN -> CLOSED 国家机器。 接连失效会绊倒电路开通;在开通时,电话会被拒绝,直到开通时间快到来,然后一个单一的探测器(HALF OPEN)会用指数增加的回放(封顶)决定是关闭还是重新开通.
import { createCircuitBreaker, CircuitOpenError } from "@centient/resilience";
const breaker = createCircuitBreaker({
failureThreshold: 5, // open after 5 consecutive failures
openDurationMs: 30_000, // stay open 30s before probing
backoffMultiplier: 2, // double the open duration on each failed probe
maxOpenDurationMs: 300_000,
});
try {
const data = await breaker.execute(() => fetchFromUpstream());
} catch (e) {
if (e instanceof CircuitOpenError) {
// serve a fallback; e.retryAfterMs tells you when to try again
}
}
带外录音(当您不把电话包进来时) execute:
if (breaker.canExecute()) {
try { doWork(); breaker.onSuccess(); }
catch { breaker.onFailure(); }
}
纯国有机器
工厂包装了一台可以直接使用的纯净而不可改变的国产机器(取自"十字架" circuit-breaker.ts) (中文(简体) ). 每个函数都使用状态 + 时间- 毫升秒印并返回新状态 :
import {
createCircuitBreakerState,
getDefaultCircuitBreakerConfig,
checkCircuit,
recordSuccess,
recordFailure,
} from "@centient/resilience";
const config = getDefaultCircuitBreakerConfig();
let state = createCircuitBreakerState(config);
state = recordFailure(state, config, Date.now()); // you own the clock here
const { result, updatedState } = checkCircuit(state, Date.now());
输出 : createCircuitBreaker, (中文(简体) ), CircuitOpenError, (中文(简体) ), getDefaultCircuitBreakerConfig, (中文(简体) ), createCircuitBreakerState, (中文(简体) ), checkCircuit, (中文(简体) ), recordSuccess, (中文(简体) ), recordFailure类型 CircuitBreaker, (中文(简体) ), CircuitBreakerConfig, (中文(简体) ), CircuitBreakerOptions, (中文(简体) ), CircuitBreakerState, (中文(简体) ), CircuitCheckResult, (中文(简体) ), CircuitState。 。 。 .
托肯-巴克特率限制器
一桶能撑到 capacity 符号和再填充在 refillPerSecond。重新填充从时钟中粗略地计算——没有背景计时器.
import { createTokenBucket } from "@centient/resilience";
const bucket = createTokenBucket({ capacity: 10, refillPerSecond: 5 });
// Non-blocking: returns a Result
const r = bucket.tryAcquire(3);
if (r.ok) {
// 3 tokens consumed; r.value tokens remain
} else {
// denied; r.error.retryAfterMs ms until enough tokens accrue
}
// Blocking: waits (via injected sleep) until tokens are available
await bucket.acquire(2);
bucket.available(); // current tokens after lazy refill
输出 : createTokenBucket类型 TokenBucket, (中文(简体) ), TokenBucketConfig, (中文(简体) ), TokenBucketOptions, (中文(简体) ), RateLimitError。 。 。 .
被绑定的按键限制器
createKeyedLimiter 每个密钥都有一个私人的标志桶,并绑入活的
(c) 登记簿,并附有出入许可令。 万键默认匹配
由平衡引导者推广原型;生产呼叫器应设置
maxKeys 他们的正常身份空间和威胁模式.
import { createKeyedLimiter } from "@centient/resilience";
const limiter = createKeyedLimiter({
capacity: 60,
refillPerSecond: 1,
maxKeys: 5_000,
});
const result = limiter.tryAcquire(accountId);
if (!result.ok) {
// result.error.retryAfterMs tells the transport when this key may retry
}
limiter.available(accountId); // creates/promotes the key when it is absent
limiter.stats(); // { size, evictions, clockRegressions }
退出重置密钥的预算, 如果该密钥稍后返回 。 不给一个
无约束的攻击者控制值直接输入登记册:
身份、大小 maxKeys 因此,监测 stats().evictions。工作量
需要跨程序或持久执行的,需要一个呼叫者拥有的商店.
密钥按合同是条管弦;将复合维度编码为一
稳定的字符串,而不是为每个调用构建一个新的tuple或对象.
密钥外观是故意无阻. 不暴露隐私
桶子或(或) acquire():飞行中服务员本来可以在被驱逐后幸存
同一键得到一个新的桶, 将执行分成两个
独立预算。 使用 createTokenBucket() 单屏蔽时直接
桶是必需的原始.
输出 : createKeyedLimiter类型 KeyedLimiter, (中文(简体) ),
KeyedLimiterConfig, (中文(简体) ), KeyedLimiterStats。 。 。 .
与焦躁的后退
linear 复制 @centient/sdk 精确地重试时间表——基数 attempt * baseDelayMs 加快讯 [0, jitterRatio * baseDelayMs) (sdk 用户数) jitterRatio = 0.5)——使斯德克能以"入"来取取. exponential 实值 baseDelayMs * factor^(attempt-1),封顶 maxDelayMs同样的添加剂焦躁.
import { createBackoff } from "@centient/resilience";
// Linear (sdk-compatible): attempt 1 ∈ [1000, 1500), attempt 2 ∈ [2000, 2500)
const linear = createBackoff({ baseDelayMs: 1000 });
const delay = linear.delayFor(attempt); // sleep this many ms before retry
// Exponential with cap
const expo = createBackoff({
baseDelayMs: 100,
strategy: "exponential",
factor: 2,
maxDelayMs: 1000,
});
expo.baseFor(5); // 1000 (1600 capped)
// Deterministic jitter in tests
import { fixedRandom } from "@centient/resilience";
createBackoff({ baseDelayMs: 1000, random: fixedRandom(0) }).delayFor(1); // exactly 1000
baseFor(attempt) 财务报告和审定财务报表 maxFor(attempt) 揭开(封口)底部和全焦封口.
全焦 (无地板)
jitter: "full" 切换到 AWS “ 责任后退和慢跑 ” 满 变体 : delay = random() * min(maxDelayMs, baseDelayMs * factor^(attempt-1)) - 穿着制服 [0, cap), (中文(简体) ), 没有地板,可以是0。默认值(jitter: "additive")是不变的,所以现有的呼叫者没有区别.
当许多客户对同一上游退后时使用. 非零层重新组装出再试的舰队,并重新组装出引起出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出.
const backoff = createBackoff({
baseDelayMs: 500,
strategy: "exponential",
factor: 2,
maxDelayMs: 5_000,
jitter: "full",
});
backoff.delayFor(1); // ∈ [0, 500) — can be exactly 0
backoff.delayFor(3); // ∈ [0, 2000)
backoff.baseFor(3); // 2000 — in full mode the schedule value is the CAP, not the floor
jitterRatio 不适用于完全焦急(跨度来自调度值本身); 传递两个抛出,而不是默默地忽略一个.
累计延迟预算
重试链条最坏的情况是 总和 拖延时间. totalMaxFor(attempts) 报告确切的信封 -- sum(maxFor(1..attempts-1)),从 attempts 尝试入睡 attempts - 1 - 比粗糙更紧 (attempts - 1) * maxDelayMs 约束.
拨打预算时
const backoff = createBackoff({
baseDelayMs: 500, strategy: "exponential", factor: 2, maxDelayMs: 5_000,
jitter: "full",
attempts: 3, // the chain the budget covers
maxTotalDelayMs: 15_000, // must stay well inside a 30s tick
});
backoff.totalMaxFor(3); // 1500 (500 + 1000) — throws at construction if it exceeded the budget
backoff.budgetedAttempts; // 3 — withRetry refuses to run more than this
输出 : createBackoff类型 Backoff, (中文(简体) ), BackoffConfig, (中文(简体) ), BackoffStrategy, (中文(简体) ), BackoffJitter。 。 。 .
用可注入分类器重试
withRetry 运行 a 上的同步操作 Backoff 排程和重试 shouldRetry 上位接受 。 时间表、上游和 sleep 全部注射,所以在测试中循环是决定性的。 最后一次出错是在尝试被用尽后重新投放,没有改变——没有新的出错形状,因此现有的故障处理仍然具有权威性.
import { createBackoff, withRetry } from "@centient/resilience";
const backoff = createBackoff({
baseDelayMs: 500, strategy: "exponential", factor: 2,
maxDelayMs: 5_000, jitter: "full", attempts: 3, maxTotalDelayMs: 15_000,
});
const crystal = await withRetry(() => client.crystals.get(id), {
backoff, // attempts defaults to backoff.budgetedAttempts (3 here) — no need to repeat it
onRetry: ({ attempt, delayMs, error }) => log.warn("retry", { attempt, delayMs, error }),
});
attempts 默认返回已声明 budgetedAttempts,或者在没有宣布预算时3个——一次在时间表上说明链长。 传递一个值 上文 预算仍然投出;只有违约者才推迟执行时间表.
将每个调用一个逻辑操作包入- 将一个页码循环包入, 而不是每页取取取预算乘以页码.
分类是参数,不是政策
哪些错误值得重试 电话 失败域 。 一个客户骑出上游棕褐色 想要请求超时重试( 他们是棕色出的主要瞬间)和未知错误 没有 被重审(不可分类的失败可能是非理想的部分成功,并重玩重复写作). isTransientError 是被包装的默认 :
| 类 | 复试 |
|---|---|
超时( E)AbortError 编号 : TimeoutError 编号 : code: "TIMEOUT" /"请求超时") | 对 |
5x (中文(简体) )statusCode 编号 : status 编号 : response.status (单位:500) | 对 |
网络NETWORK_ERROR, (中文(简体) ), ECONNREFUSED, (中文(简体) ), ECONNRESET, (中文(简体) ), ETIMEDOUT(中文(简体) ) | 对 |
| 4xx (400–499) (英语), 包括409起CAS冲突 | 没有 |
Schema 验证 (ZodError(中文(简体) ) | 没有 |
其他一切(不详、非)Error 投掷) | 没有 |
一个状态代码,当存在时,胜过名称/代码/消息热度. 409是终端,因为盲目重播相同 expectedVersion 永远无法成功——呼叫者的比较和设置循环拥有冲突政策.
提供你自己的上游,或构建一个被包装的变体:
import { createTransientErrorPredicate, withRetry } from "@centient/resilience";
const shouldRetry = createTransientErrorPredicate({
isConflict: isCrystalVersionConflictError, // caller-owned conflicts, evaluated first
retryableCodes: ["EAI_AGAIN"], // extra transient codes
retryUnknown: false, // default; true only if every op is idempotent
});
await withRetry(op, { backoff, shouldRetry });
取消和整个链条的最后期限
signal 在每次尝试(一个已经吸收的信号从未开始操作)之前,在每次失败之前,以及 期间 每个后退睡眠——被注射的 sleep 缩短等待时间。 还交给了 op因此,合作行动在飞行中会松动,而不是跑去完成一个没人想要的结果.
timeoutMs 范围 整个链条 ——每一个尝试加上他们之间的每次睡眠——这是束缚的 attempts 无法表达 : attempts 限制你问了多少次,但只有最后期限限制你等待多久,因为尝试的持续时间是上游的业务.
const crystal = await withRetry((signal) => client.crystals.get(id, { signal }), {
backoff,
signal: request.signal, // caller cancellation — never retried
timeoutMs: 20_000, // covers every attempt AND every sleep
});
检查取消 在此之前 shouldRetry 从来没有看到失败, 并且从来没有被重审。 这不是一种便利——被取消的操作通常会因自己的流产形状出错而失败(pfetch 提高a DOMException 名称 AbortError),被包装的分类学读作可重试的超时),所以留待分类器处理,取消请求会 开始一个新的。 。 。 .
是什么表面是信号中止的原因 页:1而不是流产的事实 由附件中止的信号 withTimeout 带着那个电话 DeadlineExceededError 或者说 DeadlineTimerError,链条重投它不变。 将每次中止都取消 OperationAbortedError 当附在后面的最后期限过后,或者更糟糕的是,当其定时器失效而从未执行过最后期限时,将报告撤回的请求.
输出 : withRetry, (中文(简体) ), isTransientError, (中文(简体) ), createTransientErrorPredicate类型 ShouldRetry, (中文(简体) ), RetryConfig, (中文(简体) ), RetryAttemptInfo, (中文(简体) ), TransientErrorPredicateOptions。 。 。 .
暂停和取消
withTimeout 对同步操作规定一个时钟截止时间,并将操作交给 AbortSignal 这样它就可以停止在最后期限——或呼叫者——撤回利息的时刻拉动资源.
import { withTimeout, isDeadlineExceededError, isOperationAbortedError } from "@centient/resilience";
const body = await withTimeout((signal) => fetch(url, { signal }), { timeoutMs: 5_000 });
try {
await withTimeout((signal) => work(signal), { timeoutMs: 5_000, signal: req.signal });
} catch (e) {
if (isDeadlineExceededError(e)) metrics.timeouts++;
else if (isOperationAbortedError(e)) return; // the caller left
else throw e;
}
时间流过注射者 Sleep ——同一单等接合速率限制器和 withRetry 使用——所以这里没有读出墙上钟和用手进行测试来点燃最后期限,没有假定时器. systemSleep 当操作获胜时清除其待决的定时器,所以短操作的漫长期限不会使事件循环打开.
当最后期限或流产获胜时, withTimeout 拒绝 马上;它不等待操作通知。 只有慢事完成后发射的最后期限不是最后期限. 操作的信号被中止( 带有决定性错误作为它的 reason),其最终的拒绝被吸收,所以一个被放弃的操作从不露出无所处理的拒绝.
三个错误,因为它们意味着不同的东西
| 结果 | 错误 | 由 isTransientError? 吗? |
|---|---|---|
| 截止日期已过 | DeadlineExceededError | 对 - 这是一个超时 |
| 呼叫者信号中断 | OperationAbortedError | 永远 |
注射时 sleep 被拒绝 | DeadlineTimerError | 永远 - 它会重生 |
初分分为分. 最后期限是 尝试: 火势恰到好处, 中止是 打电话者的意图:重放会复活被明确取消的作品. 一个赤裸的 AbortController 都崩溃成一个 AbortError,这使得在重试回回路必须决定的时候,区别是无法恢复的.
第三是接合失败,而不是行动. Sleep合同是 页:1 - 流产初期,否则 ms ——因此拒绝(或同步抛出)意味着最后期限无法被执行. withTimeout 表面为 DeadlineTimerError,将拒绝值作为 cause,而不是吸收它:一个被吸收的人让呼叫等待一个可能永远不会解决的行动,这是一个默默地被删除的最后期限——比根本没有最后期限更糟糕,因为呼叫者相信他们有一个. 这是故意的 没有 报告为 DeadlineExceededError因为最后期限没有过去 说它会让你看到一个缓慢的上游 而不是断裂的接缝.
所有这三者都得到 name,通过 isDeadlineExceededError 编号 : isOperationAbortedError 编号 : isDeadlineTimerError,所以分类存活了两本 本包在一个依赖树上 instanceof 不会的.
组成两个
串行期限内每一次尝试的最后期限,信号线通过:
await withRetry(
(signal) => withTimeout((s) => fetch(url, { signal: s }), { timeoutMs: 2_000, signal }),
{ backoff, timeoutMs: 20_000 },
);
内心 DeadlineExceededError 是默认分类器重试的瞬间;外部截止结束链。 线索 signal 内在的呼唤就是让外部最后期限缩短飞行尝试的时间——同样的错误等级,相反的判决,由范围而不是形状决定.
输出 : withTimeout, (中文(简体) ), DeadlineExceededError, (中文(简体) ), OperationAbortedError, (中文(简体) ), DeadlineTimerError, (中文(简体) ), isDeadlineExceededError, (中文(简体) ), isOperationAbortedError, (中文(简体) ), isDeadlineTimerError, (中文(简体) ), neverAbortedSignal类型 TimeoutConfig。 。 。 .
缓存( LRU / TTL / SWR)
容量限制的 LRU 缓存,可选 TTL 和 stale- while- revalidate. 阅读被推广到最近使用;溢出则驱逐最近使用最少的人.
import { createCache } from "@centient/resilience";
// Plain LRU
const lru = createCache<string, number>({ maxSize: 100 });
lru.set("a", 1);
lru.get("a"); // { hit: true, status: "fresh", value: 1 }
// TTL + stale-while-revalidate
const cache = createCache<string, User>({
maxSize: 1000,
ttlMs: 60_000, // fresh for 60s
staleWhileRevalidateMs: 30_000, // then served stale for 30s more
});
const r = cache.get("u1");
if (r.hit && r.status === "stale") {
// serve r.value now, refresh in the background
void refreshUser("u1").then((u) => cache.set("u1", u));
}
get 返回时 { hit, status, value? } 地点 status 实值 "fresh" | "stale" | "expired"。误入或完全过期的条目是 { hit: false, status: "expired" }。 。 。 . stats() 暴露出被击中/出错/减压/过期计数器.
输出 : createCache类型 Cache, (中文(简体) ), CacheConfig, (中文(简体) ), CacheGetResult, (中文(简体) ), CacheEntryStatus, (中文(简体) ), CacheStats。 。 。 .
固定货币池
最多运行同步任务 concurrency 飞行中; 其余队列 FIFO. 可选 maxQueue 雨棚负载( 以 PoolRejectedError而不是任由积压增加.
import { createPool, PoolRejectedError } from "@centient/resilience";
const pool = createPool({ concurrency: 4 });
const results = await Promise.all(urls.map((u) => pool.run(() => fetch(u))));
await pool.onIdle(); // resolves when all work settles
// Bounded queue: reject when saturated
const bounded = createPool({ concurrency: 2, maxQueue: 10 });
try {
await bounded.run(task);
} catch (e) {
if (e instanceof PoolRejectedError) {
// shed load
}
}
pool.active, (中文(简体) ), pool.queued,以及 pool.stats() 报告占用情况.
输出 : createPool, (中文(简体) ), PoolRejectedError类型 Pool, (中文(简体) ), PoolConfig, (中文(简体) ), PoolStats。 。 。 .
共享原始出口
- 时钟 :
systemClock, (中文(简体) ),createManualClock类型Clock, (中文(简体) ),ManualClock - 睡觉:
systemSleep类型Sleep——单等接缝(TokenBucket.acquire, (中文(简体) ),withRetry, (中文(简体) ),withTimeout) (中文(简体) ). 签名为(ms, signal?) => Promise<void>; 中止睡眠 早决定而不是拒绝,因为什么是流产 表示 属于呼叫者,它知道是来自最后期限还是取消。 一个忽视的睡眠signal仍能满足这种需求, 拒绝不是合同的一部分,也不是取消的后路:拒绝的睡眠打破了接缝withTimeout说通过DeadlineTimerError而不是吸收它. - 随机 :
systemRandom, (中文(简体) ),fixedRandom, (中文(简体) ),sequenceRandom类型RandomSource - 结果 :
ok, (中文(简体) ),err, (中文(简体) ),isOk, (中文(简体) ),isErr类型Result, (中文(简体) ),Ok, (中文(简体) ),Err
许可证
麻省理工学院