8000 fix: 올리브영 준비를 정적 원점 문서로 분리 by hmmhmmhm · Pull Request #319 · hmmhmmhm/daiso-mcp · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/oliveyoung-free-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ GitHub Actions의 `Sync Worker Secrets`는 위 네 OY secrets를 동기화하며

원본 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분 세션 교체와 메모리 상한은 유지합니다.
개별 원본 HTTP 오류, 업무 실패, JSON 형식 오류, fetch 네트워크 오류 및 15초 fetch 제한시간 초과는 해당 요청만 실패시킵니다. 단발성 실패에서는 사용 가능한 브라우저를 종료하거나 전체 복구 대기를 시작하지 않습니다. HTTP 403 또는 JSON 대신 검증 HTML이 3회 연속 반환되면 세션 거절로 분류해 회수하고 기존 복구 대기를 적용합니다. HTTP 오류 본문은 즉시 취소합니다. 실제 페이지 충돌·컨텍스트 소멸·18초 Node watchdog 정지는 기존 회수·복구 절차를 유지합니다. 준비 페이지는 자동 이동 스크립트가 없는 같은 원점의 `/robots.txt`입니다. 홈페이지나 `/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까지 강제 종료되거나 소유권 확인이 실패한 경우 소유 마커를 유지하여 재시작을 차단합니다. 이 경우 운영자가 잔존 프로세스를 확인해야 합니다. 소유 확인 없이 마커 파일을 지우면 안 됩니다.

Expand Down Expand Up @@ -144,3 +144,6 @@ Playwright Chromium 153.0.8010.12에서 sandbox를 켜고 검증했습니다.
최신 야간 Health Checks 네 건에서 올리브영 실패가 반복됐고, 같은 날 External Smoke는 통과했습니다. 캐시를 사용하는 smoke 통과는 캐시 우회 원본 성공의 근거가 아닙니다. 로컬 로그에는 요청 하나가 실패한 직후 세션 회수와 최대 15분 복구 대기가 반복됐습니다. 같은 회선의 격리된 새 브라우저에서는 실제 상품 API가 정상 응답했습니다.

기존 코드는 완료된 HTTP 실패와 브라우저 고장을 같은 catch에서 처리했습니다. 단일 실패가 모든 올리브영 요청의 장기 장애로 확대되는 동작은 회귀 테스트로 재현했습니다. 격리된 실제 브라우저에서도 첫 상품 조회 직후 두 번째 조회가 `Execution context was destroyed`로 실패하는 현상을 두 번 재현했습니다. 페이지의 추가 이동과 조회가 겹치는 문제도 한 번의 이동 대기·재시도로 처리합니다. 당시 운영 실패의 HTTP 상태는 기존 로그에 없어 모든 최초 실패가 같은 원인인지, 403 또는 429도 있었는지는 단정할 수 없습니다. 수정 후 안전 코드로 구분합니다. 검사 실패 기준과 캐시 우회 조건은 완화하지 않습니다.


숨겨진 운영 브라우저의 첫 요청에서 추가 시간 초과와 세션 회수가 확인됐습니다. 실제 숨김 상태에서 홈페이지를 거치지 않고 정적 원점 문서만 준비한 대조 실험은 상품·매장 API 모두 성공했습니다. 초기 앱의 자동 이동과 API 실행 컨텍스트를 분리하도록 준비 경로를 고정했습니다. 요청 실패 안전 로그는 `request-watchdog-timeout`, `request-navigation`, `request-context-closed`, `request-playwright-timeout`, `request-backoff`, `request-unexpected`를 구분하며 오류 원문은 저장하지 않습니다.
23 changes: 19 additions & 4 deletions scripts/relay/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ export async function bounded<T>(work: Promise<T>, ms: number): Promise<T> {
clearTimeout(timer!);
}
}
/** 원문·검색 조건 없이 브라우저 장애의 종류만 남깁니다. */
export function browserFailureReason(error: unknown): string {
if (!(error instanceof Error)) return 'unknown';
if (error.message === 'Browser watchdog timeout') return 'watchdog-timeout';
if (error.message === 'Browser recovery backoff') return 'backoff';
if (error.message.includes('Execution context was destroyed')) return 'navigation';
if (error.message.includes('has been closed')) return 'context-closed';
if (error.name === 'TimeoutError') return 'playwright-timeout';
return 'unexpected';
}
export function createBrowserLifecycle(
launch: () => Promise<BrowserOwner>,
onEvent?: (reason: string) => void | Promise<void>,
Expand Down Expand Up @@ -139,14 +149,17 @@ export function createBrowserLifecycle(
page.on('crash', () => retireQuietly('page-crash'));
page.on('close', () => retireQuietly('page-closed'));
await bounded(
page.goto('https://www.oliveyoung.co.kr/', {
page.goto('https://www.oliveyoung.co.kr/robots.txt', {
waitUntil: 'domcontentloaded',
timeout: 30000,
}),
32000,
);
await bounded(
page.waitForURL('**/store/**', { waitUntil: 'domcontentloaded', timeout: 30000 }),
page.waitForURL('https://www.oliveyoung.co.kr/robots.txt', {
waitUntil: 'domcontentloaded',
timeout: 30000,
}),
32000,
);
if (closed || owner !== sessionOwner) throw new Error('Browser lifecycle unavailable');
Expand All @@ -168,6 +181,7 @@ export function createBrowserLifecycle(
});
}, 30000);
} catch (error) {
emit(`startup-${browserFailureReason(error)}`);
await retire('startup-failed');
throw error;
}
Expand Down Expand Up @@ -209,12 +223,12 @@ export function createBrowserLifecycle(
!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/**', {
await page!.waitForURL('https://www.oliveyoung.co.kr/robots.txt', {
waitUntil: 'domcontentloaded',
timeout: 5000,
});
Expand Down Expand Up @@ -250,6 +264,7 @@ export function createBrowserLifecycle(
}
throw error;
}
emit(`request-${browserFailureReason(error)}`);
defer(error);
await retire('request-failed');
throw error;
Expand Down
35 changes: 33 additions & 2 deletions tests/relay/request-isolation.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EventEmitter } from 'node:events';
import { afterEach, expect, it, vi } from 'vitest';
import { createBrowserLifecycle } from '../../scripts/relay/lifecycle.js';
import { browserFailureReason, createBrowserLifecycle } from '../../scripts/relay/lifecycle.js';
function fixture(takeRetryQuota?: () => Promise<boolean>) {
const page = Object.assign(new EventEmitter(), {
goto: vi.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -158,7 +158,7 @@ it('첫 조회 뒤 페이지 이동으로 사라진 컨텍스트는 이동 완
);
try {
await expect(f.life.run('/p', {})).resolves.toMatchObject({ status: 'SUCCESS' });
expect(f.page.waitForURL).toHaveBeenLastCalledWith('https://www.oliveyoung.co.kr/store/**', {
expect(f.page.waitForURL).toHaveBeenLastCalledWith('https://www.oliveyoung.co.kr/robots.txt', {
waitUntil: 'domcontentloaded',
8000 timeout: 5000,
});
Expand Down Expand Up @@ -223,3 +223,34 @@ it('세션 교체는 이전 세션의 거절 횟수를 이어받지 않는다',
await f.life.close();
}
});
it('자동 이동하는 홈페이지 대신 부가 스크립트가 없는 같은 원점의 정적 문서를 준비한다', async () => {
const f = fixture();
f.page.goto.mockImplementation(async (url) => {
if (url === 'https://www.oliveyoung.co.kr/') throw new Error('SPA navigation');
});
try {
await expect(f.life.run('/p', {})).resolves.toMatchObject({ status: 'SUCCESS' });
expect(f.page.goto).toHaveBeenCalledWith(
'https://www.oliveyoung.co.kr/robots.txt',
expect.objectContaining({ waitUntil: 'domcontentloaded' }),
);
expect(f.page.waitForURL).toHaveBeenCalledWith(
'https://www.oliveyoung.co.kr/robots.txt',
expect.any(Object),
);
} finally {
await f.life.close();
}
});

it.each([
[undefined, 'unknown'],
[new Error('Browser watchdog timeout'), 'watchdog-timeout'],
[new Error('Browser recovery backoff'), 'backoff'],
[new Error('Execution context was destroyed'), 'navigation'],
[new Error('Target page has been closed'), 'context-closed'],
[Object.assign(new Error('private'), { name: 'TimeoutError' }), 'playwright-timeout'],
[new Error('private'), 'unexpected'],
])('소유 브라우저 오류를 원문 없이 안전한 코드로 구분한다', (error, reason) => {
expect(browserFailureReason(error)).toBe(reason);
});
Loading
0