diff --git a/locales/installer/ko-kr.json b/locales/installer/ko-kr.json index fc5ac46..1bf7ef5 100644 --- a/locales/installer/ko-kr.json +++ b/locales/installer/ko-kr.json @@ -205,6 +205,7 @@ }, "log": { "manifestDownload": "manifest 다운로드: {{url}}", + "downloadRetry": "다운로드 일시 오류({{reason}}) — {{attempt}}번째 재시도, {{secs}}초 후", "packLoadFail": "pack 로드 실패 ({{file}}): {{message}}", "packsLoaded": "로드된 음악퀴즈: {{count}}개", "selectedPack": "선택: {{key}}", diff --git a/package.json b/package.json index 2127731..7e30ae4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minecraft-music-quiz-installer", - "version": "0.4.12", + "version": "0.4.13", "description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트", "main": "dist/installer/main.js", "scripts": { diff --git a/src/installer/main.ts b/src/installer/main.ts index 954addb..f3781d5 100644 --- a/src/installer/main.ts +++ b/src/installer/main.ts @@ -109,29 +109,61 @@ process.on('unhandledRejection', (reason) => { try { sendLog(t('log.internalError', { message: reason instanceof Error ? reason.message : String(reason) })) } catch {} }) -function fetchBuffer(url: string): Promise { +// 너무 빠르게/자주 요청해 생기는 일시적 오류(429 Too Many Requests, 5xx, 네트워크 +// 끊김/타임아웃 등)는 잠깐 쉬고 몇 번 다시 시도한다. 403 도 일부 CDN 이 rate-limit 에 +// 쓰므로 포함. 그 외 4xx(404 등)나 재시도 소진 시에는 그대로 실패. +const DOWNLOAD_TRANSIENT_CODES = new Set([403, 408, 425, 429, 500, 502, 503, 504]) +const DOWNLOAD_MAX_RETRIES = 3 +const DOWNLOAD_MAX_BACKOFF_MS = 30000 + +/** Retry-After 헤더(초 또는 HTTP-date) → 대기 ms. 못 읽으면 null. */ +function parseRetryAfterMs(h: string | string[] | undefined): number | null { + if (!h) return null + const v = Array.isArray(h) ? h[0] : h + const secs = Number(v) + if (Number.isFinite(secs)) return Math.min(DOWNLOAD_MAX_BACKOFF_MS, Math.max(0, secs * 1000)) + const date = Date.parse(v) + if (!Number.isNaN(date)) return Math.min(DOWNLOAD_MAX_BACKOFF_MS, Math.max(0, date - Date.now())) + return null +} + +function fetchBuffer(url: string, redirects = 0, attempt = 0): Promise { return new Promise((resolve, reject) => { const target = new URL(url) const transport = target.protocol === 'https:' ? https : http + const retryLater = (headerDelay: number | null, reason: string): void => { + const backoff = Math.min(DOWNLOAD_MAX_BACKOFF_MS, 1000 * 2 ** attempt) + Math.floor(Math.random() * 500) + const delay = headerDelay ?? backoff + sendLog(t('log.downloadRetry', { reason, attempt: attempt + 1, secs: Math.ceil(delay / 1000) })) + sleep(delay).then(() => fetchBuffer(url, redirects, attempt + 1).then(resolve, reject)) + } const request = transport.get(target, { timeout: 30000 }, (response) => { - if (response.statusCode === 301 || response.statusCode === 302) { - const redirect = response.headers.location - if (redirect) { - response.resume() - fetchBuffer(new URL(redirect, target).toString()).then(resolve, reject) - return - } - } - if ((response.statusCode ?? 0) >= 400) { + const code = response.statusCode ?? 0 + if ((code === 301 || code === 302 || code === 303 || code === 307 || code === 308) && response.headers.location) { response.resume() - reject(new Error(`HTTP ${response.statusCode}`)) + if (redirects > 8) { reject(new Error(`HTTP ${code} (too many redirects)`)); return } + fetchBuffer(new URL(response.headers.location, target).toString(), redirects + 1, attempt).then(resolve, reject) + return + } + if (DOWNLOAD_TRANSIENT_CODES.has(code) && attempt < DOWNLOAD_MAX_RETRIES) { + response.resume() + retryLater(parseRetryAfterMs(response.headers['retry-after']), `HTTP ${code}`) + return + } + if (code >= 400) { + response.resume() + reject(new Error(`HTTP ${code}`)) return } const chunks: Buffer[] = [] response.on('data', (chunk: Buffer) => chunks.push(chunk)) response.on('end', () => resolve(Buffer.concat(chunks))) }) - request.on('error', reject) + request.on('error', (err) => { + // 연결 끊김/리셋 등 네트워크 오류도 몇 번은 재시도. + if (attempt < DOWNLOAD_MAX_RETRIES) { retryLater(null, (err as Error).message); return } + reject(err) + }) request.on('timeout', () => request.destroy(new Error(t('errors.requestTimeout')))) }) }