8000 fix: 올리브영 요청 실패의 전체 중계 장애 전파 방지 by hmmhmmhm · Pull Request #317 · 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
10 changes: 9 additions & 1 deletion docs/oliveyoung-free-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -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까지 강제 종료되거나 소유권 확인이 실패한 경우 소유 마커를 유지하여 재시작을 차단합니다. 이 경우 운영자가 잔존 프로세스를 확인해야 합니다. 소유 확인 없이 마커 파일을 지우면 안 됩니다.

Expand Down Expand Up @@ -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도 있었는지는 단정할 수 없습니다. 수정 후 안전 코드로 구분합니다. 검사 실패 기준과 캐시 우회 조건은 완화하지 않습니다.
104 changes: 70 additions & 34 deletions scripts/relay/browser.ts
Original file line number Diff line number Diff line change
@@ -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<Page, 'evaluate'>): 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;
};
}
48 changes: 45 additions & 3 deletions scripts/relay/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -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<Browser, 'newContext'>;
Expand All @@ -25,6 +25,7 @@ export async function bounded<T>(work: Promise<T>, ms: number): Promise<T> {
export function createBrowserLifecycle(
launch: () => Promise<BrowserOwner>,
onEvent?: (reason: string) => void | Promise<void>,
takeRetryQuota?: () => Promise<boolean>,
) {
const emit = (reason: string) => {
try {
Expand All @@ -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';
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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++;
Expand All @@ -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 8 retire(& 10BC8 #39;rotation');
}
throw error;
}
defer(error);
await retire('request-failed');
throw error;
Expand Down
48 changes: 41 additions & 7 deletions scripts/relay/oliveyoung.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, number> = {
'find-store': 300000,
'product-search-v3': 300000,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions scripts/relay/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
2 changes: 2 additions & 0 deletions scripts/relay/transport-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export async function createTransportLifecycle(
mode: string | undefined,
stateDir: string,
onEvent?: (reason: string) => void | Promise<void>,
takeRetryQuota?: () => Promise<boolean>,
) {
if (mode === 'http') {
const { createHttpLifecycle } = await import('./http-lifecycle.js');
Expand All @@ -16,5 +17,6 @@ export async function createTransportLifecycle(
return createBrowserLifecycle(
() => launchGuardedBrowser(join(stateDir, 'browser-owner.json'), onEvent),
onEvent,
takeRetryQuota,
);
}
Loading
Loading
0