From 6872f5fdabc13ff744989bca823365033f51ad57 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 26 Oct 2026 08:51:47 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=98=AC=EB=A6=AC=EB=B8=8C=EC=98=81=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=8B=A4=ED=8C=A8=EC=9D=98=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EC=A4=91=EA=B3=84=20=EC=9E=A5=EC=95=A0=20=EC=A0=84?= =?UTF-8?q?=ED=8C=8C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/oliveyoung-free-relay.md | 10 +- scripts/relay/browser.ts | 104 +++++++---- scripts/relay/lifecycle.ts | 48 ++++- scripts/relay/oliveyoung.ts | 48 ++++- scripts/relay/start.ts | 1 + scripts/relay/transport-lifecycle.ts | 2 + tests/relay/request-diagnostics.test.ts | 56 ++++++ tests/relay/request-isolation.test.ts | 225 ++++++++++++++++++++++++ tests/relay/transport-lifecycle.test.ts | 5 + 9 files changed, 454 insertions(+), 45 deletions(-) create mode 100644 tests/relay/request-diagnostics.test.ts create mode 100644 tests/relay/request-isolation.test.ts diff --git a/docs/oliveyoung-free-relay.md b/docs/oliveyoung-free-relay.md index 5e3d66b5..db0f1901 100644 --- a/docs/oliveyoung-free-relay.md +++ b/docs/oliveyoung-free-relay.md @@ -86,7 +86,9 @@ GitHub Actions의 `Sync Worker Secrets`는 위 네 OY secrets를 동기화하며 ## 상시 운영 보강 -중계는 한 브라우저·한 컨텍스트·한 페이지를 재사용합니다. 요청마다 탭을 만들지 않습니다. 추가 페이지는 즉시 회수하며 회수 실패·페이지 crash·Node 측 watchdog timeout에는 소유한 브라우저 세션을 폐기합니다. 200회 조회 또는 30분 사용 후 유휴 경계에서 정리하고 교체 세션을 한 번 미리 준비합니다. RSS 한도 회수도 같은 준비 경로를 사용합니다. 이전 소유 프로세스의 회수가 완료된 다음 새 브라우저를 띄우므로 브라우저 두 개를 겹쳐 유지하지 않습니다. 준비 중 health 상태는 `starting`입니다. 준비 실패 시 무한 재시도하지 않고 다음 명시적 요청을 기다립니다. 준비 중 도착한 요청은 여전히 준비 시간을 기다릴 수 있으므로 항상 15초 이내 응답을 보장하는 것은 아닙니다. +중계는 한 브라우저·한 컨텍스트·한 페이지를 재사용합니다. 요청마다 탭을 만들지 않습니다. 추가 페이지는 즉시 회수하며 회수 실패·페이지 crash·Node 측 watchdog timeout에는 소유한 브라우저 세션을 폐기합니다. 성공·실패를 합쳐 200회 시도 또는 30분 사용 후 유휴 경계에서 정리하고 교체 세션을 한 번 미리 준비합니다. RSS 한도 회수도 같은 준비 경로를 사용합니다. 이전 소유 프로세스의 회수가 완료된 다음 새 브라우저를 띄우므로 브라우저 두 개를 겹쳐 유지하지 않습니다. 준비 중 health 상태는 `starting`입니다. 준비 실패 시 무한 재시도하지 않고 다음 명시적 요청을 기다립니다. 준비 중 도착한 요청은 여전히 준비 시간을 기다릴 수 있으므로 항상 15초 이내 응답을 보장하는 것은 아닙니다. + +개별 원본 HTTP 오류, 업무 실패, JSON 형식 오류, fetch 네트워크 오류 및 15초 fetch 제한시간 초과는 해당 요청만 실패시킵니다. 단발성 실패에서는 사용 가능한 브라우저를 종료하거나 전체 복구 대기를 시작하지 않습니다. HTTP 403 또는 JSON 대신 검증 HTML이 3회 연속 반환되면 세션 거절로 분류해 회수하고 기존 복구 대기를 적용합니다. HTTP 오류 본문은 즉시 취소합니다. 실제 페이지 충돌·컨텍스트 소멸·18초 Node watchdog 정지는 기존 회수·복구 절차를 유지합니다. 초기 페이지의 추가 이동으로 실행 컨텍스트가 사라진 경우에는 같은 출처의 `/store/` 이동 완료를 기다린 뒤 읽기 요청을 한 번 재시도합니다. 이동 대기와 재시도도 기존 18초 작업 watchdog 안에서만 실행합니다. 재시도 실패 시에는 기존 유한 회수 절차를 유지합니다. 요청 실패는 품절이나 성공으로 바꾸지 않습니다. 로컬 안전 로그의 `request-http-403`, `request-http-429`, `request-timeout`, `request-invalid-json`, `request-business`로 원인을 구분하며 원본 응답 본문·검색어는 기록하지 않습니다. 요청 수준 실패에도 기존 30분 세션 교체와 메모리 상한은 유지합니다. 브라우저는 별도 guard 프로세스가 소유합니다. 중계 부모가 SIGKILL로 종료되어도 IPC 단절을 감지해 회수합니다. PID·생성 시각·프로세스 그룹을 확인한 소유 프로세스만 종료합니다. macOS에서 별도 그룹으로 분리되는 crashpad 보조 프로세스도 실행별 고유 경로로 식별해 메모리 집계와 회수에 포함합니다. 브라우저에는 GUI 실행에 필요한 환경만 전달하고 운영 토큰은 제외합니다. 정상 종료가 지연되면 확인된 그룹을 강제 종료하고 실제 소멸을 확인합니다. 개인 Chrome을 프로세스 이름으로 일괄 종료하지 않습니다. guard까지 강제 종료되거나 소유권 확인이 실패한 경우 소유 마커를 유지하여 재시작을 차단합니다. 이 경우 운영자가 잔존 프로세스를 확인해야 합니다. 소유 확인 없이 마커 파일을 지우면 안 됩니다. @@ -134,3 +136,9 @@ launchctl bootout "gui/$(id -u)/page.aka.daiso-oliveyoung" Playwright Chromium 153.0.8010.12에서 sandbox를 켜고 검증했습니다. 실제 조회 두 건과 부모 SIGKILL 후 잔여 소유 프로세스 0개를 확인했습니다. 모의 브라우저 API 조회 400회로 두 번 교체하고 각 교체 후에도 잔여 프로세스 0개를 확인했습니다. 팝업, 실제 renderer crash, 18초 Node watchdog도 검증했습니다. 메모리는 시점별 약 519~632MiB로 관측됐으며 장기간 무누수나 최고 사용량 보장을 의미하지 않습니다. `consumer-busy` 대기 복구와 실제 quota 구분, 전체 시간 제한은 [조회 오류 복구와 제한](status-resilience.md)을 참조하세요. + +## 2026-10-26 반복 장애 조사 + +최신 야간 Health Checks 네 건에서 올리브영 실패가 반복됐고, 같은 날 External Smoke는 통과했습니다. 캐시를 사용하는 smoke 통과는 캐시 우회 원본 성공의 근거가 아닙니다. 로컬 로그에는 요청 하나가 실패한 직후 세션 회수와 최대 15분 복구 대기가 반복됐습니다. 같은 회선의 격리된 새 브라우저에서는 실제 상품 API가 정상 응답했습니다. + +기존 코드는 완료된 HTTP 실패와 브라우저 고장을 같은 catch에서 처리했습니다. 단일 실패가 모든 올리브영 요청의 장기 장애로 확대되는 동작은 회귀 테스트로 재현했습니다. 격리된 실제 브라우저에서도 첫 상품 조회 직후 두 번째 조회가 `Execution context was destroyed`로 실패하는 현상을 두 번 재현했습니다. 페이지의 추가 이동과 조회가 겹치는 문제도 한 번의 이동 대기·재시도로 처리합니다. 당시 운영 실패의 HTTP 상태는 기존 로그에 없어 모든 최초 실패가 같은 원인인지, 403 또는 429도 있었는지는 단정할 수 없습니다. 수정 후 안전 코드로 구분합니다. 검사 실패 기준과 캐시 우회 조건은 완화하지 않습니다. diff --git a/scripts/relay/browser.ts b/scripts/relay/browser.ts index ae7454a3..cecebb1f 100644 --- a/scripts/relay/browser.ts +++ b/scripts/relay/browser.ts @@ -1,49 +1,85 @@ /** 개인 프로필과 분리된 브라우저의 동일 출처 조회. */ import type { Page } from 'playwright'; import type { BrowserRunner } from './oliveyoung.js'; +export class BrowserRequestError extends Error { + constructor( + readonly reason: string, + readonly upstreamStatus?: number, + ) { + super(`올리브영 브라우저 응답 실패: ${reason}`); + } +} export function createBrowserRunner(page: Pick): BrowserRunner { return async (path, body) => { const result = await page.evaluate( async ({ path, body, timeout }) => { - const response = await fetch(`https://www.oliveyoung.co.kr${path}`, { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(timeout), - }); - const reader = response.body?.getReader(); - if (!reader) throw new Error('Browser response body missing'); - const decoder = new TextDecoder(); - let bytes = 0; - let text = ''; - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - bytes += chunk.value.byteLength; - if (bytes > 2 * 1024 * 1024) { - await reader.cancel(); - throw new Error('Browser response size limit'); - } - text += decoder.decode(chunk.value, { stream: true }); - } - text += decoder.decode(); - let parsed = null; + let status: number | undefined; try { - parsed = JSON.parse(text); - } catch { - /* 보안 검증 HTML을 정상 데이터로 반환하지 않습니다. */ + const response = await fetch(`https://www.oliveyoung.co.kr${path}`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeout), + }); + status = response.status; + if (status !== 200) { + await response.body?.cancel(); + return { status, body: null }; + } + const reader = response.body?.getReader(); + if (!reader) throw new Error('Browser response body missing'); + const decoder = new TextDecoder(); + let bytes = 0; + let text = ''; + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > 2 * 1024 * 1024) { + await reader.cancel(); + throw new Error('Browser response size limit'); + } + text += decoder.decode(chunk.value, { stream: true }); + } + text += decoder.decode(); + let parsed = null; + try { + parsed = JSON.parse(text); + } catch { + /* 보안 검증 HTML을 정상 데이터로 반환하지 않습니다. */ + } + return { status: response.status, body: parsed }; + } catch (error) { + const name = error instanceof Error ? error.name : ''; + const message = error instanceof Error ? error.message : ''; + const reason = + name === 'TimeoutError' || name === 'AbortError' + ? 'timeout' + : message === 'Browser response size limit' + ? 'response size limit' + : message === 'Browser response body missing' + ? 'body missing' + : 'network'; + return { status, body: null, reason }; } - return { status: response.status, body: parsed }; }, { path, body, timeout: 15000 }, ); - if (result.status !== 200 || result.body?.status !== 'SUCCESS') - throw new Error('올리브영 브라우저 응답 실패'); + if (result.reason || result.status !== 200 || result.body?.status !== 'SUCCESS') + throw new BrowserRequestError( + result.reason || + (result.status !== 200 + ? `http-${result.status}` + : result.body + ? 'business' + : 'invalid-json'), + result.status, + ); return result.body; }; } diff --git a/scripts/relay/lifecycle.ts b/scripts/relay/lifecycle.ts index a16b338d..193d68a1 100644 --- a/scripts/relay/lifecycle.ts +++ b/scripts/relay/lifecycle.ts @@ -1,6 +1,6 @@ /** 한 브라우저 세션의 재사용·교체·회수를 직렬화합니다. */ import type { Browser, BrowserContext, Page } from 'playwright'; -import { createBrowserRunner } from './browser.js'; +import { BrowserRequestError, createBrowserRunner } from './browser.js'; import type { BrowserRunner } from './oliveyoung.js'; export interface BrowserOwner { browser: Pick; @@ -25,6 +25,7 @@ export async function bounded(work: Promise, ms: number): Promise { export function createBrowserLifecycle( launch: () => Promise, onEvent?: (reason: string) => void | Promise, + takeRetryQuota?: () => Promise, ) { const emit = (reason: string) => { try { @@ -44,6 +45,7 @@ export function createBrowserLifecycle( let born = 0; let calls = 0; let totalCalls = 0; + let sessionFailures = 0; let rotations = 0; let rssBytes = 0; let reason = 'startup'; @@ -119,6 +121,7 @@ export function createBrowserLifecycle( } born = Date.now(); calls = 0; + sessionFailures = 0; if (closed) { await retire('shutdown'); throw new Error('Browser lifecycle unavailable'); @@ -195,11 +198,34 @@ export function createBrowserLifecycle( active = true; try { await ensure(); - const result = await bounded(createBrowserRunner(page!)(path, body), 18000); + calls++; + const runner = createBrowserRunner(page!); + const request = async () => { + try { + return await runner(path, body); + } catch (error) { + if ( + !(error instanceof Error) || + !error.message.includes('Execution context was destroyed') + ) + throw error; + // 초기 페이지의 추가 이동과 겹친 읽기 요청만 한 번 다시 실행합니다. + if (takeRetryQuota && !(await takeRetryQuota())) + throw new BrowserRequestError('retry-quota'); + calls++; + emit('navigation-retry'); + await page!.waitForURL('https://www.oliveyoung.co.kr/store/**', { + waitUntil: 'domcontentloaded', + timeout: 5000, + }); + return runner(path, body); + } + }; + const result = await bounded(request(), 18000); failures = 0; retryAt = 0; lastFailure = undefined; - calls++; + sessionFailures = 0; totalCalls++; if (calls >= 200 || Date.now() - born >= 30 * 60 * 1000) { rotations++; @@ -208,6 +234,22 @@ export function createBrowserLifecycle( } return result; } catch (error) { + // 완료된 HTTP 요청의 실패는 브라우저 프로세스 고장과 구분합니다. + if (error instanceof BrowserRequestError) { + emit(`request-${error.reason.replaceAll(' ', '-')}`); + sessionFailures = + error.upstreamStatus === 403 || error.reason === 'invalid-json' ? sessionFailures + 1 : 0; + if (sessionFailures >= 3) { + sessionFailures = 0; + defer(error); + await retire('session-rejected'); + } else if (calls >= 200) { + rotations++; + replacementPending = true; + await retire('rotation'); + } + throw error; + } defer(error); await retire('request-failed'); throw error; diff --git a/scripts/relay/oliveyoung.ts b/scripts/relay/oliveyoung.ts index 360592be..1fa62f05 100644 --- a/scripts/relay/oliveyoung.ts +++ b/scripts/relay/oliveyoung.ts @@ -2,6 +2,7 @@ import { randomUUID, timingSafeEqual } from 'node:crypto'; import { OLIVEYOUNG_API } from '../../src/services/oliveyoung/api.js'; import type { OliveyoungApiResponse } from '../../src/services/oliveyoung/types.js'; +import { BrowserRequestError } from './browser.js'; import { createConsumerQuota } from './consumer.js'; import { canonicalKey, createResponseCache } from './cache.js'; import type { QuotaStatus } from './quota.js'; @@ -71,10 +72,17 @@ export function createOliveyoungRelay( const expected = Buffer.from(`Bearer ${token}`); const cache = createResponseCache(); const consumers = createConsumerQuota(); - const consumerDenied = (busy = false) => Response.json({ error: 'Relay quota exceeded', reason: busy ? 'consumer-busy' : 'consumer' }, { - status: 429, - headers: { 'x-relay-quota-reason': busy ? 'consumer-busy' : 'consumer', 'retry-after': String(busy ? 1 : consumers.retryAfter()) }, - }); + const consumerDenied = (busy = false) => + Response.json( + { error: 'Relay quota exceeded', reason: busy ? 'consumer-busy' : 'consumer' }, + { + status: 429, + headers: { + 'x-relay-quota-reason': busy ? 'consumer-busy' : 'consumer', + 'retry-after': String(busy ? 1 : consumers.retryAfter()), + }, + }, + ); const ttl: Record = { 'find-store': 300000, 'product-search-v3': 300000, @@ -193,7 +201,8 @@ export function createOliveyoungRelay( entry!.startedAt = Date.now(); if ( [...signals].every( - ([signal, arrived]) => signal.aborted || entry!.startedAt - arrived >= MAX_QUEUE_WAIT_MS, + ([signal, arrived]) => + signal.aborted || entry!.startedAt - arrived >= MAX_QUEUE_WAIT_MS, ) ) return null; @@ -228,7 +237,11 @@ export function createOliveyoungRelay( } try { const result = await entry.task; - if (request.signal.aborted || result === null || entry.startedAt - queuedAt >= MAX_QUEUE_WAIT_MS) { + if ( + request.signal.aborted || + result === null || + entry.startedAt - queuedAt >= MAX_QUEUE_WAIT_MS + ) { emit('queue', request.signal.aborted ? 'canceled' : 'expired'); return error(503, 'Request expired'); } @@ -248,7 +261,28 @@ export function createOliveyoungRelay( if (result === 'unavailable') return error(503, 'Relay quota unavailable'); if (result?.status !== 'SUCCESS') return error(502, 'Upstream unavailable'); return Response.json(result, { headers: { 'x-relay-cache': mode } }); - } catch { + } catch (failure) { + if (failure instanceof BrowserRequestError) { + if (failure.reason === 'retry-quota') { + const status = options.takeQuota?.status?.(); + return Response.json( + { error: 'Relay quota exceeded', reason: status?.blockedBy || 'unknown' }, + { + status: 429, + headers: { + 'x-relay-quota-reason': status?.blockedBy || 'unknown', + 'retry-after': String(status?.retryAfter || 60), + }, + }, + ); + } + const reason = failure.reason.replaceAll(' ', '-'); + emit('upstream', reason); + return Response.json( + { error: 'Upstream unavailable', reason, upstreamStatus: failure.upstreamStatus }, + { status: 502 }, + ); + } emit('upstream', 'error'); return error(502, 'Upstream unavailable'); } finally { diff --git a/scripts/relay/start.ts b/scripts/relay/start.ts index a7297c6f..e71fb53e 100644 --- a/scripts/relay/start.ts +++ b/scripts/relay/start.ts @@ -35,6 +35,7 @@ async function main() { operation: process.env.OY_RELAY_TRANSPORT || 'browser', outcome, }), + takeQuota, ); const recover = createRelayWatchdog(lifecycle, (outcome) => log.append({ stage: 'lifecycle', outcome }), diff --git a/scripts/relay/transport-lifecycle.ts b/scripts/relay/transport-lifecycle.ts index a21de3a9..2f78d190 100644 --- a/scripts/relay/transport-lifecycle.ts +++ b/scripts/relay/transport-lifecycle.ts @@ -4,6 +4,7 @@ export async function createTransportLifecycle( mode: string | undefined, stateDir: string, onEvent?: (reason: string) => void | Promise, + takeRetryQuota?: () => Promise, ) { if (mode === 'http') { const { createHttpLifecycle } = await import('./http-lifecycle.js'); @@ -16,5 +17,6 @@ export async function createTransportLifecycle( return createBrowserLifecycle( () => launchGuardedBrowser(join(stateDir, 'browser-owner.json'), onEvent), onEvent, + takeRetryQuota, ); } diff --git a/tests/relay/request-diagnostics.test.ts b/tests/relay/request-diagnostics.test.ts new file mode 100644 index 00000000..7ec9e4e8 --- /dev/null +++ b/tests/relay/request-diagnostics.test.ts @@ -0,0 +1,56 @@ +import { expect, it, vi } from 'vitest'; +import { BrowserRequestError } from '../../scripts/relay/browser.js'; +import { createOliveyoungRelay } from '../../scripts/relay/oliveyoung.js'; +it.each([ + ['http-403', 403], + ['timeout', undefined], +] as const)( + '원본 실패 %s를 안전한 코드로 기록하고 다음 요청은 정상 처리한다', + async (reason, status) => { + const onEvent = vi.fn(); + const run = vi + .fn() + .mockRejectedValueOnce(new BrowserRequestError(reason, status)) + .mockResolvedValue({ status: 'SUCCESS' }); + const relay = createOliveyoungRelay('test', run, { onEvent }); + const request = () => + new Request('http://localhost/v1/oliveyoung/stock-goods-info-v3', { + method: 'POST', + headers: { authorization: 'Bearer test', 'x-relay-consumer': 'a'.repeat(64) }, + body: JSON.stringify({ goodsNo: 'A1' }), + }); + const response = await relay(request()); + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ + error: 'Upstream unavailable', + reason, + ...(status ? { upstreamStatus: status } : {}), + }); + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ stage: 'upstream', outcome: reason }), + ); + expect((await relay(request())).status).toBe(200); + }, +); + +it.each([undefined, { blockedBy: 'minute', retryAfter: 12 }])( + '재시도 할당량 실패는 외부 원본 차단과 구분한다', + async (status) => { + const takeQuota = Object.assign(vi.fn().mockResolvedValue(true), { status: () => status }); + const relay = createOliveyoungRelay( + 'test', + vi.fn().mockRejectedValue(new BrowserRequestError('retry-quota')), + { takeQuota } as never, + ); + const response = await relay( + new Request('http://localhost/v1/oliveyoung/stock-goods-info-v3', { + method: 'POST', + headers: { authorization: 'Bearer test', 'x-relay-consumer': 'b'.repeat(64) }, + body: JSON.stringify({ goodsNo: 'A1' }), + }), + ); + expect(response.status).toBe(429); + expect(response.headers.get('x-relay-quota-reason')).toBe(status?.blockedBy || 'unknown'); + expect(response.headers.get('retry-after')).toBe(String(status?.retryAfter || 60)); + }, +); diff --git a/tests/relay/request-isolation.test.ts b/tests/relay/request-isolation.test.ts new file mode 100644 index 00000000..01de1ff0 --- /dev/null +++ b/tests/relay/request-isolation.test.ts @@ -0,0 +1,225 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, expect, it, vi } from 'vitest'; +import { createBrowserLifecycle } from '../../scripts/relay/lifecycle.js'; +function fixture(takeRetryQuota?: () => Promise) { + const page = Object.assign(new EventEmitter(), { + goto: vi.fn().mockResolvedValue(undefined), + waitForURL: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockImplementation((fn, args) => fn(args)), + }); + const context = Object.assign(new EventEmitter(), { + newPage: vi.fn().mockResolvedValue(page), + pages: () => [page], + }); + const owner = { + browser: { newContext: vi.fn().mockResolvedValue(context) }, + close: vi.fn().mockResolvedValue(undefined), + rss: vi.fn().mockResolvedValue(100), + pid: 123, + }; + const launch = vi.fn().mockResolvedValue(owner); + const fetcher = vi + .fn() + .mockImplementation(async () => Response.json({ status: 'SUCCESS' })); + vi.stubGlobal('fetch', fetcher); + return { + page, + owner, + launch, + fetcher, + life: createBrowserLifecycle(launch, undefined, takeRetryQuota), + }; +} +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); +it('한 요청의15초 fetch timeout이 브라우저를 종료하거나 다음 정상 요청을15분 차단하지 않는다', async () => { + const f = fixture(); + f.fetcher.mockRejectedValueOnce(new DOMException('private detail', 'TimeoutError')); + try { + await expect(f.life.run('/p', {})).rejects.toThrow(); + expect(f.owner.close).not.toHaveBeenCalled(); + expect(f.life.status()).toMatchObject({ state: 'ready', pages: 1, retryAt: 0 }); + await expect(f.life.run('/p', {})).resolves.toMatchObject({ status: 'SUCCESS' }); + expect(f.launch).toHaveBeenCalledTimes(1); + } finally { + await f.life.close(); + } +}); +it.each([400, 403, 429, 500, 503])( + '개별 HTTP%d 실패는 그대로 반환하되 사용 가능한 브라우저를 보존한다', + async (status) => { + const f = fixture(); + f.fetcher.mockResolvedValueOnce(new Response(null, { status })); + try { + await expect(f.life.run('/p', {})).rejects.toMatchObject({ upstreamStatus: status }); + expect(f.owner.close).not.toHaveBeenCalled(); + await expect(f.life.run('/p', {})).resolves.toMatchObject({ status: 'SUCCESS' }); + } finally { + await f.life.close(); + } + }, +); +it('업무상 실패 응답도 성공이나 품절로 바꾸지 않고 다른 요청에 영향을 주지 않는다', async () => { + const f = fixture(); + f.fetcher.mockResolvedValueOnce(Response.json({ status: 'FAIL', message: 'private' })); + try { + await expect(f.life.run('/p', {})).rejects.toThrow(); + expect(f.owner.close).not.toHaveBeenCalled(); + await expect(f.life.run('/p', {})).resolves.toMatchObject({ status: 'SUCCESS' }); + } finally { + await f.life.close(); + } +}); +it.each([new DOMException('private', 'AbortError'), 'private', null])( + 'fetch 실패의 비공개 원문을 외부에 노출하지 않는다', + async (failure) => { + const f = fixture(); + f.fetcher.mockRejectedValueOnce(failure); + try { + await expect(f.life.run('/p', {})).rejects.not.toThrow('private'); + expect(f.owner.close).not.toHaveBeenCalled(); + } finally { + await f.life.close(); + } + }, +); +it('연속 실패와 성공이 섞인 200회 시도 뒤에도 정상 교체하고 다음 조회를 처리한다', async () => { + const f = fixture(); + try { + for (let i = 0; i < 100; i++) { + f.fetcher.mockResolvedValueOnce(new Response(null, { status: 503 })); + await expect(f.life.run('/p', {})).rejects.toMatchObject({ upstreamStatus: 503 }); + await expect(f.life.run('/p', {})).resolves.toMatchObject({ status: 'SUCCESS' }); + } + await expect(f.life.run('/p', {})).resolves.toMatchObject({ status: 'SUCCESS' }); + expect(f.life.status()).toMatchObject({ state: 'ready', rotations: 1, retryAt: 0 }); + expect(f.launch).toHaveBeenCalledTimes(2); + } finally { + await f.life.close(); + } +}); + +it.each([403, 200])( + '세션 거절 HTTP%d가 3회 연속이면 제한적으로 교체하고 복구 대기를 유지한다', + async (status) => { + vi.useFakeTimers(); + const f = fixture(); + try { + for (let i = 0; i < 3; i++) { + f.fetcher.mockResolvedValueOnce(new Response('challenge', { status })); + await expect(f.life.run('/p', {})).rejects.toThrow(); + } + expect(f.owner.close).toHaveBeenCalledTimes(1); + expect(f.life.status()).toMatchObject({ state: 'idle', reason: 'session-rejected' }); + await expect(f.life.run('/p', {})).rejects.toThrow('backoff'); + expect(f.launch).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(60001); + await expect(f.life.run('/p', {})).resolves.toMatchObject({ status: 'SUCCESS' }); + expect(f.launch).toHaveBeenCalledTimes(2); + } finally { + await f.life.close(); + } + }, +); +it('HTTP 오류 본문을 즉시 취소하고 비공개 본문을 읽지 않는다', async () => { + const f = fixture(); + const cancel = vi.fn(); + f.fetcher.mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status: 503 })); + try { + await expect(f.life.run('/p', {})).rejects.toMatchObject({ upstreamStatus: 503 }); + expect(cancel).toHaveBeenCalledTimes(1); + } finally { + await f.life.close(); + } +}); +it('HTTP 503 실패만 200회여도 세션 사용 상한에 따라 교체한다', async () => { + const f = fixture(); + f.fetcher.mockImplementation(async () => new Response(null, { status: 503 })); + try { + for (let i = 0; i < 200; i++) + await expect(f.life.run('/p', {})).rejects.toMatchObject({ upstreamStatus: 503 }); + f.fetcher.mockImplementation(async () => Response.json({ status: 'SUCCESS' })); + await expect(f.life.run('/p', {})).resolves.toMatchObject({ status: 'SUCCESS' }); + expect(f.life.status()).toMatchObject({ state: 'ready', rotations: 1, retryAt: 0 }); + expect(f.launch).toHaveBeenCalledTimes(2); + } finally { + await f.life.close(); + } +}); + +it('첫 조회 뒤 페이지 이동으로 사라진 컨텍스트는 이동 완료 후 한 번 재시도한다', async () => { + const f = fixture(); + f.page.evaluate.mockRejectedValueOnce( + new Error( + 'page.evaluate: Execution context was destroyed, most likely because of a navigation', + ), + ); + try { + await expect(f.life.run('/p', {})).resolves.toMatchObject({ status: 'SUCCESS' }); + expect(f.page.waitForURL).toHaveBeenLastCalledWith('https://www.oliveyoung.co.kr/store/**', { + waitUntil: 'domcontentloaded', + timeout: 5000, + }); + expect(f.page.evaluate).toHaveBeenCalledTimes(2); + expect(f.owner.close).not.toHaveBeenCalled(); + } finally { + await f.life.close(); + } +}); +it('페이지 이동 오류의 재시도도 실패하면 기존 유한 복구 절차를 따른다', async () => { + const f = fixture(); + f.page.evaluate.mockRejectedValue(new Error('Execution context was destroyed')); + try { + await expect(f.life.run('/p', {})).rejects.toThrow('Execution context was destroyed'); + expect(f.page.evaluate).toHaveBeenCalledTimes(2); + expect(f.owner.close).toHaveBeenCalledTimes(1); + } finally { + await f.life.close(); + } +}); +it('비 Error 컨텍스트 오류는 임의로 재시도하지 않는다', async () => { + const f = fixture(); + f.page.evaluate.mockRejectedValueOnce('unavailable'); + try { + await expect(f.life.run('/p', {})).rejects.toBe('unavailable'); + expect(f.page.evaluate).toHaveBeenCalledTimes(1); + } finally { + await f.life.close(); + } +}); + +it.each([true, false])( + '페이지 이동 재시도도 별도 원본 할당량 승인 %s를 따른다', + async (allowed) => { + const takeQuota = vi.fn().mockResolvedValue(allowed); + const f = fixture(takeQuota); + f.page.evaluate.mockRejectedValueOnce(new Error('Execution context was destroyed')); + try { + if (allowed) await expect(f.life.run('/p', {})).resolves.toMatchObject({ status: 'SUCCESS' }); + else await expect(f.life.run('/p', {})).rejects.toMatchObject({ reason: 'retry-quota' }); + expect(takeQuota).toHaveBeenCalledTimes(1); + expect(f.page.evaluate).toHaveBeenCalledTimes(allowed ? 2 : 1); + expect(f.owner.close).not.toHaveBeenCalled(); + } finally { + await f.life.close(); + } + }, +); +it('세션 교체는 이전 세션의 거절 횟수를 이어받지 않는다', async () => { + const f = fixture(); + vi.useFakeTimers(); + try { + for (let i = 0; i < 2; i++) { + f.fetcher.mockResolvedValueOnce(new Response(null, { status: 403 })); + await expect(f.life.run('/p', {})).rejects.toThrow(); + } + await vi.advanceTimersByTimeAsync(30 * 60 * 1000 + 1); + f.fetcher.mockResolvedValueOnce(new Response(null, { status: 403 })); + await expect(f.life.run('/p', {})).rejects.toThrow(); + expect(f.life.status()).toMatchObject({ state: 'ready', retryAt: 0 }); + } finally { + await f.life.close(); + } +}); diff --git a/tests/relay/transport-lifecycle.test.ts b/tests/relay/transport-lifecycle.test.ts index 1cff66da..e2a43ded 100644 --- a/tests/relay/transport-lifecycle.test.ts +++ b/tests/relay/transport-lifecycle.test.ts @@ -27,3 +27,8 @@ it('accepts explicit browser and rejects unknown transport', async () => { expect(await createTransportLifecycle('browser', '/state')).toBe('browser'); await expect(createTransportLifecycle('bogus', '/state')).rejects.toThrow('OY_RELAY_TRANSPORT'); }); +it('브라우저 재시도에 별도 원본 할당량 함수를 전달한다', async () => { + const quota = vi.fn().mockResolvedValue(true); + await createTransportLifecycle('browser', '/state', undefined, quota); + expect(mocks.browser).toHaveBeenLastCalledWith(expect.any(Function), undefined, quota); +});