From 7d34ceb4397c0ea0e2d000830bff5e4eb3173c4f Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 26 Jun 2026 15:28:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(delete-utils):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=88=AA=E9=99=A4=E9=87=8D=E8=A9=A6=E3=80=81=E4=BD=B5=E7=99=BC?= =?UTF-8?q?=E8=88=87=20best-effort=20=E5=85=B1=E7=94=A8=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 deleteWithRetry(暫時性錯誤指數退避重試、永久性錯誤即止)、 runWithConcurrency(有上限併發)與 isRetryableStatus,供清理流程強化錯誤處理。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/delete-utils.js | 79 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 app/delete-utils.js diff --git a/app/delete-utils.js b/app/delete-utils.js new file mode 100644 index 0000000..55f16ad --- /dev/null +++ b/app/delete-utils.js @@ -0,0 +1,79 @@ +// 刪除作業共用工具:暫時性錯誤的指數退避重試、有上限併發,以及 best-effort 續行所需的結果型別。 +// 對應 AI review 對 release/tag 刪除「重試、錯誤分流、併發、部分失敗續行」的多項建議。 + +// 視為可重試的暫時性 HTTP 狀態碼(限流與閘道層暫時性錯誤)。 +// 刻意不含 500:500 多為確定性錯誤,重試通常無益。 +const RETRYABLE_STATUS = new Set([429, 502, 503, 504]) + +// 單筆刪除的最大嘗試次數與退避基準。 +const MAX_ATTEMPTS = 3 +const BASE_BACKOFF_MS = 200 + +// 預設併發上限:加速大量刪除,同時避免對 Gitea API 造成過大壓力或觸發速率限制。 +const DEFAULT_CONCURRENCY = 4 + +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * 判斷 HTTP 狀態碼是否為可重試的暫時性錯誤(429/502/503/504)。 + * @param {number} status HTTP 狀態碼 + * @returns {boolean} + */ +export function isRetryableStatus(status) { + return RETRYABLE_STATUS.has(status) +} + +/** + * 帶指數退避的刪除:204 視為成功;暫時性錯誤(網路例外或 429/502/503/504)會重試; + * 永久性錯誤(如 401/403/404)立即停止重試。本函式不對外拋例外,失敗以回傳值表示。 + * @param {{ deleteResource: (url: string) => Promise }} client 具 deleteResource 的客戶端 + * @param {string} url 目標資源位址 + * @param {{ attempts?: number, sleep?: (ms: number) => Promise }} [options] 可注入嘗試次數與 sleep(測試用) + * @returns {Promise<{ status: number, error?: Error, attempts: number }>} 最終狀態(204 成功);網路例外時 status 為 0 並帶 error + */ +export async function deleteWithRetry(client, url, options = {}) { + const { attempts = MAX_ATTEMPTS, sleep = wait } = options + let last = { status: 0, attempts: 0 } + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + const status = await client.deleteResource(url) + if (status === 204 || !isRetryableStatus(status)) { + return { status, attempts: attempt } // 成功或永久性錯誤,不再重試 + } + last = { status, attempts: attempt } + } catch (error) { + last = { status: 0, error, attempts: attempt } + } + if (attempt < attempts) { + await sleep(BASE_BACKOFF_MS * 2 ** (attempt - 1)) // 200ms、400ms… + } + } + return last +} + +/** + * 以有上限的併發逐項執行非同步 worker,回傳對應輸入順序的結果陣列。 + * 控制併發數可加速處理,同時避免一次送出過多請求。worker 不應拋例外(由呼叫端自行吞例外)。 + * @template T, R + * @param {T[]} items 待處理項目 + * @param {(item: T, index: number) => Promise} worker 處理單一項目 + * @param {number} [concurrency] 併發上限,預設 4 + * @returns {Promise} + */ +export async function runWithConcurrency(items, worker, concurrency = DEFAULT_CONCURRENCY) { + const results = new Array(items.length) + let next = 0 + + async function runner() { + while (next < items.length) { + const index = next + next += 1 + results[index] = await worker(items[index], index) + } + } + + const size = Math.max(1, Math.min(concurrency, items.length)) + await Promise.all(Array.from({ length: size }, () => runner())) + return results +}