a6ced32d45
封測事故(Evan):daemon 逐檔萃取上傳、每個檔在雲端產生一次工作流執行, 690 個檔在自己的免費 CF 帳號上短時間內衝出 1,070 次寫入,撞上免費上限 1,000, 額度爆掉、只有 8 個檔成功。雲端那一半(紀錄改走資料層 API)已修好, daemon 這一半原本完全沒有節奏——本次補齊四件事: 1. 上傳節奏(direct_pacing.go):單輪最多處理 MaxEventsPerRun 個事件 (預設 25)、每次觸發雲端前節流 700ms;一輪掃到的事件依檔案 mtime 由新到舊排序,今天寫的永遠優先,積壓慢慢消化不擋日常使用。 2. 額度用完講人話(quota.go):偵測到 Workers AI「10,000 neurons」/ 「4006」等已知上游訊號後,換成三句話(今天已整理幾份/可換模型或 升級 Cloudflare/不花錢也沒關係、今天或明天早上 8:00 會自動恢復), 不出現裸露的錯誤碼;同帳號同輪與下一輪都不再繼續撞牆 (quotaState 全域冷卻,跨資料夾/跨程序重啟持續,直到台灣時間 早上 8:00 額度重置)。 3. 斷點續傳:每個事件處理完立刻寫回 manifest(不再等整輪跑完才存一次), process 被殺掉重開只會接著做真正還沒完成的部分;removed 事件另外 用 preScanEntries 快照保護,下架失敗時不會被其他事件的存檔動作 誤標成「已完成」而永遠不再重試。 4. 同內容多格式去重(scan.go):同一批來源轉出的多種格式(如 leo 給的 資料集 27,164 檔=9,045 md+9,044 json+9,043 html,md/json 同檔名 主幹)依檔名主幹分組,只留優先序最高的一份進事件管線,其餘標記在 DuplicateFormats(不吃三倍額度)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
155 lines
5.1 KiB
Go
155 lines
5.1 KiB
Go
// direct_quota_test.go — 額度用完要說人話並降速(2026-08-07 task 2)端到端驗證。
|
||
//
|
||
// 模擬 wiki mistakes.md 2026-08-06 記錄的真實上游狀況:Workers AI 每日免費額度
|
||
// 10,000 neurons 用完,HTTP 502,訊息含「4006: you have used up your daily free
|
||
// allocation of 10,000 neurons」。驗收:畫面(status.json)出現三句話,
|
||
// 沒有裸露的錯誤碼;且同一輪/下一輪不再繼續撞同一面牆。
|
||
package collector
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// quotaExhaustedServer 對任何 /portal/daemon/extract 請求都回上游額度用完的原始錯誤。
|
||
// calls 只計「真的萃取」請求(body.text 非空)——ProbeWorkersAI 每輪固定會探測一次
|
||
// (送空 text,不燒 LLM 額度,見 probe_workersai.go),不是本測試要驗的「有沒有繼續撞牆」。
|
||
func quotaExhaustedServer(t *testing.T, calls *int) *httptest.Server {
|
||
t.Helper()
|
||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
var body struct {
|
||
Text string `json:"text"`
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||
if strings.TrimSpace(body.Text) != "" {
|
||
*calls++
|
||
}
|
||
w.WriteHeader(http.StatusBadGateway) // 實據:HTTP 502
|
||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||
"error": "4006: you have used up your daily free allocation of 10,000 neurons",
|
||
})
|
||
}))
|
||
}
|
||
|
||
func TestDirect_QuotaExhausted_ShowsHumanMessageNoRawCode(t *testing.T) {
|
||
root := t.TempDir()
|
||
writeFile(t, root, "a.md", "內容 A", baseTime)
|
||
writeFile(t, root, "b.md", "內容 B", baseTime.Add(time.Minute))
|
||
|
||
var calls int
|
||
srv := quotaExhaustedServer(t, &calls)
|
||
defer srv.Close()
|
||
|
||
manifest := filepath.Join(t.TempDir(), "m.json")
|
||
cfg := &DirectConfig{
|
||
WatchFolders: []string{root},
|
||
Manifest: manifest,
|
||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||
Library: "kb", Extractor: "workers-ai", ExtractorExplicit: true,
|
||
MaxRemoved: DefaultMaxRemovedRatio,
|
||
}
|
||
|
||
results, exit, _ := RunDirectOnce(cfg, false)
|
||
if exit != 1 {
|
||
t.Fatalf("兩份都該萃取失敗,exit 應為 1,got %d", exit)
|
||
}
|
||
if len(results) != 2 {
|
||
t.Fatalf("results=%+v", results)
|
||
}
|
||
|
||
banned := []string{"4006", "502", "HTTP", "neurons"}
|
||
for _, r := range results {
|
||
for _, b := range banned {
|
||
if strings.Contains(r.Error, b) {
|
||
t.Errorf("結果不該出現裸露的錯誤碼 %q:%q", b, r.Error)
|
||
}
|
||
}
|
||
for _, want := range []string{"今天已經幫你整理了", "升級 Cloudflare", "自動恢復"} {
|
||
if !strings.Contains(r.Error, want) {
|
||
t.Errorf("缺三句話之一 %q:%q", want, r.Error)
|
||
}
|
||
}
|
||
}
|
||
|
||
// status.json 也要是人話,同樣不准有裸碼
|
||
st, err := LoadSyncStatus(StatusFilePath(manifest))
|
||
if err != nil {
|
||
t.Fatalf("讀 status.json 失敗:%v", err)
|
||
}
|
||
if st.ExtractorOK {
|
||
t.Error("額度冷卻中,ExtractorOK 應為 false")
|
||
}
|
||
for _, b := range banned {
|
||
if strings.Contains(st.ExtractorError, b) {
|
||
t.Errorf("status.ExtractorError 不該有裸碼 %q:%q", b, st.ExtractorError)
|
||
}
|
||
}
|
||
acc, ok := st.AccountDetails[instanceHostOf(srv.URL)]
|
||
if !ok {
|
||
t.Fatal("找不到帳號的 status")
|
||
}
|
||
if acc.QuotaMessage == nil {
|
||
t.Fatal("應該有 QuotaMessage")
|
||
}
|
||
if acc.QuotaCooldownUntil == "" {
|
||
t.Fatal("應該記下冷卻到什麼時候")
|
||
}
|
||
for _, b := range banned {
|
||
combined := acc.QuotaMessage.Combined()
|
||
if strings.Contains(combined, b) {
|
||
t.Errorf("QuotaMessage 不該有裸碼 %q:%q", b, combined)
|
||
}
|
||
}
|
||
|
||
// 只有第一次真的打了 API(第二份的 xerr 判斷不需要——一偵測到就進冷卻,
|
||
// 第二個事件被上面的 qs.inCooldown 擋下,不再觸網)。
|
||
if calls != 1 {
|
||
t.Fatalf("偵測到額度用完後不該繼續撞牆,got %d 次呼叫", calls)
|
||
}
|
||
}
|
||
|
||
// 同一帳號、下一輪(新的 RunDirectOnce 呼叫,模擬下一次輪詢):冷卻還沒過,
|
||
// 一次網路呼叫都不該再打——這是「不要一次湧上去、也不要一直撞牆」的核心。
|
||
func TestDirect_QuotaExhausted_NextRunSkipsWithoutNetworkCall(t *testing.T) {
|
||
root := t.TempDir()
|
||
writeFile(t, root, "a.md", "內容 A", baseTime)
|
||
|
||
var calls int
|
||
srv := quotaExhaustedServer(t, &calls)
|
||
defer srv.Close()
|
||
|
||
cfg := &DirectConfig{
|
||
WatchFolders: []string{root},
|
||
Manifest: filepath.Join(t.TempDir(), "m.json"),
|
||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||
Library: "kb", Extractor: "workers-ai", ExtractorExplicit: true,
|
||
MaxRemoved: DefaultMaxRemovedRatio,
|
||
}
|
||
|
||
if _, exit, _ := RunDirectOnce(cfg, false); exit != 1 {
|
||
t.Fatal("第一輪應該偵測到額度用完")
|
||
}
|
||
if calls != 1 {
|
||
t.Fatalf("第一輪應該只打 1 次,got %d", calls)
|
||
}
|
||
|
||
results2, exit2, _ := RunDirectOnce(cfg, false)
|
||
if exit2 != 0 {
|
||
t.Fatalf("冷卻中的 skip 不是失敗,exit 應為 0,got %d results=%+v", exit2, results2)
|
||
}
|
||
if calls != 1 {
|
||
t.Fatalf("冷卻中不該再打任何一次 API,got %d 次呼叫", calls)
|
||
}
|
||
if len(results2) != 1 || results2[0].Status != "skipped" {
|
||
t.Fatalf("results2=%+v", results2)
|
||
}
|
||
if !strings.Contains(results2[0].Error, "自動恢復") {
|
||
t.Errorf("跳過訊息也該是三句話:%q", results2[0].Error)
|
||
}
|
||
}
|