当已有实现已经编码了你想要的行为时,让 Claude 在移植任何一行代码之前,先以可审查的成品形式证明它理解了参考实现。
vendor/rate-limiter 中的这个 Rust crate 实现了我想要的精确退避行为。阅读它并在我们的 TypeScript API 客户端中重新实现相同的语义——但首先给我一份语义映射表,以便我确认你理解了它。
该 crate 做什么、每种行为如何映射到 TypeScript,以及移植无法进行字面翻译的每一处。在你签字确认之前,不会实施任何内容。
bucket.rs):每个出站请求从桶中取出 1 个令牌,桶以 rate_per_sec = 8 的速率补充,上限为 burst_capacity = 40。桶初始为满,因此冷启动客户端可瞬间突发 40 个请求。last_refill 仅在至少铸造一个完整令牌时才前进,因此不足一个令牌的经过时间会累积到下次。backoff.rs):在可重试失败时,下次延迟从 [base, min(prev × 3, cap)] 均匀抽取,其中 base = 250ms、cap = 30s。每次抽取成为下次的种子——延迟会游走而非同步翻倍,从而在故障后使客户端去同步。budget.rs):第二个独立的桶。每次首次尝试成功的请求存入 1;每次重试尝试取出 10。当余额不足以支付取出时,完全拒绝重试(首次尝试请求仍会发出)。这在持续故障期间将重试放大限制在约 10%。acquire()、next_delay()、try_withdraw(),由调用方决定什么算可重试。TS 移植保持这一边界——Acme 在 src/api/errors.ts 中的 isRetryable() 仍是唯一真相来源。高亮区域在列间一一对应——悬停一处可点亮其对应项及映射的注意事项。
fn refill(&mut self, now: Instant) {
let elapsed = now
.saturating_duration_since1(self.last_refill);
let new_tokens = elapsed.as_nanos() as u64
* self.rate_per_sec as u64
/ 1_000_000_0002;
if new_tokens > 03 {
self.tokens = (self.tokens + new_tokens)
.min(self.burst_capacity);
self.last_refill = now;
}
}
private refill(now: number): void {
// now comes from performance.now(), not Date.now()
const elapsedMs = Math.max(0, now - this.lastRefill)1;
const newTokens = Math.floor(
(elapsedMs * this.ratePerSec) / 1000
)2;
if (newTokens > 03) {
this.tokens = Math.min(
this.tokens + newTokens,
this.burstCapacity
);
this.lastRefill = now;
}
}
saturating_duration_since 将负的经过时间钳制为零。Date.now() 在 NTP 校正下可能回跳;移植使用单调的 performance.now()并保留 Math.max(0, …) 作为双保险匹配。u64 除法截断;JS 除法不截断。Math.floor 恢复截断。用毫秒而非纳秒是安全的:在 rate = 8 时,elapsedMs * 8 远低于 2⁵³,无精度损失。last_refill 仅在铸造完整令牌时才前进。去掉这一守卫(一种容易的「简化」)会在每次调用时静默丢弃不足一个令牌的进度——在低速率下,频繁轮询时桶将永远无法补充。完全保留,并加上回归测试。fn next_delay(&mut self) -> Duration {
let hi = (self.prev_delay_ms.saturating_mul(3)4)
.min(self.cap_ms);
let lo = self.base_ms;
let ms = self.rng
.gen_range(lo..=hi.max(lo))5;
self.prev_delay_ms = ms;6
Duration::from_millis(ms)
}
nextDelay(): number {
const hi = Math.min(this.prevDelayMs * 34, this.capMs);
const lo = this.baseMs;
const span = Math.max(hi, lo) - lo;
const ms = lo + Math.floor(
this.random() * (span + 1)
)5;
this.prevDelayMs = ms;6
return ms;
}
saturating_mul(3) 防止 u64 溢出。在 JS 中不会溢出——capMs = 30_000 在到达 2⁵³ 之前就限制了乘积——因此有意去掉该守卫(见 §3「舍弃」列)。lo..=hi 包含两端点。朴素的 lo + random() * (hi - lo) 永远不会得到 hi。Math.floor 内的 + 1 恢复了闭区间性——一个一字之差的 bug 磁铁,特意标出以便你否决或认可。prev——这正是它去相关而非普通指数退避加抖动的原因。reset() 在成功时恢复 prevDelayMs = baseMs,与 crate 的 Backoff::reset 一致。this.random 可注入以实现确定性测试(crate 在测试中使用带种子的 SmallRng)。pub fn try_withdraw(&self) -> bool {
let mut b = self.inner.lock().unwrap()7;
b.deposit_drip(Instant::now());
if b.balance >= WITHDRAW_COST {
b.balance -= WITHDRAW_COST;8
true
} else {
false // refuse retry, don’t queue9
}
}
tryWithdraw(): boolean {
// no lock: single-threaded event loop7 —
// but NO await between check and debit.
this.depositDrip(this.clock());
if (this.balance >= WITHDRAW_COST) {
this.balance -= WITHDRAW_COST;8
return true;
}
return false9;
}
Mutex 存在是因为 Rust 调用方从工作线程取出。Acme 客户端运行在单一事件循环上,因此锁消失——但它提供的原子性必须通过约定来保持:tryWithdraw 端到端同步,我会添加 eslint no-await-in-budget 边界注释,并测试该方法从不返回 Promise。await(例如记录日志),让两个进行中的重试都通过检查。false——请求要么作为首次尝试发出,要么快速失败。移植不得「好心」将重试入队等待;那会重建预算所要防止的重试风暴。lastRefill 仅在铸造时前进[base, min(prev×3, cap)] 上均匀分布,两端闭区间burst_at_t0)Instant → performance.now()——两者均单调;在 8/s 速率下毫秒精度足够u64 纳秒 → number 毫秒——所有乘积可证 < 2⁵³;Math.floor 复现整数除法Mutex<Budget> → 普通字段——通过仅同步约定 + 测试保证原子性SmallRng → 注入的 random()——默认 Math.random,测试中用带种子的桩saturating_mul 溢出守卫——在毫秒范围内应用 cap 后不可达Send + Sync 实现、Arc 克隆——没有跨线程共享的需求tokio/async-std 特性标志——移植在构造上运行时无关telemetry.track() 上报;保留挂钩点| 边界情况 | Rust crate | TypeScript 移植 | 匹配 |
|---|---|---|---|
| 时钟偏移 会话中途系统时钟回拨 5 秒 |
Instant 单调——不受影响。saturating_duration_since 是第二道防线。 |
performance.now() 单调——不受影响。保留 Math.max(0,…) 作为同样的第二道防线。 |
一致 |
| t=0 突发 新客户端一次性发出 45 个请求 |
前 40 个立即准入(桶初始为满);41–45 在补充前被拒绝。Crate 测试:burst_at_t0。 |
相同:40 个准入,5 个以 RateLimited 拒绝。移植测试逐字复现 crate 的 fixture 数值。 |
一致 |
| 预算耗尽 持续 60 秒 100% 失败 |
约 10 次重试后余额降至 0;进一步重试被拒绝,首次尝试继续。重试率稳定在存入÷成本 = 成功率的 10%(此处为 0)。 | 相同经济学、相同稳定点。差异:拒绝以 RetryBudgetExhausted 错误呈现,使 Acme 上传队列可显示「重新连接」而非静默失败。 |
等价* |
| 慢速滴漏 速率 8/s 但每 20ms 轮询一次(每次 0.16 令牌) |
守卫将不足一个令牌的时间向前累积;无论轮询节奏如何,约每 125ms 铸造一个令牌。 | 相同,通过保留的 newTokens > 0 守卫(对照 A,注释 3)。回归测试断言 20ms 轮询下的铸造节奏。 |
一致 |
| 达到上限的延迟 第 10 次连续失败 |
抽取范围向 [250ms, 30s] 收缩;延迟保持 ≤ 30s 但仍抖动——绝非固定 30s(避免客户端重新同步)。 |
相同边界、相同非退化抖动——用带种子的 RNG 复现 crate 的 cap_still_jitters 测试向量验证。 |
一致 |
*「等价」= 相同决策、不同表面。crate 返回裸 false;移植将其包装为类型化错误,因为 Acme UI 需要区分「限流」与「离线」。若你更希望保留裸布尔值,请标记此行。
回复「语义已确认」,我会先实现 tokenBucket.ts、backoff.ts 和 budget.ts,并先翻译 crate 的 14 个测试。或更正上面任何一行——引用其编号(如「注释 5」「预算耗尽行」),我会在写代码前修订映射表。