未知项野外指南 · 动手实现之前
← 全部示例

指定一个参考实现

当已有实现恰好承载着你想要的行为时,先让 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 客户端里重新实现同样的语义——但先给我一份语义地图,让我确认你确实理解了它。

Claude 产出的内容

语义地图:rate-limiter → Acme API 客户端

等待确认

这个 crate 做了什么、每项行为如何映射到 TypeScript,以及所有无法逐字直译的地方。在你对本页签字确认之前,一行代码都不会实现。

vendor/rate-limiter(Rust,612 行,3 个模块) 目标 acme-web 中的 src/api/retry/ 已读 bucket.rs · backoff.rs · budget.rs · 14 个测试
1 这个 crate 实际做了什么
  • 令牌桶准入bucket.rs):每个发出的请求从桶里取走 1 个令牌;桶以 rate_per_sec = 8 的速率补充,上限 burst_capacity = 40。桶初始是满的,所以冷启动的客户端可以瞬间突发 40 个请求。
  • 补充是惰性的,且做整数截断:只有请求到达时才铸造令牌,按流逝纳秒数 × 速率 ÷ 10⁹ 计算。小数令牌从不存储——取而代之,last_refill 只在铸造出至少一个完整令牌时才前移,因此不足一个令牌的流逝时间会累积到下一次。
  • 去相关抖动退避backoff.rs):遇到可重试的失败时,下一次延迟从 [base, min(prev × 3, cap)] 中均匀抽取,其中 base = 250mscap = 30s。每次抽取的结果都是下一次的种子——延迟是游走的而非同步翻倍,这让客户端在故障恢复后彼此错开节奏。
  • 重试预算budget.rs):第二个独立的桶。每个首次尝试即成功的请求存入 1;每次重试取出 10。余额不足以支付一次取出时,重试被完全拒绝(首次请求仍会发出)。这把持续故障期间的重试放大限制在约 10%。
  • 失败分类是调用方的职责:crate 从不检查错误本身。它只暴露 acquire()next_delay()try_withdraw(),什么算可重试由调用方决定。TS 移植版保留这条边界——Acme 在 src/api/errors.ts 里的 isRetryable() 仍是唯一事实来源。
2 并排对照:Rust 源码 → 拟议的 TypeScript

高亮区域在两栏之间一一对应——悬停其中一处,会同时点亮它的对应代码和相应的陷阱注记。

对照 A / 3

惰性补充——整数截断必须在浮点世界里活下来

vendor/rate-limiter/src/bucket.rs:41Rust
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;
    }
}
src/api/retry/tokenBucket.ts(拟议)TS
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;
  }
}
1时钟回拨。saturating_duration_since 会把负的流逝时间钳制为零。Date.now() 在 NTP 校时下可能向回跳;移植版改用单调时钟 performance.now()并且保留 Math.max(0, …) 作为双保险。
2整数与浮点运算。Rust 的 u64 除法会截断;JS 的除法不会。Math.floor 恢复了截断语义。用毫秒替代纳秒是安全的:在 rate = 8 下,elapsedMs * 8 远小于 2⁵³,不会损失精度。
3承重的守卫条件。last_refill 只在铸造出完整令牌时才前移。去掉这个守卫(很容易被当成“简化”)会让每次调用都悄悄丢掉不足一个令牌的进度——低速率下、轮询频繁时,桶会永远补不上。此处原样保留,并附一个回归测试。
对照 B / 3

去相关抖动——闭区间取值,游走的种子

vendor/rate-limiter/src/backoff.rs:27Rust
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)
}
src/api/retry/backoff.ts(拟议)TS
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;
}
4饱和乘法。saturating_mul(3) 防的是 u64 溢出。在 JS 里这不可能溢出——capMs = 30_000 早在乘积逼近 2⁵³ 之前就把它限住了——所以这个守卫被有意去掉(见第 3 节“已省去”一栏)。
5闭区间与开区间。Rust 的 lo..=hi 两端都取得到。朴素写法 lo + random() * (hi - lo) 永远取不到 hiMath.floor 里的 + 1 恢复了闭区间——一字之差的 bug 高发点,特意点出来供你否决或放行。
6有状态的种子。每次抽取的值成为下一轮的 prev——正是这一点让它是去相关抖动,而非普通的指数退避加抖动。成功后 reset()prevDelayMs 还原为 baseMs,与 crate 的 Backoff::reset 一致。this.random 可注入,便于确定性测试(crate 的测试用的是带种子的 SmallRng)。
对照 C / 3

重试预算——事件循环用不上的互斥锁(但有一个陷阱)

vendor/rate-limiter/src/budget.rs:58Rust
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
    }
}
src/api/retry/budget.ts(拟议)TS
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;
}
7线程安全 → 事件循环。crate 里的 Mutex 之所以存在,是因为 Rust 调用方会从工作线程发起取款。Acme 的客户端跑在单一事件循环上,锁因此消失——但它提供的原子性必须靠约定保留下来tryWithdraw 全程同步,我还会加一条 eslint no-await-in-budget 边界注释,外加一个断言该方法从不返回 Promise 的测试。
8检查与扣款保持连体。两侧结构相同。这里的失败模式不是数据竞争——而是将来某次重构在检查和扣款之间插入一个 await(比如为了打日志),让两个在途重试都通过了检查。
9拒绝,绝不排队。预算为空时 crate 立即返回 false——请求要么作为首次尝试发出,要么快速失败。移植版绝不能“好心地”把重试排进队列稍后执行;那会重新造出预算机制本要防住的重试风暴。
3 行为清单:原样保留 / 有意改动 / 已省去
原样保留
  • 补充截断 + 守卫——只铸造整数个令牌;lastRefill 仅在铸造时前移
  • 抖动公式——在 [base, min(prev×3, cap)] 上均匀取值,两端闭合
  • 预算经济学——首试成功 +1,每次重试 −10,上限 1000
  • 桶初始为满——40 个请求的冷启动突发是刻意为之(与 crate 测试 burst_at_t0 一致)
  • 错误由调用方分类——限流器从不检查失败本身
有意改动
  • Instantperformance.now()——两者都是单调时钟;速率 8/s 下毫秒精度足够
  • u64 纳秒 → number 毫秒——所有乘积可证明 < 2⁵³;Math.floor 重演整数除法
  • Mutex<Budget> → 普通字段——原子性靠“全程同步”约定 + 测试保证
  • SmallRng → 注入的 random()——默认 Math.random,测试里用带种子的桩
已省去(不需要)
  • saturating_mul 溢出守卫——毫秒量级下 cap 生效之后不可能触达
  • Send + Sync 实现、Arc 克隆——没有需要跨线程共享的场景
  • tokio/async-std 特性开关——移植版天然与运行时无关
  • Prometheus 计数器——Acme 改用 telemetry.track() 上报;埋点位置保留
4 边界情况:两侧的预期行为
边界情况Rust crateTypeScript 移植版匹配度
时钟偏移
系统时钟在会话中途回拨 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 需要区分“被限流”和“离线”。如果你更想保留裸布尔值,请标记这一行。

5 签字确认

你一声令下,随时开始移植

回复 “semantics confirmed”(语义已确认),我就会实现 tokenBucket.tsbackoff.tsbudget.ts,并先把 crate 的 14 个测试翻译过来。也可以纠正上面任意一条——引用它的编号(如“注记 5”“预算耗尽那一行”),我会在写代码前先修订这份地图。