认清你的未知 · 实施前
← 全部示例

指向参考实现

当已有实现已经编码了你想要的行为时,让 Claude 在移植任何一行代码之前,先以可审查的成品形式证明它理解了参考实现。

提示词
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,…) 作为同样的第二道防线。 一致
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 需要区分「限流」与「离线」。若你更希望保留裸布尔值,请标记此行。

5 签字确认

你准备好了就开始移植

回复「语义已确认」,我会先实现 tokenBucket.tsbackoff.tsbudget.ts,并先翻译 crate 的 14 个测试。或更正上面任何一行——引用其编号(如「注释 5」「预算耗尽行」),我会在写代码前修订映射表。