fix(installer): 다운로드 일시 오류 시 자동 재시도(백오프)
메인 간편설치기의 fetchBuffer/downloadFile 은 재시도가 없어, 너무 빠르게/자주 요청해 생기는 일시적 오류(429·5xx·네트워크 끊김·타임아웃, 일부 403)에 곧바로 실패했다. RP 설치기처럼 일시 오류를 지수 백오프(Retry-After 우선)로 최대 3회 재시도하도록 보강(모드 다중 다운로드/서버·맵·리소스팩·매니페스트 모두 적용). 404 등 영구 오류는 그대로 실패. 0.4.12→0.4.13. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Buffer> {
|
||||
// 너무 빠르게/자주 요청해 생기는 일시적 오류(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<Buffer> {
|
||||
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'))))
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user