当已有实现恰好承载着你想要的行为时,先让 Claude 拿出一份可审阅的成果,证明它真的读懂了参考实现——然后才允许它移植任何一行代码。
This Rust crate in vendor/rate-limiter implements the exact backoff behavior I want. Read it and reimplement the same semantics in our TypeScript API client — but first show me a semantics map so I can confirm you understood it.
中文大意: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,…) 作为同样的第二道防线。 |
identical 完全一致 |
| t=0 时的突发 新客户端一次性发出 45 个请求 |
前 40 个立即放行(桶初始为满);第 41–45 个在补充前被拒绝。crate 测试:burst_at_t0。 |
相同:放行 40 个,5 个以 RateLimited 被拒。移植版测试逐字复用 crate 的用例数据。 |
identical 完全一致 |
| 预算耗尽 持续 60 秒 100% 失败 |
余额约 10 次重试后降到 0;后续重试被拒绝,首次请求继续发出。重试率稳定在存入÷成本 = 成功率的 10%(此处为 0)。 | 同样的经济学,同样的稳定点。差别在于:拒绝以 RetryBudgetExhausted 错误浮出,让 Acme 的上传队列可以显示“重连中”而不是静默失败。 |
equivalent* 等价* |
| 缓慢滴灌 速率 8/s,但每 20ms 轮询一次(每次 0.16 个令牌) |
守卫把不足一个令牌的时间累积下去;无论轮询节奏如何,约每 125ms 铸出一个令牌。 | 相同,靠原样保留的 newTokens > 0 守卫(对照 A,注记 3)。回归测试断言 20ms 轮询下的铸造节奏。 |
identical 完全一致 |
| 触顶延迟 连续第 10 次失败 |
取值范围收敛到 [250ms, 30s];延迟保持 ≤ 30s 但仍带抖动——绝不会固定在 30s(避免客户端重新同步)。 |
同样的边界,同样不退化的抖动——用带种子的 RNG 重放 crate 的 cap_still_jitters 测试向量验证。 |
identical 完全一致 |
* “equivalent”(等价)= 决策相同、外在形式不同。crate 直接返回裸的 false;移植版把它包装成带类型的错误,因为 Acme 的 UI 需要区分“被限流”和“离线”。如果你更想保留裸布尔值,请标记这一行。
回复 “semantics confirmed”(语义已确认),我就会实现 tokenBucket.ts、backoff.ts 和 budget.ts,并先把 crate 的 14 个测试翻译过来。也可以纠正上面任意一条——引用它的编号(如“注记 5”“预算耗尽那一行”),我会在写代码前先修订这份地图。