gu-log SP-251 繁體中文翻譯版。原作:Thariq (@trq212)Know your unknownsEnglish
認識你的未知 · 實作前
← 所有範例

指向參考實作

當一個現有的實作已經編碼了你想要的行為,在移植任何一行程式碼之前,先讓 Claude 證明它理解了參考實作——作為一個可審核的產物。

Prompt
vendor/rate-limiter 這個 Rust crate 實作了我想要的精確退避行為。讀懂它,然後在我們的 TypeScript API client 重新實作相同的語意——但先給我一份語意對照表,讓我確認你理解了。
Claude 產出的內容

語意對照表:rate-limiter → Acme API client

待確認

這個 crate 做什麼、每個行為如何對應到 TypeScript、以及移植時無法直接翻譯的每一處。在你簽核這一頁之前,不會實作任何東西。

來源 vendor/rate-limiter(Rust,612 行,3 個模組) 目標 acme-web 的 src/api/retry/ 已讀 bucket.rs · backoff.rs · budget.rs · 14 個測試
1 這個 crate 實際上做什麼
  • Token bucket 准入bucket.rs):每個送出的請求從一個桶子提取 1 個 token,桶子以 rate_per_sec = 8 的速度補充到 burst_capacity = 40。桶子一開始是滿的,所以一個冷啟動的 client 可以瞬間突發 40 個請求。
  • 補充是 lazy 的且用整數截斷:token 只在請求到達時鑄造,從 經過的奈秒數 × rate ÷ 10⁹ 計算。零頭 token 永遠不儲存——而是 last_refill 只在至少鑄造一個完整 token 時才前進,所以不足一個 token 的時間會結轉。
  • 去相關的抖動退避backoff.rs):可重試的失敗時,下次延遲從 [base, min(prev × 3, cap)] 均勻隨機抽取,base = 250mscap = 30s。每次抽取成為下次的種子——延遲是遊走的而不是鎖步倍增,這能在故障後讓 client 們去同步化。
  • 重試預算budget.rs):第二個獨立的桶子。每個成功的首次嘗試請求存入 1;每個重試嘗試提取 10。當餘額不夠提取時,完全拒絕重試(首次嘗試請求照樣送出)。這在持續故障期間把重試放大率限制在 ~10%。
  • 失敗分類是呼叫者的工作:crate 從不檢查錯誤。它暴露 acquire()next_delay()try_withdraw(),由呼叫者決定什麼算可重試。TS 移植保留這個邊界——Acme 的 isRetryable()src/api/errors.ts 仍是唯一真相來源。
2 並排對照:Rust 原始碼 → 提議的 TypeScript

高亮區域跨欄對應——hover 任一處會同時亮起它對應的另一側和下方註解。

對 A / 3

Lazy 補充——整數截斷必須在浮點數世界存活

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 來自 performance.now(),不是 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整數 vs 浮點數運算。Rust 的 u64 除法截斷;JS 除法不會。Math.floor 恢復截斷。毫秒而非奈秒是安全的:在 rate = 8 時,elapsedMs * 8 遠低於 2⁵³,所以沒有精度損失。
3承重的 guard。last_refill 只在鑄造完整 token 時前進。移除這個 guard(一個容易的「簡化」)會悄悄地在每次呼叫時丟棄不足一個 token 的進度——在低速率下,頻繁輪詢會讓桶子永遠無法補充。完全保留,加上一個回歸測試。
對 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_0002⁵³ 之前很久就限制了乘積——所以這個 guard 被刻意移除(見 §3,「已移除」欄)。
5包含 vs 排除上界的範圍。Rust 的 lo..=hi 包含兩端。天真的 lo + random() * (hi - lo) 永遠不會產生 hiMath.floor 裡的 + 1 恢復了包含上界——一個一字元的 bug 磁鐵,特別標出來讓你可以否決或認可。
6有狀態的種子。每次抽取成為下次迭代的 prev——這就是讓它去相關而非單純指數退避加抖動的原因。reset() 在成功時把 prevDelayMs 恢復成 baseMs,對應 crate 的 Backoff::resetthis.random 是可注入的用於確定性測試(crate 在測試中用有種子的 SmallRng)。
對 C / 3

重試預算——event loop 不需要的 mutex(有一個陷阱)

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 {
  // 無鎖:單執行緒 event loop7
  // 但檢查和扣款之間不能有 await。
  this.depositDrip(this.clock());
  if (this.balance >= WITHDRAW_COST) {
    this.balance -= WITHDRAW_COST;8
    return true;
  }
  return false9;
}
7執行緒安全 → event loop。crate 的 Mutex 存在是因為 Rust 呼叫者從 worker 執行緒提取。Acme 的 client 跑在一個 event loop 上,所以鎖消失了——但它提供的原子性必須透過慣例保留tryWithdraw 端到端都是同步的,我會加一個 eslint no-await-in-budget 邊界註解加上一個測試確認這個方法永遠不回傳 Promise。
8檢查後扣款保持融合。兩邊形狀相同。這裡的失敗模式不是資料競爭——而是未來的重構在檢查和扣款之間插入一個 await(例如為了 log),讓兩個 in-flight 的重試都通過檢查。
9拒絕,永遠不排隊。預算空了時 crate 立刻回傳 false——請求要嘛作為首次嘗試送出,要嘛快速失敗。移植版絕對不能「好心地」把重試排隊等之後——那會重建預算本來要防止的重試風暴。
3 行為:原樣保留 / 刻意修改 / 已移除
原樣保留
  • 補充截斷 + guard——只有完整 token;lastRefill 只在鑄造時前進
  • 抖動公式——在 [base, min(prev×3, cap)] 上均勻分布,兩端都包含
  • 預算經濟學——首次嘗試成功 +1,重試 -10,上限 1000
  • 桶子一開始是滿的——40 個請求的冷啟動突發是刻意的(對應 crate 測試 burst_at_t0
  • 呼叫者分類錯誤——limiter 從不檢查失敗
刻意修改
  • Instantperformance.now()——都是單調的;ms 解析度在 8/s 速率下足夠
  • u64 奈秒 → number 毫秒——所有乘積可證明 < 2⁵³;Math.floor 重播整數除法
  • Mutex<Budget> → 普通欄位——原子性靠同步慣例 + 測試
  • SmallRng → 可注入的 random()——預設是 Math.random,測試中用有種子的 stub
已移除(不需要)
  • saturating_mul 溢位 guard——套用 cap 後在 ms 範圍不可達
  • Send + Sync impl、Arc 複製——沒有執行緒可以共享
  • tokio/async-std feature flag——移植版設計上與 runtime 無關
  • Prometheus counter——Acme 改用 telemetry.track();hook 點保留
4 邊界情況:兩邊的預期行為
邊界情況Rust crateTypeScript 移植匹配
時鐘偏移
系統時鐘在工作階段中倒退 5 秒
Instant 是單調的——不受影響。saturating_duration_since 是第二道防線。 performance.now() 是單調的——不受影響。Math.max(0,…) 作為同樣的第二道防線保留。 一致
t=0 時突發
全新 client 一次發 45 個請求
前 40 個立即准入(桶子一開始滿);41-45 被拒絕直到補充。Crate 測試:burst_at_t0 相同:40 個准入,5 個被拒絕並回傳 RateLimited。移植測試原封不動重播 crate 的 fixture 數字。 一致
預算耗盡
持續 60 秒的 100% 失敗
餘額在 ~10 次重試後耗盡到 0;之後的重試被拒絕,首次嘗試繼續。重試率穩定在 存入/成本 = 成功率的 10%(這裡是 0)。 相同的經濟學,相同的穩定點。差異:拒絕會表面化為 RetryBudgetExhausted 錯誤,這樣 Acme 的上傳佇列可以顯示「重新連線中」而非靜默失敗。 等效*
慢速滴入
rate 8/s 但每 20ms 輪詢一次(0.16 token/次輪詢)
guard 把不足一個 token 的時間結轉;無論輪詢節奏如何,每 ~125ms 鑄造一個 token。 相同,靠保留的 newTokens > 0 guard(對 A,註解 3)。回歸測試在 20ms 輪詢下斷言鑄造節奏。 一致
cap 時的延遲
第 10 次連續失敗
抽取範圍趨向 [250ms, 30s];延遲保持 ≤ 30s 但仍有抖動——永遠不是固定的 30s(避免 client 們重新同步)。 相同的邊界,相同的非退化抖動——用有種子的 RNG 重播 crate 的 cap_still_jitters 測試向量驗證。 一致

*「等效」= 相同的決定,不同的表面。Crate 回傳裸 false;移植版把它包成型別化的錯誤,因為 Acme 的 UI 需要區分「被限流」和「離線」。如果你傾向保留裸布林值,請標記這一列。

5 簽核

準備好了就開始移植

回覆「語意確認」,我會實作 tokenBucket.tsbackoff.tsbudget.ts,先翻譯 crate 的 14 個測試。或者糾正上面的任何一列——引用它的編號(如「註解 5」、「預算耗盡列」),我會在寫程式碼之前修改對照表。