// 刪除作業共用工具:暫時性錯誤的指數退避重試、有上限併發,以及 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 }