8000 fix: 올리브영 상시 복구와 외부 제한 대응 by hmmhmmhm · Pull Request #312 · 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: 5 additions & 0 deletions docs/dtryx-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,8 @@ node --max-old-space-size=128 --env-file=/absolute/path/dtryx.env \
먼저 로컬 인증 health와 세 API를 확인한 뒤 Access를 통한 같은 요청, 운영 REST/MCP를 확인한다. 올리브영의 health·실제 조회·탭 수를 함께 확인한다. 강제 종료 후 LaunchAgent 재시작 및 사용량 원장 보존을 확인한다.

중계를 중단할 때는 Worker의 디트릭스 설정을 제거하면 직접 요청으로 돌아가지만, 원본 연결 장애가 재현될 수 있다. 토큰을 Worker/GitHub/로컬에서 함께 교체한다. 올리브영 토큰이나 상태 파일을 덮어쓰지 않는다.


## 일본 상품 상세의 외부 호출 제한

일본 공식 상품 JSON이429를 반환하면 같은 Mac 중계의 모든 일본 상품 상세 요청이 대기 시간을 공유합니다. Retry-After를 읽고, 안내가 없으면60초 대기하며 최소1초·최대24시간으로 제한합니다. 인증된 캐시 우회도 원본 제한 대기는 우회하지 않습니다. 기존 성공 응답 캐시는 기존 정책대로 유지합니다. 이 대기는 정상 품절이나 자체 일일/분당 한도를 뜻하지 않습니다. 실패는502로 유지하며 x-relay-upstream-status:429를 Worker 공개 진단의 upstreamStatus에 전달합니다. 이 처리는 원본429를 없애거나 검사를 성공으로 바꾸지 않고 중복 원본 호출을 줄이는 조치입니다.
13 changes: 13 additions & 0 deletions docs/oliveyoung-http-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,16 @@ HTTP 모드의 health에 `lastFailure: { status, at }`가 추가됩니다. `stat
이 확인은 기존 종료 기한 안에서 한 번만 공유합니다. 확인이 끝나면 다음 요청에서 새 브라우저를 준비할 수 있지만, 처음 실패한 원본 요청을 성공으로 바꾸거나 자동으로 무한 재시도하지 않습니다. 인증 자료와 누적 호출 원장은 수정하지 않으며, 원본의 간헐적 차단·지연을 해소한다는 의미도 아닙니다.

이미 실패 상태로 남은 구버전 프로세스는 먼저 유휴 상태를 확인해 정상 종료합니다. 남은 마커의 감시자 PID·브라우저 PID·원래 그룹·전용 crashpad 프로세스가 모두 없는지 확인한 뒤 마커를 보존 이동하고 LaunchAgent를 다시 시작합니다. 소유권이 불명확하면 다른 브라우저를 종료하거나 마커를 삭제하지 않습니다. 복구 후 캐시 우회 실제 조회와 CLI/MCP, 정기 경로 스냅샷을 확인합니다.


## 2026-10-25 반복 종료 장애와 상시 복구

일반 HTTP 중계는 현재도 공식 상품 검색에서 HTTP403을 반환했습니다. 별도 격리 Chrome의 headless 실행도 공식 메인 페이지와 상품 API에서403이 확인됐습니다. 창 없는 실행이나 HTTP 전환이 검증되기 전에는 정상인 browser 모드를 유지합니다. Chrome 창을 닫으면 해당 세션의 조회에 영향을 줄 수 있습니다. 개인 Chrome 프로필이나 쿠키는 사용하지 않습니다.

브라우저 교체 시 종료 확인이 제한 시간을 넘기면 원래 요청은 실패로 남깁니다. 이후 감시자의 정상 종료가 확인됐거나, 숫자형 비정상 종료 후 기존 PID·그룹·전용 crashpad 및 마커의 소멸/동일성 검증을 다시 통과한 경우에만 다음 작업에서 세션을 재생성합니다. 시그널 종료·살아 있는 감시자·불확실한 소유권은 계속 차단합니다. 원래 종료 기한을 늘리거나 재고0으로 대체하지 않습니다.

Mac 중계는 기존60초 heartbeat에서 로컬 수명 상태를 확인합니다. failed/idle이고 작업 중이 아닐 때만 준비를 시도하며 동시 시도를 공유합니다. 실패하면1분,2분,4분,8분,최대15분 간격으로 대기합니다. 이 감시는 상품·재고 API를 호출하지 않으며, Worker의5분 경로 선택 및 GitHub 원본 조회 검사를 대체하지 않습니다. 실제 복구 뒤 운영 fresh 조회와 CLI/MCP 검증은 별도로 필요합니다. 로그는 recovery-started/recovery-ready/recovery-failed/cleanup-recovered 같은 고정된 사유만 남기고 인증/오류 원문을 저장하지 않습니다.

일반 사용자 요청과 자동 준비도 같은 수명 계층의 실패 대기(retryAt)를 적용합니다. 요청을 반복해도 대기를 연장하거나 우회하지 않습니다. 부모 프로세스가 재시작되어 메모리 기록이 없는 경우에는 private 마커의 정수형PID·부모PID·그룹·생성시각·전용crashpad 형식과 파일 소유자·inode/device를 확인하고, 현재 관련 프로세스가 모두 없을 때만 원본 기록을 보존 이동합니다. 시작 중 기록처럼 브라우저 정체가 없는 마커는 계속 차단합니다. 이 복구에서는 프로세스를 추정해 종료하지 않습니다.

동시 재시작·종료 복구는 마커 경로의 SHA-256으로 정한 loopback TCP 포트(49152~65535)의 OS lease로 직렬화합니다. 시작 검증부터 감시자의 새 마커 획득까지와 후속 소멸 재검증/보존 이동에 같은 lease를 사용합니다. 임시 리스너는 외부 인터페이스에 노출하지 않고 연결을 즉시 닫습니다. 충돌·사용 중이면 소유권을 추정하지 않고 실패로 유지합니다. lease는 작업 완료 또는 부모 프로세스 종료 때 OS가 해제하므로 별도 stale lock 파일은 남기지 않습니다.
68 changes: 49 additions & 19 deletions scripts/relay/daiso-jp.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,52 @@
/** 고정 일본 다이소 상품 JSON만 제공하며 임의 URL을 받지 않습니다. */
import { parseProductDetail } from '../../src/services/daisoJp/parser.js';
import { createHttpRelay, RelayError, abortable, readJson, type HttpRelayOptions } from './http-relay.js';
export function createJapanProductRelay(token: string, options: Pick<HttpRelayOptions, 'takeQuota' | 'fetcher' | 'onEvent'>) {
return createHttpRelay(token, {
...options, prefix: '/v1/dtryx/', operations: ['daiso-jp-product'], cacheTtl: () => 1800000,
validate(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new RelayError(400);
const p = value as Record<string, unknown>;
if (Object.keys(p).length !== 1 || typeof p.id !== 'string' || !/^\d{13}$/.test(p.id)) throw new RelayError(400);
return { id: p.id };
},
async upstream(_operation, value, signal, fetcher) {
const { id } = value as { id: string };
const response = await abortable(fetcher(new URL(`https://jp.daisonet.com/products/${id}.js`), { redirect: 'manual', signal }), signal);
if (response.status !== 200) { void response.body?.cancel().catch(() => undefined); throw new RelayError(502, undefined, undefined, response.status); }
const data = await readJson(response.body, 2 * 1024 * 1024, signal, 502);
parseProductDetail(data, id);
return data;
},
});
import { parseRetryAfterDelayMs } from '../../src/utils/http.js';
import {
createHttpRelay,
RelayError,
abortable,
readJson,
type HttpRelayOptions,
} from './http-relay.js';
export function createJapanProductRelay(
token: string,
options: Pick<HttpRelayOptions, 'takeQuota' | 'fetcher' | 'onEvent'>,
) {
let blockedUntil = 0;
return createHttpRelay(token, {
...options,
prefix: '/v1/dtryx/',
operations: ['daiso-jp-product'],
cacheTtl: () => 1800000,
validate(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new RelayError(400);
const p = value as Record<string, unknown>;
if (Object.keys(p).length !== 1 || typeof p.id !== 'string' || !/^\d{13}$/.test(p.id))
throw new RelayError(400);
return { id: p.id };
},
async upstream(_operation, value, signal, fetcher) {
// 원본 제한은 품절이나 자체 할당량으로 바꾸지 않고 모든 상품에 대기를 공유합니다.
if (Date.now() < blockedUntil) throw new RelayError(502, undefined, undefined, 429);
const { id } = value as { id: string };
const response = await abortable(
fetcher(new URL(`https://jp.daisonet.com/products/${id}.js`), {
redirect: 'manual',
signal,
}),
signal,
);
if (response.status === 429)
blockedUntil =
Date.now() +
Math.max(1000, Math.min(parseRetryAfterDelayMs(response) ?? 60000, 86400000));
if (response.status !== 200) {
void response.body?.cancel().catch(() => undefined);
throw new RelayError(502, undefined, undefined, response.status);
}
const data = await readJson(response.body, 2 * 1024 * 1024, signal, 502);
parseProductDetail(data, id);
return data;
},
});
}
46 changes: 42 additions & 4 deletions scripts/relay/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface BrowserOwner {
browser: Pick<Browser, 'newContext'>;
pid: number;
close(deadline?: number): Promise<void>;
recover?(deadline: number): Promise<boolean>;
rss(): Promise<number>;
}
export async function bounded<T>(work: Promise<T>, ms: number): Promise<T> {
Expand Down Expand Up @@ -39,6 +40,7 @@ export function createBrowserLifecycle(
let closed = false;
let failed: unknown;
let cleanup: Promise<void> = Promise.resolve();
let retired: BrowserOwner | undefined;
let born = 0;
let calls = 0;
let totalCalls = 0;
Expand All @@ -48,6 +50,16 @@ export function createBrowserLifecycle(
let timer: ReturnType<typeof setInterval> | undefined;
let initializing: Promise<void> | undefined;
let replacementPending = false;
let failures = 0;
let retryAt = 0;
let lastFailure: unknown;
const backoffError = new Error('Browser recovery backoff');
const defer = (error: unknown) => {
if (closed || error === backoffError || error === lastFailure) return;
lastFailure = error;
retryAt = Date.now() + Math.min(900000, 60000 * 2 ** Math.min(failures++, 4));
emit('recovery-backoff');
};
const retire = (why: string) => {
reason = why;
emit(why);
Expand All @@ -64,6 +76,7 @@ export function createBrowserLifecycle(
const deadline = Date.now() + 10000;
cleanup = bounded(previous.close(deadline), Math.max(0, deadline - Date.now())).catch(
(error) => {
retired = previous;
failed = error;
throw error;
},
Expand All @@ -76,7 +89,22 @@ export function createBrowserLifecycle(
void retire(why).catch(() => undefined);
};
const ensureSession = async () => {
await cleanup;
if (closed) throw new Error('Browser lifecycle unavailable');
if (!owner && Date.now() < retryAt) throw backoffError;
// 같은 실패의 ensure→run 전파만 공유하고 새 복구 시도는 별도로 계산합니다.
lastFailure = undefined;
try {
await cleanup;
} catch (error) {
if (closed || !retired?.recover) throw error;
const deadline = Date.now() + 5000;
if (!(await bounded(retired.recover(deadline), Math.max(0, deadline - Date.now()))))
throw error;
retired = undefined;
failed = undefined;
cleanup = Promise.resolve();
emit('cleanup-recovered');
}
if (closed) throw new Error('Browser lifecycle unavailable');
if (owner && Date.now() - born >= 30 * 60 * 1000) {
await retire('rotation');
Expand Down Expand Up @@ -142,9 +170,14 @@ export function createBrowserLifecycle(
}
};
const ensure = () => {
initializing ??= ensureSession().finally(() => {
initializing = undefined;
});
initializing ??= ensureSession()
.catch((error) => {
defer(error);
throw error;
})
.finally(() => {
initializing = undefined;
});
return initializing 10000 ;
};
// 교체당 한 번만 미리 준비하고 실패 시 다음 명시적 요청을 기다립니다.
Expand All @@ -163,6 +196,9 @@ export function createBrowserLifecycle(
try {
await ensure();
const result = await bounded(createBrowserRunner(page!)(path, body), 18000);
failures = 0;
retryAt = 0;
lastFailure = undefined;
calls++;
totalCalls++;
if (calls >= 200 || Date.now() - born >= 30 * 60 * 1000) {
Expand All @@ -172,6 +208,7 @@ export function createBrowserLifecycle(
}
return result;
} catch (error) {
defer(error);
await retire('request-failed');
throw error;
} finally {
Expand Down Expand Up @@ -205,6 +242,7 @@ export function createBrowserLifecycle(
rssBytes,
pages: context?.pages().length || 0,
reason,
retryAt,
}),
};
}
23 changes: 23 additions & 0 deletions scripts/relay/marker-lease.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/** 파일 잠금 대신 OS가 종료 시 해제하는 로컬 포트로 마커 변경을 직렬화합니다. */
import { createHash } from 'node:crypto';
import { createServer } from 'node:net';
import { realpath } from 'node:fs/promises';
import { dirname, basename, join } from 'node:path';
export function markerLeasePort(markerPath: string): number {
return 49152 + (createHash('sha256').update(markerPath).digest().readUInt16BE(0) % 16384);
}
export async function acquireMarkerLease(
markerPath: string,
): Promise<(() => Promise<void>) & { markerPath: string }> {
markerPath = join(await realpath(dirname(markerPath)), basename(markerPath));
const server = createServer((socket) => socket.destroy());
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(markerLeasePort(markerPath), '127.0.0.1', resolve);
});
let closing: Promise<void> | undefined;
return Object.assign(
() => (closing ??= new Promise<void>((resolve) => server.close(() => resolve()))),
{ markerPath },
);
}
51 changes: 51 additions & 0 deletions scripts/relay/recover-abandoned-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/** 재시작 전 마커도 파일 정체와 소유 프로세스의 완전 소멸을 확인합니다. */
import { constants } from 'node:fs';
import { open } from 'node:fs/promises';
import { bounded } from './lifecycle.js';
import { processSnapshot, type ProcessIdentity } from './ownership.js';
< 10BC8 /td> import { recoverExitedGuard } from './recover-exited-guard.js';
export async function recoverAbandonedGuard(
markerPath: string,
deadline: number,
snapshot = processSnapshot,
): Promise<boolean> {
const remaining = () => Math.max(0, deadline - Date.now());
try {
const handle = await open(
markerPath,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
try {
const stat = await bounded(handle.stat(), remaining());
if (
!stat.isFile() ||
stat.size > 16384 ||
stat.uid !== process.getuid!() ||
stat.mode & 0o077
)
return false;
const marker = JSON.parse(await bounded(handle.readFile('utf8'), remaining()));
const root = marker?.browser as ProcessIdentity | undefined;
if (
!root ||
!Number.isSafeInteger(marker.guardPid) ||
marker.guardPid <= 1 ||
!Number.isSafeInteger(root.pid) ||
root.pid <= 1 ||
root.pgid !== root.pid ||
root.ppid !== marker.guardPid ||
typeof root.birth !== 'string' ||
!root.birth ||
typeof root.crashDir !== 'string' ||
!root.crashDir.startsWith(markerPath + '.') ||
!root.crashDir.endsWith('.crashpad')
)
return false;
return await recoverExitedGuard(markerPath, marker.guardPid, root, deadline, snapshot, stat);
} finally {
await handle.close();
}
} catch {
return false;
}
}
5 changes: 4 additions & 1 deletion scripts/relay/recover-exited-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export async function recoverExitedGuard(
root: ProcessIdentity,
deadline: number,
snapshot = processSnapshot,
expected?: { dev: number; ino: number },
): Promise<boolean> {
const remaining = () => Math.max(0, deadline - Date.now());
try {
Expand All @@ -20,6 +21,7 @@ export async function recoverExitedGuard(
);
try {
const stat = await bounded(handle.stat(), remaining());
if (expected && (expected.dev !== stat.dev || expected.ino !== stat.ino)) return false;
if (!stat.isFile() || stat.size > 16384 || stat.uid !== process.getuid!()) return false;
const marker = JSON.parse(await bounded(handle.readFile('utf8'), remaining()));
if (marker?.guardPid !== guardPid) return false;
Expand All @@ -31,7 +33,8 @@ export async function recoverExitedGuard(
const current = await bounded(lstat(markerPath), remaining());
if (current.dev !== stat.dev || current.ino !== stat.ino) return false;
if (!remaining()) return false;
await bounded(rename(markerPath, `${markerPath}.exited-${randomUUID()}`), remaining());
// 완료 전 잠금을 해제하지 않도록 커밋 중인 파일 이동 자체는 기다립니다.
await rename(markerPath, `${markerPath}.exited-${randomUUID()}`);
return true;
} finally {
await handle.close();
Expand Down
14 changes: 9 additions & 5 deletions scripts/relay/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { pathToFileURL } from 'node:url';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { createTransportLifecycle } from './transport-lifecycle.js';
import { createRelayWatchdog } from './watchdog.js';
import { createFileQuota } from './quota.js';
import { createOliveyoungRelay } from './oliveyoung.js';

Expand All @@ -25,11 +26,6 @@ async function main() {
log.append({ stage: 'process', outcome: 'starting' });
const takeQuota = await createFileQuota(join(stateDir, 'quota.json'));
log.append({ stage: 'quota', outcome: 'snapshot', ...takeQuota.status() });
const heartbeat = setInterval(
() => log.append({ stage: 'process', outcome: 'heartbeat', ...takeQuota.status() }),
60000,
);
heartbeat.unref();
const lifecycle = await createTransportLifecycle(
process.env.OY_RELAY_TRANSPORT,
stateDir,
Expand All @@ -40,6 +36,14 @@ async function main() {
outcome,
}),
);
const recover = createRelayWatchdog(lifecycle, (outcome) =>
log.append({ stage: 'lifecycle', outcome }),
);
const heartbeat = setInterval(() => {
log.append({ stage: 'process', outcome: 'heartbeat', ...takeQuota.status() });
void recover();
}, 60000);
heartbeat.unref();
try {
await lifecycle.start();
const handler = observeRelay(
Expand Down
Loading
Loading
0