8000 fix: 외부 세션 및 요청 제한 자동 복구 보강 by hmmhmmhm · Pull Request #318 · 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
2 changes: 1 addition & 1 deletion .github/workflows/health-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ jobs:
}

# 실패 직후 전체 검사를 반복하면 정상 경로까지 중계 한도를 다시 소비합니다.
# 검증된 분당 제한만 만료 후 해당 ID를 한 번 재확인하고 최초 실패를 보존합니다.
# 검증된 자체 분당 제한과 명시적 올리브영 원본 429만 대기 후 해당 ID를 한 번 확인합니다.
run_health_check

- name: Preserve health check attempts
Expand Down
4 changes: 2 additions & 2 deletions docs/health-check-stock-and-quota.md
Original file line number Diff line number Diff line change
Expand Up @@ -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도 계속 실패합니다.
2 changes: 2 additions & 0 deletions docs/oliveyoung-free-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -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까지 강제 종료되거나 소유권 확인이 실패한 경우 소유 마커를 유지하여 재시작을 차단합니다. 이 경우 운영자가 잔존 프로세스를 확인해야 합니다. 소유 확인 없이 마커 파일을 지우면 안 됩니다.
Expand Down
2 changes: 1 addition & 1 deletion docs/relay-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 내부·공통 릴레이 캐시를 우회í• 8000 œ다. 실패 직후 전체 검사를 반복하지 않고, 검증된 분당 중계 한도 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 재시도는 활성화하지 않는다.

Expand Down
10 changes: 10 additions & 0 deletions docs/seveneleven-web-stock-flow-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -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이 계속되거나 추가 사용자 확인이 요구되면 자동으로 그 확인을 우회하지 않으며 실패를 유지합니다.
7 changes: 4 additions & 3 deletions scripts/ops/health-check-retry.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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 };
Expand Down
9 changes: 8 additions & 1 deletion scripts/relay/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export class BrowserRequestError extends Error {
constructor(
readonly reason: string,
readonly upstreamStatus?: number,
readonly retryAfter?: number,
) {
super(`올리브영 브라우저 응답 실패: ${reason}`);
}
Expand All @@ -29,7 +30,12 @@ export function createBrowserRunner(page: Pick<Page, 'evaluate'>): 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');
Expand Down Expand Up @@ -79,6 +85,7 @@ export function createBrowserRunner(page: Pick<Page, 'evaluate'>): BrowserRunner
? 'business'
: 'invalid-json'),
result.status,
result.retryAfter,
);
return result.body;
};
Expand Down
17 changes: 17 additions & 0 deletions scripts/relay/dtryx-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -85,6 +101,7 @@ async function main() {
quota: quota.status(),
logging: convenienceLog!.status(),
authentication: session?.status(),
sevenSession: sevenSession?.status(),
});
return response;
}, convenienceLog.append);
Expand Down
5 changes: 3 additions & 2 deletions scripts/relay/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading
0