當一個現有的實作已經編碼了你想要的行為,在移植任何一行程式碼之前,先讓 Claude 證明它理解了參考實作——作為一個可審核的產物。
vendor/rate-limiter 這個 Rust crate 實作了我想要的精確退避行為。讀懂它,然後在我們的 TypeScript API client 重新實作相同的語意——但先給我一份語意對照表,讓我確認你理解了。
這個 crate 做什麼、每個行為如何對應到 TypeScript、以及移植時無法直接翻譯的每一處。在你簽核這一頁之前,不會實作任何東西。
bucket.rs):每個送出的請求從一個桶子提取 1 個 token,桶子以 rate_per_sec = 8 的速度補充到 burst_capacity = 40。桶子一開始是滿的,所以一個冷啟動的 client 可以瞬間突發 40 個請求。last_refill 只在至少鑄造一個完整 token 時才前進,所以不足一個 token 的時間會結轉。backoff.rs):可重試的失敗時,下次延遲從 [base, min(prev × 3, cap)] 均勻隨機抽取,base = 250ms,cap = 30s。每次抽取成為下次的種子——延遲是遊走的而不是鎖步倍增,這能在故障後讓 client 們去同步化。budget.rs):第二個獨立的桶子。每個成功的首次嘗試請求存入 1;每個重試嘗試提取 10。當餘額不夠提取時,完全拒絕重試(首次嘗試請求照樣送出)。這在持續故障期間把重試放大率限制在 ~10%。acquire()、next_delay()、try_withdraw(),由呼叫者決定什麼算可重試。TS 移植保留這個邊界——Acme 的 isRetryable() 在 src/api/errors.ts 仍是唯一真相來源。高亮區域跨欄對應——hover 任一處會同時亮起它對應的另一側和下方註解。
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 來自 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;
}
}
saturating_duration_since 把負的經過時間夾到零。Date.now() 在 NTP 校正時會倒跳;移植版使用單調的 performance.now() 並且保留 Math.max(0, …) 作為同等的保護。u64 除法截斷;JS 除法不會。Math.floor 恢復截斷。毫秒而非奈秒是安全的:在 rate = 8 時,elapsedMs * 8 遠低於 2⁵³,所以沒有精度損失。last_refill 只在鑄造完整 token 時前進。移除這個 guard(一個容易的「簡化」)會悄悄地在每次呼叫時丟棄不足一個 token 的進度——在低速率下,頻繁輪詢會讓桶子永遠無法補充。完全保留,加上一個回歸測試。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⁵³ 之前很久就限制了乘積——所以這個 guard 被刻意移除(見 §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 {
// 無鎖:單執行緒 event loop7 —
// 但檢查和扣款之間不能有 await。
this.depositDrip(this.clock());
if (this.balance >= WITHDRAW_COST) {
this.balance -= WITHDRAW_COST;8
return true;
}
return false9;
}
Mutex 存在是因為 Rust 呼叫者從 worker 執行緒提取。Acme 的 client 跑在一個 event loop 上,所以鎖消失了——但它提供的原子性必須透過慣例保留:tryWithdraw 端到端都是同步的,我會加一個 eslint no-await-in-budget 邊界註解加上一個測試確認這個方法永遠不回傳 Promise。await(例如為了 log),讓兩個 in-flight 的重試都通過檢查。false——請求要嘛作為首次嘗試送出,要嘛快速失敗。移植版絕對不能「好心地」把重試排隊等之後——那會重建預算本來要防止的重試風暴。lastRefill 只在鑄造時前進[base, min(prev×3, cap)] 上均勻分布,兩端都包含burst_at_t0)Instant → performance.now()——都是單調的;ms 解析度在 8/s 速率下足夠u64 奈秒 → number 毫秒——所有乘積可證明 < 2⁵³;Math.floor 重播整數除法Mutex<Budget> → 普通欄位——原子性靠同步慣例 + 測試SmallRng → 可注入的 random()——預設是 Math.random,測試中用有種子的 stubsaturating_mul 溢位 guard——套用 cap 後在 ms 範圍不可達Send + Sync impl、Arc 複製——沒有執行緒可以共享tokio/async-std feature flag——移植版設計上與 runtime 無關telemetry.track();hook 點保留| 邊界情況 | Rust crate | TypeScript 移植 | 匹配 |
|---|---|---|---|
| 時鐘偏移 系統時鐘在工作階段中倒退 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 需要區分「被限流」和「離線」。如果你傾向保留裸布林值,請標記這一列。
回覆「語意確認」,我會實作 tokenBucket.ts、backoff.ts 和 budget.ts,先翻譯 crate 的 14 個測試。或者糾正上面的任何一列——引用它的編號(如「註解 5」、「預算耗盡列」),我會在寫程式碼之前修改對照表。