Skip to content

Failover theo phân loại lỗi (retry trước headers, capture mẫu lỗi thật) #5

Closed

Description

@longphamdev

Failover per-account theo bảng phân loại lỗi

What to build

Request lỗi account-level → tự failover sang acc khác. Bước đầu tiên là capture mẫu lỗi thật — KHÔNG đoán matcher:

  1. Capture mẫu lỗi thật (prereq của matcher): acc hiện tại đang rỗng balances → gọi request thật để lấy mẫu quota-exhausted (status + biz code + body). Ghi mẫu vào issue này. Đừng spam upstream để kích 429 — nhánh unknown error phải log đầy đủ status + biz code + headers (redact credential) để mẫu tự tích lũy khi dùng thật.
  2. Bảng phân loại lỗi (đã chốt):
    • 429 + Retry-After → cooling acc đó đúng thời hạn, failover
    • 429 không Retry-After → cooling 30s, failover
    • Quota exhausted (match theo mẫu captured) → exhausted tới quotaExpiresAt, failover
    • 401 trước khi có response → single-flight refresh 1 lần; refresh fail vì network/5xx ≠ expired; fail xác nhận → expired, failover
    • Timeout / connection reset sau khi đã gửi body → KHÔNG retry, không rotate (không biết upstream xử lý chưa)
    • 5xx / network → retry policy hiện có của proxy, không rotate acc
    • Global 429 (không phải account-level) → cooldown cấp pool ngắn, không rotate
    • Lỗi giữa stream → không retry, chỉ đánh dấu acc theo bằng chứng
  3. Retry boundary: chỉ retry khi downstream chưa nhận response headers (upstream đã trả 429 nhưng proxy chưa forward là vẫn được). Body request phải replay được.
  4. Single-flight refresh per account: N request cùng gặp JWT hết hạn → 1 promise refresh duy nhất, cả N chờ cùng kết quả; credential mới persist vào store; refresh fail network/5xx không đánh expired.

Acceptance criteria

  • Mẫu quota-exhausted thật được capture và comment vào issue; matcher viết theo mẫu đó
  • Mock upstream: acc A 429 → acc B 200 → client nhận 200, A cooling có Retry-After đúng
  • Acc A exhausted → request chuyển sang B, A không được pick tới hạn
  • 401 → refresh 1 lần (verify chỉ 1 lần qua mock counter); refresh fail → expired; network fail → KHÔNG expired
  • Retry chỉ xảy ra khi chưa forward headers (test: upstream 429 trước stream → failover; lỗi giữa stream → client nhận lỗi, không failover)
  • N concurrent requests khi JWT hết hạn → đúng 1 refresh call
  • Timeout sau khi gửi body → không có request thứ 2 lên bất kỳ acc nào
  • Unknown error → log đầy đủ redacted, không rotate, không crash
  • Global 429 → pool cooldown, không rotate

Activity

  1. longphamdev commented on Nov 9, 2026

    @longphamdev
    OwnerAuthor

    Bước đầu bắt buộc trước capture mẫu lỗi (từ review #4 — failover dựa vào pool giải phóng slot đúng):

    1. Watchdog reservation (Bun socket-close regression mitigation):

    • Bun 1.3.x không phát server-side close khi client destroy đột ngột → release khi client abort có thể chỉ dựa vào pipeTo.finally (đợi upstream kết thúc). Stream treo/dài giữ slot mãi.
    • Mỗi reservation có max lifetime (config accounts.reservationTimeoutMs, default 15 phút). Quá hạn → force release + log cảnh báo.
    • Test: fake stream treo → watchdog giải phóng slot, request kế pick được acc.
    • Ghim version Bun trong Dockerfile; ghi issue upstream tham chiếu (bun issue về missing close event) — ghi link vào đây khi có.

    2. Refresh JWT của acc non-primary:

    3. Routes còn đọc primary (routes-mcp, async handler):

    • Đây vẫn là đường request của user. Primary exhausted mà acc B còn quota → hai route này vẫn đi primary và fail. Nối qua pool trong ticket này (cùng pattern handler.ts), hoặc tối thiểu ghi rõ decyzję: các route này dùng pool pick.\n
    • claim/runtime.ts là background job, không phải request path — để sau.

    4. Small verifications (nếu chưa có test):

    • Acc bị xóa khỏi store khi đang có reservation → release() sau đó no-op an toàn, không throw.
    • Pass-through 1 acc: 429 trên acc duy nhất KHÔNG chuyển nó sang cooling rồi tự chặn request kế — hành vi phải y như cũ (no-pool). Test để pin behavior này.
    • exhausted revive theo quotaExpiresAt (đã có test ở AccountPool module: state machine, round-robin, inflight cap, queue timeout #3 pool), xác nhận KHÔNG nhầm với tokenExpiresAt — thêm test nếu thiếu.
  2. longphamdev commented on Nov 9, 2026

    @longphamdev
    OwnerAuthor

    Update về Bun socket-close bug — ĐÃ ĐƯỢC FIX trong Bun 1.4.0:

    • Local Bun 1.3.11: bug CÓ (repro: client destroy → không close event)
    • Docker oven/bun:1.4.0-alpine (image production đang dùng): ĐÃ FIX (repro: close event phát đúng)

    → Production (docker) KHÔNG bị ảnh hưởng. Chỉ môi trường dev chạy bun 1.3.11 local mới thấy.

    Vẫn làm watchdog (accounts.reservationTimeoutMs, default 15 phút) vì: (1) phòng hồi quy Bun sau này, (2) stream treo upstream thật vẫn có thể giữ slot — không phụ thuộc Bun bug. Dockerfile đã pin 1.4.0-alpine từ trước — giữ nguyên.

  3. longphamdev commented on Nov 9, 2026

    @longphamdev
    OwnerAuthor

    Kết quả khảo sát 'refresh JWT' — KHÔNG có refresh grant programmatic trong codebase:

    Facts từ code:

    1. start-plan JWT: lấy từ OAuth login (token exchange cần authCode từ browser callback/server poll). JWT chỉ có iat, KHÔNG có exp (jwt-age.ts, verified live 2026-10: 8-day-old JWT vẫn dùng được). Upstream chỉ từ chối khi trả 401/3012 — không có expiry theo tuổi.
    2. coding-plan: apiKey.secret long-lived, 401 nghĩa là key bị revoke → phải re-login interactive.
    3. bigmodel: oauthAccessToken — cũng không có refresh_token grant trong code.

    → Không thể 'refresh' tự động: mọi đường đều cần interactive re-login (browser flow).

    Thiết kế điều chỉnh cho 401 handling trong #5 (thay cho auto-refresh):

    • 401 account-level → đánh dấu acc expired (không retry refresh — không có gì để refresh), failover sang acc khác.
    • Trả message rõ ràng kèm account id: account <id> credential rejected upstream — run: zcode-proxy auth login zai --add (re-login cùng acc sẽ upsert đúng entry theo userId — nhờ Phase 1 hardening: golden KDF test, facade saveCredential match theo userId, .bak/lock polish #1).
    • Single-flight áp dụng cho việc kiểm tra/tránh stampede đánh dấu expired, không phải refresh call.
    • Mục 5 review (rotation) không áp dụng vì không có refresh response; mục 6 (lockfile) áp dụng khi user re-login bằng CLI trong khi server chạy — đã có lockfile protocol + hot-reload.

    Mục 3 review (pool cập nhật credential ngay): khi user re-login qua CLI, store ghi qua lockfile → pool syncWithStore() (5s timer + trước mỗi reserve nếu stale) nạp credential mới — sẽ thêm 'sync-on-stale' trước reserve để không đợi tối đa 5s. Test kèm.

  4. longphamdev commented on Nov 9, 2026

    @longphamdev
    OwnerAuthor

    Mẫu lỗi thật #1 — quota-exhausted (captured live từ acc rỗng balances):

    POST /v1/messages (anthropic passthrough, start-plan, acc rỗng balances)
    HTTP/1.1 200 OK          ← LƯU Ý: HTTP 200, KHÔNG PHÁI 4xx/5xx!
    {"code":1005,"msg":"exceed quota limit","logid":"20261109054957b119e7a7a8bc0fc942ed"}
    

    Và ở openai format (/v1/chat/completions), cùng lỗi hiện tại surfacing là:

    HTTP/1.1 502
    {"error":{"type":"translation_failed","message":"upstream returned invalid Anthropic message: {\"code\":1005,\"msg\":\"exceed quota limit\",\"logid\":...}"}}
    

    Điểm mấu chốt: matcher KHÔNG được dựa vào status — phải quét body cho code:1005 / exceed quota limit dù HTTP status là 200. Đây chính là lý do review yêu cầu capture mẫu thật: đoán theo status sẽ miss hoàn toàn nhánh này.

    Việc cần làm (phần còn lại của ticket):

    1. Classifier: parse body JSON của response 200 → code === 1005 → mark acc exhausted (tới claim-expiry/quota reset khi biết) + failover nếu chưa stream.
    2. Fixture test với body mẫu (đã sạch dữ liệu nhạy cảm — chỉ có code/msg/logid).
    3. Chưa kích 429 — để mẫu tự tích lũy qua unknown-error logging.
  5. longphamdev commented on Nov 9, 2026

    @longphamdev
    OwnerAuthor

    #5 hoàn thành — tổng kết các commit:

    Commit Nội dung
    c35c2d7 Reservation watchdog + single-acc 429 parity (prep)
    87c28d2 Watchdog aborts upstream + stream-wrapper release; container suite verify
    3b26b2b Wrapper data-path integrity tests (byte-for-byte, backpressure, error-mid-stream, cancel race) + env-independent update check
    2dae802 401 failover + freshness (syncIfStale) + re-login state reset
    94fd841 401 hardening: half-open, global-401 guard, tried-set, single-acc parity, body re-dispatch test, inFlight preservation, 503 UX với labels
    dfd725f Biz-1005 quota exhaustion (HTTP 200 envelope!) + routes-mcp/async pool wiring + capture mẫu thật
    1b14f1e Global-429 pool guard

    Điểm chính:

    • Mẫu lỗi thật captured: quota-exhausted đến dạng HTTP 200 + {code:1005} — classifier quét body, không dựa status (đã lưu fixture, không có dữ liệu nhạy cảm)
    • 401: expire + rotate + half-open (10 phút) + global-outage guard (≥2 acc cùng 401/30s = sự cố upstream, không đốt pool) + tried-set (không re-pick acc đã thử)
    • 429: per-account cooling với Retry-After; global-429 guard tương tự 401
    • routes-mcp + async: pool wiring — async không giữ slot suốt job (copy credential, release ngay)
    • Watchdog: force-release + abort upstream (Reservation.signal → AbortSignal.any vào dispatch)

    Suite: local 1209 pass / 0 fail; container 1210 pass / 0 fail. Worktree #2 đã sync tới HEAD.

    Frontier còn lại: #6 (poller + /quota schema). CLI (#2) làm khi cần demo.

  6. longphamdev commented on Nov 9, 2026

    @longphamdev
    OwnerAuthor

    Hoàn thành: failover 401/1005 + global guards (401/429) + half-open + routes-mcp/async wiring + capture mẫu lỗi thật. Suite 1209/0 local, 1210/0 container. Chi tiết ở comment tổng kết. Frontier: #6.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions