diff --git a/.github/workflows/health-checks.yml b/.github/workflows/health-checks.yml index 2e0d8386..5ef5b5fb 100644 --- a/.github/workflows/health-checks.yml +++ b/.github/workflows/health-checks.yml @@ -119,7 +119,7 @@ jobs: } # 실패 직후 전체 검사를 반복하면 정상 경로까지 중계 한도를 다시 소비합니다. - # 검증된 분당 제한만 만료 후 해당 ID를 한 번 재확인하고 최초 실패를 보존합니다. + # 검증된 자체 분당 제한과 명시적 올리브영 원본 429만 대기 후 해당 ID를 한 번 확인합니다. run_health_check - name: Preserve health check attempts diff --git a/docs/health-check-stock-and-quota.md b/docs/health-check-stock-and-quota.md index 1d62023e..a355f8ce 100644 --- a/docs/health-check-stock-and-quota.md +++ b/docs/health-check-stock-and-quota.md @@ -21,10 +21,10 @@ CU·GS25·세븐일레븐 Mac 중계는 같은 원장을 공유하며 분당 690 응답의 `diagnostics.quotaReason`이 `minute`, `daily`, `consumer`, `consumer-busy`이면 중계가 확인한 자체 정책 제한입니다. `retryAfter`는 재시도까지 기다릴 초 단위 시간입니다. 단순 HTTP 429만으로 자체 제한과 원본 업체 제한을 구분하지 않습니다. -헬스체크 결과에도 `errorCode`, `upstreamStatus`, `quotaReason`, `retryAfter`를 보존합니다. Actions는 실패 직후 전체 점검을 다시 실행하지 않습니다. 정상 경로까지 반복 호출하며 중계 한도를 소비하던 동작을 제거했습니다. 첫 실패 기록을 보존하고 다음 점검에서 재확인합니다. +헬스체크 결과에도 `errorCode`, `upstreamStatus`, `quotaReason`, `retryAfter`를 보존합니다. Actions는 실패 직후 전체 점검을 다시 실행하지 않습니다. 정상 경로까지 반복 호출하며 중계 한도를 소비하던 동작을 제거했습니다. 첫 실패 기록을 보존하고, 아래 허용 조건에 맞는 경우에만 대기 후 단일 검사를 재확인합니다. CGV·디트릭스 중계는 같은 원장을 공유하며 분당 300회, UTC 하루 300,000회로 제한합니다. 편의점 원장과 분리되어 있으며 원장을 초기화하지 않습니다. 편의점 중계가 자체 분·일 한도로 거부하면 HTTP 429에 `x-relay-quota-reason: minute|daily`와 `Retry-After`를 반환합니다. Worker는 이 값을 공개 진단과 재시도 대기 시간에 반영합니다. 외부 서비스가 반환한 429에는 자체 한도 사유를 붙이지 않습니다. -CI는 검증된 자체 분당 제한(429·minute·1~60초 Retry-After)에 걸린 알려진 검사만 만료 후 한 번 재확인합니다. 전체 스위트를 다시 실행하지 않으며, 일일 제한·외부 오류·사유가 없는 오류는 재시도하지 않습니다. 항목당 20초·재확인 전체 120초 상한을 적용하고, 같은 검사 ID의 캐시되지 않은 정상 응답만 실패를 대체합니다. 선택 항목인 세븐일레븐 인기 검색어는 검증된 HTTP 200 빈 응답도 기존 `skipped` 기준을 유지합니다. 첫 실패·재확인·최종 결과와 재시도 항목을 아티팩트에 보존합니다. 재확인이 실패하면 CI도 계속 실패합니다. +CI는 검증된 자체 분당 제한(429·minute·1~60초 Retry-After) 또는 명시적인 올리브영 원본 제한(`OLIVEYOUNG_UPSTREAM_RATE_LIMITED`·429·자체 한도 사유 없음·1~60초 대기)에 걸린 알려진 검사만 만료 후 한 번 재확인합니다. 전체 스위트를 다시 실행하지 않으며, 일일 제한·403·일반 외부 오류·사유가 없는 오류는 재시도하지 않습니다. 항목당 20초·재확인 전체 120초 상한을 적용하고, 같은 검사 ID의 캐시되지 않은 정상 응답만 실패를 대체합니다. 선택 항목인 세븐일레븐 인기 검색어는 검증된 HTTP 200 빈 응답도 기존 `skipped` 기준을 유지합니다. 첫 실패·재확인·최종 결과와 재시도 항목을 아티팩트에 보존합니다. 재확인이 실패하면 CI도 계속 실패합니다. diff --git a/docs/oliveyoung-free-relay.md b/docs/oliveyoung-free-relay.md index db0f1901..28281a94 100644 --- a/docs/oliveyoung-free-relay.md +++ b/docs/oliveyoung-free-relay.md @@ -88,6 +88,8 @@ GitHub Actions의 `Sync Worker Secrets`는 위 네 OY secrets를 동기화하며 중계는 한 브라우저·한 컨텍스트·한 페이지를 재사용합니다. 요청마다 탭을 만들지 않습니다. 추가 페이지는 즉시 회수하며 회수 실패·페이지 crash·Node 측 watchdog timeout에는 소유한 브라우저 세션을 폐기합니다. 성공·실패를 합쳐 200회 시도 또는 30분 사용 후 유휴 경계에서 정리하고 교체 세션을 한 번 미리 준비합니다. RSS 한도 회수도 같은 준비 경로를 사용합니다. 이전 소유 프로세스의 회수가 완료된 다음 새 브라우저를 띄우므로 브라우저 두 개를 겹쳐 유지하지 않습니다. 준비 중 health 상태는 `starting`입니다. 준비 실패 시 무한 재시도하지 않고 다음 명시적 요청을 기다립니다. 준비 중 도착한 요청은 여전히 준비 시간을 기다릴 수 있으므로 항상 15초 이내 응답을 보장하는 것은 아닙니다. +원본 HTTP 429는 `OLIVEYOUNG_UPSTREAM_RATE_LIMITED`, HTTP 429, `upstreamStatus: 429`, 중립 `retryAfter`로 전달하며 자체 한도의 `quotaReason`은 붙이지 않습니다. 양의 정수 초 또는 미래 HTTP-date 형식의 `Retry-After`를 읽고, 남은 시간을 초 단위로 올림해 최대 86400초로 제한합니다. 헤더가 없거나 잘못되면 원본 안내로 주장하지 않고 `retryAfterSource: local-policy`인 60초 대기를 적용합니다. 같은 작업의 새 원본 요청과 대기열은 이 기간에 원본 호출과 한도 차감을 멈춥니다. 유효한 일반 캐시는 계속 사용할 수 있으며 fresh 조회는 429 실패를 유지합니다. 브라우저는 이 요청 제한으로 재시작하지 않습니다. Health CI는 알려진 올리브영 검사에서 이 명시적 코드와 1~60초 대기가 확인될 때만 인증된 fresh 단일 검사를 한 번 실행합니다. 최초 실패 아티팩트는 유지하고 재확인도 실패하면 실패 상태를 보존합니다. + 개별 원본 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까지 강제 종료되거나 소유권 확인이 실패한 경우 소유 마커를 유지하여 재시작을 차단합니다. 이 경우 운영자가 잔존 프로세스를 확인해야 합니다. 소유 확인 없이 마커 파일을 지우면 안 됩니다. diff --git a/docs/relay-observability.md b/docs/relay-observability.md index dc426367..d9c2d3ee 100644 --- a/docs/relay-observability.md +++ b/docs/relay-observability.md @@ -64,7 +64,7 @@ health.cache의 `hits`, `misses`, `expired`, `evicted`는 프로세스 시작 ## GitHub Health Checks 판정과 보존 -quick/full 모두 운영 인증과 `fresh=true`, `x-health-check-force-fresh: true`를 함께 보내 새 검사 요약을 요청한다. 허용된 원점의 검사 대상에는 운영 인증과 fresh 헤더가 전달되며 REST·OY 내부·공통 릴레이 캐시를 우회한다. 실패 직후 전체 검사를 반복하지 않고, 검증된 분당 중계 한도 429만 만료 후 해당 검사 ID를 한 번 fresh로 재확인한다. HTTP 실패 후 이전 성공 파일을 재사용하지 않으며, 비정상 JSON·빈 검사 목록·모순된 요약은 실패로 처리한다. 각 요청은 최대 240초, 전체 job은 최대 10분이다. 시도별 원본 JSON과 오류 요약은 Actions artifact로 7일 보관한다. +quick/full 모두 운영 인증과 `fresh=true`, `x-health-check-force-fresh: true`를 함께 보내 새 검사 요약을 요청한다. 허용된 원점의 검사 대상에는 운영 인증과 fresh 헤더가 전달되며 REST·OY 내부·공통 릴레이 캐시를 우회한다. 실패 직후 전체 검사를 반복하지 않고, 검증된 분당 중계 한도 429 또는 명시적인 올리브영 원본 429(`OLIVEYOUNG_UPSTREAM_RATE_LIMITED`, 자체 한도 사유 없음)만 1~60초 대기 만료 후 해당 검사 ID를 한 번 fresh로 재확인한다. HTTP 실패 후 이전 성공 파일을 재사용하지 않으며, 비정상 JSON·빈 검사 목록·모순된 요약은 실패로 처리한다. 각 요청은 최대 240초, 전체 job은 최대 10분이다. 시도별 원본 JSON과 오류 요약은 Actions artifact로 7일 보관한다. CGV는 `CGV_UPSTREAM_UNAVAILABLE`, API HTTP 503, 원본 HTTP 403이 모두 확인될 때만 `degraded`로 분류한다. 이는 해당 기능이 제한된 상태이며 복구를 뜻하지 않는다. 인증 오류, 타임아웃, JSON 파싱 실패 등은 계속 `fail`이다. 선택 데이터가 없는 `skipped` 검사는 정상 요약에 포함될 수 있다. Actions 성공 여부와 별도로 요약의 저하 서비스 목록을 확인해야 한다. 유료 Zyte 재시도는 활성화하지 않는다. diff --git a/docs/seveneleven-web-stock-flow-analysis.md b/docs/seveneleven-web-stock-flow-analysis.md index 570f68ee..875e64a3 100644 --- a/docs/seveneleven-web-stock-flow-analysis.md +++ b/docs/seveneleven-web-stock-flow-analysis.md @@ -222,3 +222,13 @@ 1. `open/search/store` 결과의 `storeCdList`를 그대로 후보군으로 사용 2. `open/product/search/stock`에서 `smCd/stokMngCd/stokMngQty/stockApplicationRate` 확보 3. `open/real-stock/multi/01|02/stocks`로 직접 재고수량 조회 + +## 2026-10-26: 공개 웹 세션 준비를 통한 HTTP 복구 + +실제 운영 캐시 우회 검사에서 Mac의 일반 HTTP 요청이 원본 403으로 거절됐습니다. 같은 Mac에서 공식 페이지를 연 브라우저와 헤드리스 브라우저의 상품 API는 200이었으며, 이후 Node HTTP 조회도 200으로 복구됐습니다. 쿠키를 포함하지 않은 후속 Node 대조 요청도 성공했으므로, 쿠키 자체가 반드시 필요했다거나 모든 차단 원인이 쿠키였다고 단정하지 않습니다. + +중계는 일반 HTTP를 먼저 사용합니다. 선택 설정 `SEVEN_BROWSER_EXECUTABLE`이 있으면 원본 403일 때만 전용 헤드리스 브라우저로 공식 루트 페이지의 공개 세션을 준비합니다. 개인 프로필이나 계정 로그인을 사용하지 않습니다. 브라우저 회수를 확인한 뒤 HTTP API를 한 번만 재시도하고, 이 추가 API 요청도 기존 호출 원장에 반영합니다. 브라우저는 상시 실행하지 않습니다. + +공개 세션은 메모리에 최대 30분 보존하며, 쿠키는 세븐일레븐의 고정 원점과 허용 API 경로에만 전송합니다. 준비 작업은 공유하고 최소 60초 간격을 두며, 준비 실패나 반복 403을 정상으로 바꾸지 않습니다. 일반 HTTP에서 정상인 동안에는 브라우저를 시작하지 않습니다. 세션 값·원문·검색 조건은 로그나 health에 기록하지 않습니다. 인증된 편의점 health의 `sevenSession`은 준비 상태와 만료·재시도 시각만 제공합니다. + +운영 설정에는 설치된 전용 Chrome for Testing 실행 파일의 절대 경로를 지정합니다. 편의점 상태 디렉터리의 `seven-browser-owner.json`은 기존 guard의 소유권·회수 규칙을 사용합니다. 마커가 남았을 때 소유권 확인 없이 삭제하지 않습니다. 새로운 정상 웹 세션이 준비돼도 원본 403이 계속되거나 추가 사용자 확인이 요구되면 자동으로 그 확인을 우회하지 않으며 실패를 유지합니다. diff --git a/scripts/ops/health-check-retry.ts b/scripts/ops/health-check-retry.ts index 47fa0670..bbca7d3d 100644 --- a/scripts/ops/health-check-retry.ts +++ b/scripts/ops/health-check-retry.ts @@ -1,4 +1,4 @@ -/** 분당 중계 한도로 실패한 알려진 검사만 한 번 다시 확인합니다. */ +/** 확인된 짧은 요청 제한으로 실패한 알려진 검사만 한 번 다시 확인합니다. */ import type { HealthCheckSummary, HealthCheckResult } from '../../src/api/healthCheckTypes.js'; export const MINUTE_RECHECK_IDS = [ 'cu.stores', 'cu.inventory', 'gs25.products', 'gs25.stores', 'gs25.inventory', @@ -37,9 +37,10 @@ export async function retryMinuteChecks(payload: HealthCheckSummary, options: Re if (!isValidHealthPayload(payload)) throw new Error('Invalid health payload'); const candidates = payload.checks.filter(check => check.status === 'fail' && MINUTE_RECHECK_IDS.includes(check.id) && check.httpStatus === 429 && check.upstreamStatus === 429 && - (check.id === 'cli.contract' + (((check.id.startsWith('oliveyoung.') || check.id === 'cli.contract') && check.errorCode === 'OLIVEYOUNG_UPSTREAM_RATE_LIMITED' && + check.quotaReason === undefined) || (check.quotaReason === 'minute' && (check.id === 'cli.contract' ? ['CONVENIENCE_RELAY_FAILED', 'OLIVEYOUNG_RELAY_HTTP_ERROR', 'DTRYX_RELAY_FAILED', 'CGV_RELAY_FAILED'].includes(check.errorCode ?? '') - : check.errorCode === relayErrorCode(check.id)) && check.quotaReason === 'minute' && + : check.errorCode === relayErrorCode(check.id)))) && typeof check.retryAfter === 'number' && Number.isInteger(check.retryAfter) && check.retryAfter >= 1 && check.retryAfter <= 60); if (!candidates.length) return { payload, attempts }; diff --git a/scripts/relay/browser.ts b/scripts/relay/browser.ts index cecebb1f..8b7605ed 100644 --- a/scripts/relay/browser.ts +++ b/scripts/relay/browser.ts @@ -5,6 +5,7 @@ export class BrowserRequestError extends Error { constructor( readonly reason: string, readonly upstreamStatus?: number, + readonly retryAfter?: number, ) { super(`올리브영 브라우저 응답 실패: ${reason}`); } @@ -29,7 +30,12 @@ export function createBrowserRunner(page: Pick): BrowserRunner status = response.status; if (status !== 200) { await response.body?.cancel(); - return { status, body: null }; + const header = response.headers.get('retry-after'); + const seconds = header && /^\d+$/.test(header) ? Number(header) + : header ? Math.ceil((Date.parse(header) - Date.now()) / 1000) : NaN; + const retryAfter = Number.isSafeInteger(seconds) && seconds > 0 + ? Math.min(seconds, 86400) : undefined; + return { status, body: null, retryAfter }; } const reader = response.body?.getReader(); if (!reader) throw new Error('Browser response body missing'); @@ -79,6 +85,7 @@ export function createBrowserRunner(page: Pick): BrowserRunner ? 'business' : 'invalid-json'), result.status, + result.retryAfter, ); return result.body; }; diff --git a/scripts/relay/dtryx-start.ts b/scripts/relay/dtryx-start.ts index 77732b08..0d72ac6b 100644 --- a/scripts/relay/dtryx-start.ts +++ b/scripts/relay/dtryx-start.ts @@ -11,6 +11,8 @@ import { assertSeparateQuotaDirectories, CONVENIENCE_QUOTA_LIMITS, } from './quota.js'; +import { createSevenSessionTransport } from './seven-session.js'; +import { prepareSevenBrowserSession } from './seven-browser-session.js'; import { createConvenienceRelay } from './convenience.js'; import { createGs25SessionTransport } from './gs25-session.js'; import { createJapanProductRelay } from './daiso-jp.js'; @@ -71,11 +73,25 @@ async function main() { }; void maintainAuthentication(); } + const sevenExecutable = process.env.SEVEN_BROWSER_EXECUTABLE?.trim(); + const sevenSession = sevenExecutable + ? createSevenSessionTransport({ + takeQuota: quota, + prepare: () => + prepareSevenBrowserSession( + join(convenienceDir, 'seven-browser-owner.json'), + sevenExecutable, + ), + onEvent: (outcome) => + convenienceLog!.append({ stage: 'session', operation: 'seven', outcome }), + }) + : undefined; const convenienceHandler = createConvenienceRelay(convenienceToken, { takeQuota: quota, onEvent: convenienceLog.append, gs25ApiKey: process.env.GS25_API_KEY, gs25Session: session, + fetcher: sevenSession?.fetch, }); convenience = observeRelay(async (request) => { const response = await convenienceHandler(request); @@ -85,6 +101,7 @@ async function main() { quota: quota.status(), logging: convenienceLog!.status(), authentication: session?.status(), + sevenSession: sevenSession?.status(), }); return response; }, convenienceLog.append); diff --git a/scripts/relay/guard.ts b/scripts/relay/guard.ts index 033d9793..8ff44daf 100644 --- a/scripts/relay/guard.ts +++ b/scripts/relay/guard.ts @@ -8,11 +8,12 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.argv[2], (crashDir) => chromium.launchServer({ - headless: false, + headless: process.argv[3] === 'headless', chromiumSandbox: true, env: browserEnvironment(process.env, crashDir), host: '127.0.0.1', - executablePath: process.env.OY_BROWSER_EXECUTABLE, + executablePath: + process.argv[3] === 'headless' ? process.argv[4] : process.env.OY_BROWSER_EXECUTABLE, handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false, diff --git a/scripts/relay/oliveyoung.ts b/scripts/relay/oliveyoung.ts index 1fa62f05..0bd3386b 100644 --- a/scripts/relay/oliveyoung.ts +++ b/scripts/relay/oliveyoung.ts @@ -89,6 +89,17 @@ export function createOliveyoungRelay( 'stock-goods-info-v3': 600000, 'stock-stores': 60000, }; + const cooldowns = new Map(); + const cooldownError = (operation: string) => { + const cooldown = cooldowns.get(operation); + if (!cooldown || cooldown.until <= Date.now()) return undefined; + return new BrowserRequestError('http-429', 429, Math.ceil((cooldown.until - Date.now()) / 1000)); + }; + const originLimited = (failure: BrowserRequestError, operation: string) => Response.json({ + error: 'Upstream rate limited', code: 'OLIVEYOUNG_UPSTREAM_RATE_LIMITED', + upstreamStatus: 429, retryAfter: failure.retryAfter, + retryAfterSource: cooldowns.get(operation)!.source, + }, { status: 429, headers: { 'retry-after': String(failure.retryAfter) } }); type Result = OliveyoungApiResponse | null | 'quota' | 'unavailable'; const pending = new Map< string, @@ -188,6 +199,8 @@ export function createOliveyoungRelay( headers: { 'content-type': 'application/json', 'x-relay-cache': 'hit' }, }); } + const cooling = cooldownError(operation); + if (cooling) return originLimited(cooling, operation); let entry = pending.get(key); const mode = entry ? 'coalesced' : 'miss'; emit('cache', mode, entry?.requestId); @@ -206,6 +219,8 @@ export function createOliveyoungRelay( ) ) return null; + const cooling = cooldownError(operation); + if (cooling) throw cooling; if (options.takeQuota) { try { if (!(await options.takeQuota())) { @@ -219,7 +234,18 @@ export function createOliveyoungRelay( } } reservation.commit(); - const result = await run(paths[operation], body); + let result: OliveyoungApiResponse; + try { + result = await run(paths[operation], body); + } catch (failure) { + if (failure instanceof BrowserRequestError && failure.upstreamStatus === 429) { + const valid = Number.isInteger(failure.retryAfter) && failure.retryAfter! >= 1 && failure.retryAfter! <= 86400; + const retryAfter = valid ? failure.retryAfter! : 60; + cooldowns.set(operation, { until: Date.now() + retryAfter * 1000, source: valid ? 'upstream' : 'local-policy' }); + throw new BrowserRequestError('http-429', 429, retryAfter); + } + throw failure; + } if (!bypassCache && result?.status === 'SUCCESS') cache.set(key, JSON.stringify(result), ttl[operation]); emit('upstream', result?.status === 'SUCCESS' ? 'success' : 'error'); @@ -263,6 +289,10 @@ export function createOliveyoungRelay( return Response.json(result, { headers: { 'x-relay-cache': mode } }); } catch (failure) { if (failure instanceof BrowserRequestError) { + if (failure.upstreamStatus === 429) { + emit('upstream', 'http-429'); + return originLimited(failure, operation); + } if (failure.reason === 'retry-quota') { const status = options.takeQuota?.status?.(); return Response.json( diff --git a/scripts/relay/seven-browser-session.ts b/scripts/relay/seven-browser-session.ts new file mode 100644 index 00000000..b7f2cd94 --- /dev/null +++ b/scripts/relay/seven-browser-session.ts @@ -0,0 +1,33 @@ +/** 개인 프로필을 사용하지 않고 공개 웹 세션만 준비한 뒤 소유 브라우저를 종료합니다. */ +import { bounded } from './lifecycle.js'; +import { launchGuardedBrowser } from './supervisor.js'; +import type { SevenPublicSession } from './seven-session.js'; +export async function prepareSevenBrowserSession( + marker: string, + executable: string, +): Promise { + const owner = await launchGuardedBrowser(marker, undefined, executable); + try { + return await bounded( + (async () => { + const context = await owner.browser.newContext({ locale: 'ko-KR' }); + const page = await context.newPage(); + const response = await page.goto('https://new.7-elevenapp.co.kr/', { + waitUntil: 'domcontentloaded', + timeout: 10000, + }); + if (response?.status() !== 200) throw new Error('Seven public session unavailable'); + const userAgent = await page.evaluate(() => navigator.userAgent); + const cookies = await context.cookies('https://new.7-elevenapp.co.kr/'); + return { + cookie: cookies.map((c) => `${c.name}=${c.value}`).join('; '), + userAgent, + expiresAt: Date.now() + 30 * 60000, + }; + })(), + 15000, + ); + } finally { + await owner.close(); + } +} diff --git a/scripts/relay/seven-session.ts b/scripts/relay/seven-session.ts new file mode 100644 index 00000000..60a16105 --- /dev/null +++ b/scripts/relay/seven-session.ts @@ -0,0 +1,108 @@ +/** 세븐일레븐 공개 웹 세션을 HTTP 조회에 재사용하며 실패 복구를 한 번으로 제한합니다. */ +import { SEVENELEVEN_API } from '../../src/services/seveneleven/api.js'; +import { RelayError, type HttpRelayOptions } from './http-relay.js'; +export interface SevenPublicSession { + cookie: string; + userAgent: string; + expiresAt: number; +} +const origin = SEVENELEVEN_API.BASE_URL; +const paths = new Set([ + ...Object.values(SEVENELEVEN_API).filter((path) => path.startsWith('/api/')), + `${SEVENELEVEN_API.REAL_STOCK_MULTI_PATH}/01/stocks`, +]); +export function createSevenSessionTransport(options: { + fetcher?: typeof fetch; + prepare: () => Promise; + takeQuota: HttpRelayOptions['takeQuota']; + onEvent?: (reason: string) => void; +}) { + const fetcher = options.fetcher ?? fetch; + let session: SevenPublicSession | undefined; + let pending: Promise | undefined; + let nextPrepareAt = 0; + const emit = (reason: string) => { + try { + options.onEvent?.(reason); + } catch { + /* 관측 실패는 세션을 폐기하지 않습니다. */ + } + }; + const prepare = () => { + if (pending) return pending; + nextPrepareAt = Date.now() + 60000; + pending = (async () => { + try { + const value = await options.prepare(); + if ( + !value.cookie || + value.cookie.length > 16384 || + /[\r\n\0]/.test(value.cookie) || + !value.userAgent || + value.userAgent.length > 512 || + /[\r\n\0]/.test(value.userAgent) || + !Number.isFinite(value.expiresAt) || + value.expiresAt <= Date.now() + ) + throw new Error('Invalid public session'); + session = { ...value, expiresAt: Math.min(value.expiresAt, Date.now() + 30 * 60000) }; + emit('session-ready'); + } catch { + session = undefined; + emit('session-unavailable'); + } + })().finally(() => { + pending = undefined; + }); + return pending; + }; + const run: typeof fetch = async (input, init) => { + // 내부 HTTP 중계는 URL만 전달합니다. 재사용 불가능한 Request 스트림은 수정하지 않습니다. + if (input instanceof Request) return fetcher(input, init); + const url = new URL(input); + if (url.origin !== origin || !paths.has(url.pathname)) return fetcher(input, init); + init?.signal?.throwIfAborted(); + if (session && session.expiresAt <= Date.now()) session = undefined; + const withSession = () => { + const headers = new Headers(init?.headers); + if (session) { + headers.set('Cookie', session.cookie); + headers.set('User-Agent', session.userAgent); + } + return { ...init, headers }; + }; + const response = await fetcher(input, withSession()); + if (response.status !== 403) return response; + if (!pending && Date.now() < nextPrepareAt) return response; + await response.body?.cancel(); + await prepare(); + init?.signal?.throwIfAborted(); + if (!session) return response; + if (!(await options.takeQuota())) { + const quota = options.takeQuota.status?.(); + throw new RelayError( + 429, + undefined, + quota?.blockedBy === 'minute' || quota?.blockedBy === 'daily' + ? { quotaReason: quota.blockedBy, retryAfter: quota.retryAfter } + : undefined, + ); + } + // 준비가 성공해도 원본 API 재시도는 정확히 한 번만 수행합니다. + return fetcher(input, withSession()); + }; + return { + fetch: run, + status: () => ({ + state: pending + ? 'preparing' + : session && session.expiresAt > Date.now() + ? 'ready' + : Date.now() < nextPrepareAt + ? 'retry_wait' + : 'idle', + expiresAt: session?.expiresAt ?? 0, + retryAt: nextPrepareAt, + }), + }; +} diff --git a/scripts/relay/supervisor.ts b/scripts/relay/supervisor.ts index 49464e3e..07241f7f 100644 --- a/scripts/relay/supervisor.ts +++ b/scripts/relay/supervisor.ts @@ -12,6 +12,7 @@ import { ownedGroup, processSnapshot, type ProcessIdentity } from './ownership.j export async function launchGuardedBrowser( markerPath: string, onEvent?: (reason: string) => void | Promise, + headlessExecutable?: string, ): Promise { const release = await acquireMarkerLease(markerPath); markerPath = release.markerPath; @@ -26,7 +27,7 @@ export async function launchGuardedBrowser( } }; try { - return await launchWhileLocked(markerPath, serial, onEvent); + return await launchWhileLocked(markerPath, serial, onEvent, headlessExecutable); } finally { starting = false; await release(); @@ -36,6 +37,7 @@ async function launchWhileLocked( markerPath: string, serial: (action: () => Promise) => Promise, onEvent?: (reason: string) => void | Promise, + headlessExecutable?: string, ): Promise { const emit = (reason: string) => { try { @@ -51,9 +53,13 @@ async function launchWhileLocked( } emit('abandoned-marker-recovered'); } - const guard = fork(fileURLToPath(new URL('./guard.ts', import.meta.url)), [markerPath], { - stdio: ['ignore', 'ignore', 'ignore', 'ipc'], - }); + const guard = fork( + fileURLToPath(new URL('./guard.ts', import.meta.url)), + [markerPath, ...(headlessExecutable ? ['headless', headlessExecutable] : [])], + { + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + }, + ); let exited = false; let exitCode: number | null | undefined; const exit = new Promise((resolve) => diff --git a/src/services/oliveyoung/errors.ts b/src/services/oliveyoung/errors.ts index 1d799c1f..5fe20f6b 100644 --- a/src/services/oliveyoung/errors.ts +++ b/src/services/oliveyoung/errors.ts @@ -4,6 +4,16 @@ import { HttpError, UnexpectedHtmlResponseError } from '../../utils/http.js'; export function toOliveyoungRelayError(error: unknown): ServiceError { if (error instanceof HttpError) { + if (error.status === 429 && !error.quota) { + try { + const body = JSON.parse(error.bodyText); + if (body?.code === 'OLIVEYOUNG_UPSTREAM_RATE_LIMITED' && body.upstreamStatus === 429 && + Number.isInteger(body.retryAfter) && body.retryAfter >= 1 && body.retryAfter <= 86400) { + return new ServiceError('OLIVEYOUNG_UPSTREAM_RATE_LIMITED', '올리브영 원본 요청 제한', + 429, true, 429, undefined, undefined, body.retryAfter); + } + } catch { /* 안전한 원본 제한 계약만 분류합니다. */ } + } return new ServiceError( 'OLIVEYOUNG_RELAY_HTTP_ERROR', `올리브영 릴레이 요청 실패 (HTTP ${error.status})`, diff --git a/tests/api/oliveyoung-relay-diagnostics.test.ts b/tests/api/oliveyoung-relay-diagnostics.test.ts index 38103c18..6b87a226 100644 --- a/tests/api/oliveyoung-relay-diagnostics.test.ts +++ b/tests/api/oliveyoung-relay-diagnostics.test.ts @@ -33,3 +33,12 @@ it.each([ expect(payload.diagnostics).not.toHaveProperty('upstreamStatus'); expect(JSON.stringify(payload)).not.toContain('private-'); }); +it('명시한 원본 429의 중립 대기를 자체 한도와 구분한다', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 'OLIVEYOUNG_UPSTREAM_RATE_LIMITED', upstreamStatus: 429, retryAfter: 12, error: 'private-token' }, { status: 429 }))); + const response = await app.request('/products?keyword=팩', {}, { OY_RELAY_URL: 'https://private-relay.example', OY_RELAY_TOKEN: 'private-token' }); + expect(response.status).toBe(429); + const result = await response.json(); + expect(result.diagnostics).toMatchObject({ code: 'OLIVEYOUNG_UPSTREAM_RATE_LIMITED', upstreamStatus: 429, retryAfter: 12 }); + expect(result.diagnostics).not.toHaveProperty('quotaReason'); + expect(JSON.stringify(result)).not.toContain('private'); +}); diff --git a/tests/relay/browser.test.ts b/tests/relay/browser.test.ts index 3faf3376..03b4e43e 100644 --- a/tests/relay/browser.test.ts +++ b/tests/relay/browser.test.ts @@ -73,3 +73,21 @@ it('본문 없는 HTTP 응답을 거절한다', async () => { vi.unstubAllGlobals(); } }); +it.each([['17', 17], ['0', undefined], ['1.5', undefined], ['bad', undefined], ['999999', 86400], [null, undefined]])('원본 429의 안전한 숫자 Retry-After %s만 보존한다', async (header, retryAfter) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('private challenge', { status: 429, headers: header === null ? {} : { 'retry-after': header } }))); + try { + const page = { evaluate: vi.fn().mockImplementation((fn, args) => fn(args)) }; + await expect(createBrowserRunner(page)('/p', {})).rejects.toMatchObject({ reason: 'http-429', upstreamStatus: 429, retryAfter }); + } finally { vi.unstubAllGlobals(); } +}); + +it.each([[17000, 17], [17001, 18], [-1000, undefined], [0, undefined], [86401000, 86400]])('원본 Retry-After HTTP 날짜의 남은 초 %s를 안전하게 보존한다', async (remaining, retryAfter) => { + const now = Date.UTC(2026, 9, 26, 1, 0, 0); + const clock = vi.spyOn(Date, 'now').mockReturnValue(now + (remaining === 17001 ? 999 : 0)); + const deadline = now + (remaining === 17001 ? 18000 : remaining); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('private challenge', { status: 429, headers: { 'retry-after': new Date(deadline).toUTCString() } }))); + try { + const page = { evaluate: vi.fn().mockImplementation((fn, args) => fn(args)) }; + await expect(createBrowserRunner(page)('/p', {})).rejects.toMatchObject({ reason: 'http-429', upstreamStatus: 429, retryAfter }); + } finally { clock.mockRestore(); vi.unstubAllGlobals(); } +}); diff --git a/tests/relay/oliveyoung-origin-limit.test.ts b/tests/relay/oliveyoung-origin-limit.test.ts new file mode 100644 index 00000000..8d4085d2 --- /dev/null +++ b/tests/relay/oliveyoung-origin-limit.test.ts @@ -0,0 +1,48 @@ +import { expect, it, vi } from 'vitest'; +import { createOliveyoungRelay } from '../../scripts/relay/oliveyoung.js'; +let consumerSequence = 0; +const consumerHeaders = () => ({ 'x-relay-consumer': (++consumerSequence).toString(16).padStart(64, '0') }); +const payload = { goodsNo: 'A1' }; +const request = (path = 'stock-goods-info-v3', body = JSON.stringify(payload), token = 'test') => + new Request(`http://localhost/v1/oliveyoung/${path}`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, ...consumerHeaders() }, + body, + }); +it('원본 429는 작업별 대기 동안 새 요청과 대기열의 한도 소비를 막는다', async () => { + const { BrowserRequestError } = await import('../../scripts/relay/browser.js'); + let fail!: (error: Error) => void; + const run = vi.fn().mockImplementationOnce(() => new Promise((_, reject) => { fail = reject; })).mockResolvedValue({ status: 'SUCCESS' }); + const takeQuota = vi.fn(async () => true); + const onEvent = vi.fn(); + const relay = createOliveyoungRelay('test', run, { takeQuota, onEvent }); + const first = relay(request()); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1)); + const queued = relay(request(undefined, JSON.stringify({ goodsNo: 'A2' }))); + await vi.waitFor(() => expect(onEvent.mock.calls.filter(([event]) => event.stage === 'cache')).toHaveLength(2)); + fail(new BrowserRequestError('http-429', 429, 2)); + for (const response of await Promise.all([first, queued])) { + expect(response.status).toBe(429); + expect(response.headers.get('retry-after')).toBe('2'); + expect(response.headers.get('x-relay-quota-reason')).toBeNull(); + expect(await response.json()).toMatchObject({ code: 'OLIVEYOUNG_UPSTREAM_RATE_LIMITED', upstreamStatus: 429, retryAfter: 2 }); + } + expect((await relay(request())).status).toBe(429); + expect(run).toHaveBeenCalledTimes(1); expect(takeQuota).toHaveBeenCalledTimes(1); + const now = Date.now(); const clock = vi.spyOn(Date, 'now').mockReturnValue(now + 2100); + try { expect((await relay(request())).status).toBe(200); } finally { clock.mockRestore(); } + expect(run).toHaveBeenCalledTimes(2); +}); +it('원본 대기 중 정상 캐시는 유지하고 fresh 실패와 로컬 기본 대기 정책을 구분한다', async () => { + const { BrowserRequestError } = await import('../../scripts/relay/browser.js'); + const run = vi.fn().mockResolvedValueOnce({ status: 'SUCCESS' }).mockRejectedValueOnce(new BrowserRequestError('http-429', 429)); + const relay = createOliveyoungRelay('test', run); + expect((await relay(request())).status).toBe(200); + const fresh = () => { const req = request(); req.headers.set('x-relay-cache-bypass', 'true'); return req; }; + const limited = await relay(fresh()); + expect(await limited.json()).toMatchObject({ retryAfter: 60, retryAfterSource: 'local-policy' }); + expect((await relay(request())).headers.get('x-relay-cache')).toBe('hit'); + expect((await relay(fresh())).status).toBe(429); + expect((await relay(request('product-search-v3', JSON.stringify({ includeSoldOut: true, keyword: '팩', page: 1, sort: '', size: 1 })))).status).toBe(502); + expect(run).toHaveBeenCalledTimes(3); +}); diff --git a/tests/relay/seven-browser-session.test.ts b/tests/relay/seven-browser-session.test.ts new file mode 100644 index 00000000..d2a58a87 --- /dev/null +++ b/tests/relay/seven-browser-session.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +const mocks = vi.hoisted(() => ({ launch: vi.fn() })); +vi.mock('../../scripts/relay/supervisor.js', () => ({ launchGuardedBrowser: mocks.launch })); +import { prepareSevenBrowserSession } from '../../scripts/relay/seven-browser-session.js'; +const page = { goto: vi.fn(), evaluate: vi.fn() }; +const context = { newPage: vi.fn(), cookies: vi.fn() }; +const owner = { browser: { newContext: vi.fn() }, close: vi.fn() }; +beforeEach(() => { + vi.resetAllMocks(); + mocks.launch.mockResolvedValue(owner); + owner.browser.newContext.mockResolvedValue(context); + context.newPage.mockResolvedValue(page); + page.goto.mockResolvedValue({ status: () => 200 }); + vi.stubGlobal('navigator', { userAgent: 'Browser' }); + page.evaluate.mockImplementation((fn) => fn()); + context.cookies.mockResolvedValue([{ name: 'public', value: 'session' }]); + owner.close.mockResolvedValue(undefined); +}); +it('비개인 헤드리스 브라우저에서 고정 원점의 쿠키만 준비하고 API 재고는 호출하지 않는다', async () => { + const result = await prepareSevenBrowserSession('/state/seven-marker', '/browser/chrome'); + expect(mocks.launch).toHaveBeenCalledWith('/state/seven-marker', undefined, '/browser/chrome'); + expect(result).toMatchObject({ cookie: 'public=session', userAgent: 'Browser' }); + expect(page.goto).toHaveBeenCalledWith( + 'https://new.7-elevenapp.co.kr/', + expect.objectContaining({ timeout: 10000 }), + ); + expect(context.cookies).toHaveBeenCalledWith('https://new.7-elevenapp.co.kr/'); + expect(owner.close).toHaveBeenCalledTimes(1); +}); +it.each([undefined, { status: () => 403 }])( + '준비 페이지가 정상200이 아니면 원문 없이 실패하고 회수한다', + async (response) => { + page.goto.mockResolvedValue(response); + await expect(prepareSevenBrowserSession('/state/marker', '/chrome')).rejects.toThrow( + 'unavailable', + ); + expect(owner.close).toHaveBeenCalledTimes(1); + }, +); +it('세션 조회가 실패해도 소유 브라우저 종료를 기다린다', async () => { + context.cookies.mockRejectedValue(new Error('unavailable')); + await expect(prepareSevenBrowserSession('/state/marker', '/chrome')).rejects.toThrow( + 'unavailable', + ); + expect(owner.close).toHaveBeenCalledTimes(1); +}); + +afterEach(() => vi.unstubAllGlobals()); diff --git a/tests/relay/seven-session.test.ts b/tests/relay/seven-session.test.ts new file mode 100644 index 00000000..c610af25 --- /dev/null +++ b/tests/relay/seven-session.test.ts @@ -0,0 +1,168 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { createSevenSessionTransport } from '../../scripts/relay/seven-session.js'; +const url = 'https://new.7-elevenapp.co.kr/api/v1/open/search/goods'; +const session = { + cookie: 'public_session=test', + userAgent: 'Browser', + expiresAt: Date.now() + 600000, +}; +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); +it('정상 HTTP 요청에는 브라우저를 준비하지 않는다', async () => { + const fetcher = vi.fn().mockResolvedValue(Response.json({ success: true })); + const prepare = vi.fn(); + const client = createSevenSessionTransport({ fetcher, prepare, takeQuota: vi.fn() }); + expect((await client.fetch(url, { method: 'POST', body: '{}' })).status).toBe(200); + expect(prepare).not.toHaveBeenCalled(); +}); +it('403이면 공개 세션을 한 번 준비한 뒤 별도 할당량으로 HTTP 재시도한다', async () => { + const cancel = vi.fn(); + const fetcher = vi + .fn() + .mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status: 403 })) + .mockResolvedValue(Response.json({ success: true })); + const prepare = vi.fn().mockResolvedValue(session); + const quota = vi.fn().mockResolvedValue(true); + const client = createSevenSessionTransport({ fetcher, prepare, takeQuota: quota }); + expect((await client.fetch(url, { method: 'POST', body: '{}' })).status).toBe(200); + expect(cancel).toHaveBeenCalledTimes(1); + expect(quota).toHaveBeenCalledTimes(1); + expect(new Headers(fetcher.mock.calls[1][1].headers).get('cookie')).toBe(session.cookie); + expect(new Headers(fetcher.mock.calls[1][1].headers).get('user-agent')).toBe('Browser'); + await client.fetch(url, { method: 'POST', body: '{}' }); + expect(prepare).toHaveBeenCalledTimes(1); +}); +it('동시 403은 같은 준비 작업을 공유하고 쿠키는 다른 원점에 보내지 않는다', async () => { + let finish!: (s: typeof session) => void; + const prepare = vi.fn(() => new Promise((resolve) => (finish = resolve))); + const fetcher = vi + .fn() + .mockImplementation(async (_u, init) => + new Headers(init?.headers).has('cookie') + ? Response.json({ success: true }) + : new Response(null, { status: 403 }), + ); + const client = createSevenSessionTransport({ + fetcher, + prepare, + takeQuota: vi.fn().mockResolvedValue(true), + }); + const one = client.fetch(url); + const two = client.fetch(url); + await vi.waitFor(() => expect(prepare).toHaveBeenCalledTimes(1)); + finish(session); + await Promise.all([one, two]); + await client.fetch('https://example.com/api/v1/open/search/goods'); + expect(fetcher.mock.calls.at(-1)?.[1]).toBeUndefined(); +}); +it('준비 실패와 반복 차단은 무한 준비하지 않으며 잘못된 세션도 거부한다', async () => { + vi.useFakeTimers(); + const prepare = vi.fn().mockRejectedValue(new Error('private')); + const fetcher = vi.fn().mockImplementation(async () => new Response(null, { status: 403 })); + const client = createSevenSessionTransport({ + fetcher, + prepare, + takeQuota: vi.fn().mockResolvedValue(true), + }); + expect((await client.fetch(url)).status).toBe(403); + expect((await client.fetch(url)).status).toBe(403); + expect(prepare).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(60001); + prepare.mockResolvedValue({ ...session, cookie: 'x\r\nsecret' } as never); + expect((await client.fetch(url)).status).toBe(403); + expect(client.status().state).toBe('retry_wait'); +}); +it('재시도 할당량 거절이나 취소를 원본 성공으로 숨기지 않는다', async () => { + const client = createSevenSessionTransport({ + fetcher: vi.fn().mockImplementation(async () => new Response(null, { status: 403 })), + prepare: vi.fn().mockResolvedValue(session), + takeQuota: vi.fn().mockResolvedValue(false), + }); + await expect(client.fetch(url)).rejects.toMatchObject({ status: 429 }); + const controller = new AbortController(); + controller.abort(); + await expect(client.fetch(url, { signal: controller.signal })).rejects.toThrow(); +}); + +it('기본 fetch와 허용되지 않은 Request·경로에는 쿠키를 주입하지 않는다', async () => { + const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetcher); + const client = createSevenSessionTransport({ prepare: vi.fn(), takeQuota: vi.fn() }); + expect(client.status().state).toBe('idle'); + const request = new Request(url); + await client.fetch(request); + expect(fetcher).toHaveBeenLastCalledWith(request, undefined); + await client.fetch('https://new.7-elevenapp.co.kr/private'); + expect(fetcher).toHaveBeenLastCalledWith('https://new.7-elevenapp.co.kr/private', undefined); +}); +it('준비 후에도 원본403이면 실패를 그대로 반환하고 준비를 반복하지 않는다', async () => { + const fetcher = vi.fn().mockImplementation(async () => new Response(null, { status: 403 })); + const prepare = vi.fn().mockResolvedValue(session); + const client = createSevenSessionTransport({ + fetcher, + prepare, + takeQuota: vi.fn().mockResolvedValue(true), + onEvent: () => { + throw new Error('observer'); + }, + }); + expect((await client.fetch(url)).status).toBe(403); + expect(client.status().state).toBe('ready'); + expect((await client.fetch(url)).status).toBe(403); + expect(prepare).toHaveBeenCalledTimes(1); +}); +it('동시 세션 준비 상태를 표시하고 만료된 쿠키는 다음 요청에서 제거한다', async () => { + vi.useFakeTimers(); + let finish!: (s: typeof session) => void; + const prepare = vi.fn(() => new Promise((r) => (finish = r))); + const fetcher = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 403 })) + .mockImplementation(async () => new Response(null, { status: 200 })); + const client = createSevenSessionTransport({ + fetcher, + prepare, + takeQuota: vi.fn().mockResolvedValue(true), + }); + const pending = client.fetch(url); + await vi.waitFor(() => expect(prepare).toHaveBeenCalled()); + expect(client.status().state).toBe('preparing'); + finish({ ...session, expiresAt: Date.now() + 1000 }); + await pending; + await vi.advanceTimersByTimeAsync(1001); + await client.fetch(url); + expect(new Headers(fetcher.mock.calls.at(-1)?.[1].headers).has('cookie')).toBe(false); +}); +it.each(['minute', 'daily'] as const)('재시도 자체 %s 한도를 구분한다', async (blockedBy) => { + const quota = Object.assign(vi.fn().mockResolvedValue(false), { + status: () => ({ blockedBy, retryAfter: 12 }), + }); + const client = createSevenSessionTransport({ + fetcher: vi.fn().mockResolvedValue(new Response(null, { status: 403 })), + prepare: vi.fn().mockResolvedValue(session), + takeQuota: quota, + }); + await expect(client.fetch(url)).rejects.toMatchObject({ + status: 429, + quota: { quotaReason: blockedBy, retryAfter: 12 }, + }); +}); +it.each([ + { cookie: '' }, + { cookie: 'x'.repeat(16385) }, + { userAgent: '' }, + { userAgent: 'x'.repeat(513) }, + { userAgent: 'x\r\n' }, + { expiresAt: NaN }, + { expiresAt: 0 }, +])('비정상 세션을 원문 노출 없이 거부한다', async (invalid) => { + const client = createSevenSessionTransport({ + fetcher: vi.fn().mockImplementation(async () => new Response(null, { status: 403 })), + prepare: vi.fn().mockResolvedValue({ ...session, ...invalid }), + takeQuota: vi.fn(), + }); + expect((await client.fetch(url)).status).toBe(403); + expect(client.status().state).toBe('retry_wait'); +}); diff --git a/tests/relay/supervisor.test.ts b/tests/relay/supervisor.test.ts index e2fa7466..be5f8516 100644 --- a/tests/relay/supervisor.test.ts +++ b/tests/relay/supervisor.test.ts @@ -237,3 +237,16 @@ it('시작 중 연결 실패도 같은 lease 안에서 비정상 감시자를 await expect(pending).rejects.toThrow('connect failed'); expect(mocks.recover).toHaveBeenCalledTimes(1); }); + +it('지정한 전용 실행 파일로 헤드리스 세션을 준비하고 기존 소유권 회수를 유지한다', async () => { + const child = fixture(); + const pending = launchGuardedBrowser('/tmp/test-seven-owner', undefined, '/browser/chrome'); + await ready(child); + const owner = await pending; + expect(mocks.fork).toHaveBeenCalledWith( + expect.any(String), + ['/tmp/test-seven-owner', 'headless', '/browser/chrome'], + expect.any(Object), + ); + await owner.close(); +}); diff --git a/tests/services/oliveyoung/relayErrors.test.ts b/tests/services/oliveyoung/relayErrors.test.ts index 21dd5f86..d3717677 100644 --- a/tests/services/oliveyoung/relayErrors.test.ts +++ b/tests/services/oliveyoung/relayErrors.test.ts @@ -72,3 +72,15 @@ it('릴레이 응답 본문을 읽는 중 실제 제한 시간이 지나도 시 vi.useRealTimers(); } }); +it.each([ + null, {}, { code: 'OTHER' }, + { code: 'OLIVEYOUNG_UPSTREAM_RATE_LIMITED', upstreamStatus: 403, retryAfter: 3 }, + ...[0, -1, 1.5, 86401, '3', undefined].map(retryAfter => ({ code: 'OLIVEYOUNG_UPSTREAM_RATE_LIMITED', upstreamStatus: 429, retryAfter })), +])('잘못된 원본 429 계약은 중계 HTTP 오류를 유지한다: %j', async body => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json(body, { status: 429 }))); + await expect(requestOliveyoung('/p', {}, options)).rejects.toMatchObject({ code: 'OLIVEYOUNG_RELAY_HTTP_ERROR', status: 429 }); +}); +it('자체 한도 헤더가 있으면 원본 제한 코드보다 자체 한도를 우선한다', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 'OLIVEYOUNG_UPSTREAM_RATE_LIMITED', upstreamStatus: 429, retryAfter: 3 }, { status: 429, headers: { 'x-relay-quota-reason': 'minute', 'retry-after': '5' } }))); + await expect(requestOliveyoung('/p', {}, options)).rejects.toMatchObject({ code: 'OLIVEYOUNG_RELAY_HTTP_ERROR', quotaReason: 'minute', retryAfter: 5 }); +}); diff --git a/tests/workflows/health-check-retry.test.ts b/tests/workflows/health-check-retry.test.ts index e99f9c53..2649c8fa 100644 --- a/tests/workflows/health-check-retry.test.ts +++ b/tests/workflows/health-check-retry.test.ts @@ -180,3 +180,20 @@ it('전체 120초 예산에는 최초 61초 대기도 포함한다', async () => expect(fetchImpl).toHaveBeenCalledTimes(3); expect((await task).payload.status).toBe('fail'); }); +it.each(['oliveyoung.products', 'oliveyoung.inventory', 'cli.contract'])('알려진 원본 429 %s만 인증된 fresh 단일 검사로 다시 확인한다', async id => { + const initial = payload([check(id, { errorCode: 'OLIVEYOUNG_UPSTREAM_RATE_LIMITED', quotaReason: undefined, retryAfter: 2 })]); + const fetchImpl = vi.fn(async () => Response.json(payload([check(id, { status: 'ok' })]))); + const result = await retryMinuteChecks(initial, { endpoint, secret: 'secret', fetchImpl, sleep: async () => undefined }); + expect(result.payload.status).toBe('ok'); expect(initial.status).toBe('fail'); expect(fetchImpl).toHaveBeenCalledTimes(1); +}); +it.each([{ id: 'cu.stores' }, { quotaReason: 'minute' as const }, { httpStatus: 502 }, { upstreamStatus: 403 }, { retryAfter: 61 }])('원본 대기 계약과 다르면 재검증하지 않는다: %j', async fields => { + const fetchImpl = vi.fn(); + await retryMinuteChecks(payload([check('oliveyoung.inventory', { errorCode: 'OLIVEYOUNG_UPSTREAM_RATE_LIMITED', quotaReason: undefined, ...fields })]), { endpoint, secret: 'secret', fetchImpl, sleep: async () => undefined }); + expect(fetchImpl).not.toHaveBeenCalled(); +}); + +it.each([{ quotaReason: 'minute' as const }, { httpStatus: 502 }, { upstreamStatus: 403 }, { retryAfter: 61 }, { errorCode: 'OLIVEYOUNG_RELAY_HTTP_ERROR', quotaReason: undefined }])('CLI의 원본 429 계약이 불완전하면 재검증하지 않는다: %j', async fields => { + const fetchImpl = vi.fn(); + await retryMinuteChecks(payload([check('cli.contract', { errorCode: 'OLIVEYOUNG_UPSTREAM_RATE_LIMITED', quotaReason: undefined, ...fields })]), { endpoint, secret: 'secret', fetchImpl, sleep: async () => undefined }); + expect(fetchImpl).not.toHaveBeenCalled(); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 91afa94d..1f6bfdc8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -55,6 +55,8 @@ export default defineConfig({ 'scripts/relay/supervisor.ts', 'scripts/relay/quota.ts', 'scripts/relay/consumer.ts', + 'scripts/relay/seven-session.ts', + 'scripts/relay/seven-browser-session.ts', 'scripts/relay/browser-environment.ts', 'scripts/relay/http-lifecycle.ts', 'scripts/relay/transport-lifecycle.ts',