|
|
|
|
@@ -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) {
|
|
|
|
|
const code = response.statusCode ?? 0
|
|
|
|
|
if ((code === 301 || code === 302 || code === 303 || code === 307 || code === 308) && response.headers.location) {
|
|
|
|
|
response.resume()
|
|
|
|
|
fetchBuffer(new URL(redirect, target).toString()).then(resolve, reject)
|
|
|
|
|
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 ((response.statusCode ?? 0) >= 400) {
|
|
|
|
|
if (DOWNLOAD_TRANSIENT_CODES.has(code) && attempt < DOWNLOAD_MAX_RETRIES) {
|
|
|
|
|
response.resume()
|
|
|
|
|
reject(new Error(`HTTP ${response.statusCode}`))
|
|
|
|
|
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'))))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|