8000 fix: 실제 원본 점검과 서비스 장애 복구 보강 by hmmhmmhm · Pull Request #254 · 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
11 changes: 11 additions & 0 deletions docs/gs25-auth-recovery-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ JWT의 `exp`만 로컬에서 검사하고 토큰 자체·URL query·JWT 전체 c

구현: [gs25-session.ts](../scripts/relay/gs25-session.ts), [dtryx-start.ts](../scripts/relay/dtryx-start.ts), [convenience.ts](../scripts/relay/convenience.ts). 세션이 설정되면 `GS25_API_KEY`로 자동 폴백하지 않는다. 키만 바꾸는 것은 이번 복구 방법이 아니다.

### 만료된 access와 살아 있는 refresh를 구분한다

운영 프로세스가 403을 유지하더라도 파일의 refresh가 서버에서 철회됐다고 단정하지 않는다. 전송기는 갱신 401/403 또는 재시도 인증 거절을 메모리에 유지하므로, 파일의 토큰과 프로세스가 보관한 토큰이 다르거나 이전 거절이 있었다면 재기동 전까지 실패할 수 있다. 만료 시각이 미래인 refresh도 서버 유효성의 증거는 아니다.

1. 두 서비스가 유휴 상태인지 확인하고 4절에 따라 기존 프로세스를 정상 종료한다. 비공개 작업 디렉터리에 현재 0600 세션의 복사본을 만든다.
2. 복사본을 `createGs25SessionTransport`에 연결해 고정 재고 URL을 한 번 호출한다. access가 만료됐다면 기존 전송기가 정상 `tokenReissue`를 수행하고 회전된 두 토큰을 복사본에 저장한다. JWT 만료를 수정하거나 옛 refresh로 수동 갱신을 반복하지 않는다.
3. 갱신 HTTP 상태·`resultCode`·재고 HTTP 상태만 기록한다. 실패 응답을 조사할 때도 content-type과 challenge 여부처럼 비밀이 없는 메타데이터만 기록한다. 전체 응답에는 회원정보가 포함될 수 있다. 갱신 401/403이 재현되면 정상 재로그인 절차로 진행한다.
4. 갱신과 실제 재고 조회가 성공하면 **회전된 복사본**을 4절의 원자 교체 절차로 설치하고 중계를 재기동한다. 옛 세션 복사본을 다시 설치하면 회전된 refresh를 잃을 수 있다.

2026-10-11 KST 복구에서는 운영 재고 403, 만료된 access, 아직 만료되지 않은 refresh를 관측했다. 비공개 복사본을 사용한 정상 갱신은 HTTP 200/`0000`, 갱신 후 재고는 HTTP 200과 양수 재고를 반환했다. 갱신된 파일은 0600으로 저장했다. 이 증거는 파일 세션으로 갱신 가능함을 확인하지만, 이전 운영 프로세스의 403이 옛 메모리 토큰 때문인지 일시적인 원본 거절 때문인지는 확정하지 않는다. 로그인이나 토큰 계약을 바꾸지 않았고 인증 실패를 품절로 변환하지 않았다.

## 3. 만료·철회 시 정상 네이버 로그인으로 GS 세션을 다시 발급한다

사용자의 로그인·추가 인증은 사용자가 직접 완료한다. 새 티켓을 발급하고 격리된 headed 브라우저에서 정상 흐름을 진행한다. 비밀번호·네이버 쿠키·개인정보를 저장소나 도구 출력에 남기지 않는다.
Expand Down
8 changes: 7 additions & 1 deletion docs/health-check-stock-and-quota.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@

확인된 수량 0은 품절입니다. 미확인 값·음수·상품 목록만 있는 응답은 정상 재고 응답과 구분합니다. 재고 증거가 없는 결과는 `fail`로 보고하며, 원본 응답과 진단은 점검 기록에 남깁니다.

## 캐시와 실제 원본 점검

`fresh=true`와 `x-health-check-force-fresh: true`를 함께 사용한 인증된 운영 점검은 결과 요약뿐 아니라 서비스 내부·중계 캐시도 우회합니다. 캐시된 성공 응답 뒤에 원본이 실패하면 점검도 실패합니다. 공개 요청에 플래그만 붙여서는 캐시를 우회할 수 없습니다.

일반 사용자 요청의 캐시와 동일 요청 병합은 유지합니다. 점검 요청도 기존 동시성·대기·시간·호출량 상한을 적용받습니다. `ready` 중계 상태는 프로세스 실행 준비를 뜻하며, 실제 상품·매장·재고 점검 성공과 구분합니다.

## 429의 출처

CU·GS25·세븐일레븐 Mac 중계는 같은 원장을 공유하며 분당 690회, UTC 하루 10,000,000회로 제한합니다. Cloudflare 직접 경로의 호출과 별개입니다. 분당 상한에 도달해도 일일 상한에 도달했다는 의미는 아닙니다.
Expand All @@ -17,7 +23,7 @@ CU·GS25·세븐일레븐 Mac 중계는 같은 원장을 공유하며 분당 690

헬스체크 결과에도 `errorCode`, `upstreamStatus`, `quotaReason`, `retryAfter`를 보존합니다. Actions는 실패 직후 전체 점검을 다시 실행하지 않습니다. 정상 경로까지 반복 호출하며 중계 한도를 소비하던 동작을 제거했습니다. 첫 실패 기록을 보존하고 다음 점검에서 재확인합니다.

CGV·디트릭스 중계는 기존 디트릭스 원장을 공유합니다. 편의점 원장과 분리되어 있으며 이번 복구로 한도를 늘리거나 원장을 초기화하지 않습니다.
CGV·디트릭스 중계는 같은 원장을 공유하며 분당 300회, UTC 하루 300,000회로 제한합니다. 편의점 원장과 분리되어 있으며 원장을 초기화하지 않습니다.

편의점 중계가 자체 분·일 한도로 거부하면 HTTP 429에 `x-relay-quota-reason: minute|daily`와 `Retry-After`를 반환합니다. Worker는 이 값을 공개 진단과 재시도 대기 시간에 반영합니다. 외부 서비스가 반환한 429에는 자체 한도 사유를 붙이지 않습니다.

Expand Down
10 changes: 10 additions & 0 deletions docs/oliveyoung-http-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,13 @@ HTTP 조회가 차단되면 원인을 확인하고 `OY_RELAY_TRANSPORT=browser`
같은 맥에서 쿠키 없는 Python urllib는 네 API의 HTTP200/SUCCESS를 반환했고 Node fetch·Node https·기본 curl은 403을 받았습니다. Python도 필수 헤더를 제거하면403이었습니다. JSON 공백/문자 인코딩 변경은 원인이 아니었으며 정확한 차단 기준을 TLS만으로 확정하지 않습니다.

브라우저 중계와 동일 조건에서 상품 코드·상세 식별자·매장 코드·실제 재고 필드를 대조했습니다. 명동2가점 핸드크림 일반9/오늘드림7, 선크림 일반54/오늘드림43 및 픽업/취급 상태가 일치했습니다. 이 수량은 확인 당시 관측값이며 이후 변할 수 있습니다. HTTP 성공 여부는 제공자 정책·네트워크·클라이언트 버전에 따라 달라질 수 있으므로 장기간 무차단을 보장하지 않습니다.

## 2026-10-11 장애 확인

운영 health의 `ready`는 Python 실행 가능 여부만 확인합니다. 같은 고정 헤더와 공식 `product-search-v3` 요청이 HTTP403/HTML을 반환하면 상품·재고 조회는 중계502가 됩니다. 이번 확인에서는 설치된 Python 헬퍼의 요청도403을 받았으며, 설정·JSON 스키마 오류를 나타내는 근거는 없었습니다. 로컬에 기록된 네 공식 경로 이외의 대체 공개 API는 확인하지 못했습니다. 차단 기준이나 변경 시점은 이 응답만으로 확정할 수 없습니다.

HTTP 모드의 health에 `lastFailure: { status, at }`가 추가됩니다. `status`는 헬퍼가 받은 원래 HTTP 상태 숫자이며, 네트워크·프로세스·직렬화 실패 등 상태를 알 수 없는 경우 `null`입니다. `at`는 실패를 관측한 UTC 시각입니다. 다음 요청을 시작하거나 성공하면 이전 실패 정보는 지워집니다. 상위 응답 본문·URL·쿠키·토큰은 포함하지 않습니다. 외부 오류 응답과 요청 허용 경로,15/18초 제한,2MiB 상한은 유지합니다.

기존 소유 브라우저를 임시 독립 세션으로 실행한 정상 페이지 이동 후 네 공식 조회(매장9개·상품 검색·상품 식별자·매장 재고)는 모두 `SUCCESS`를 반환했고 감시 프로세스의 종료도 확인했습니다. HTTP 실패를 헤더 변형·쿠키 복사·보안 검증 자동화로 회피하지 않습니다. browser 모드 복구는 명시적 설정 변경으로만 수행하며, 브라우저 없는 실행이 필요하면 현재 HTTP403 차단이 해소되거나 제공자가 지원하는 다른 접근 경로가 필요합니다. 이 검증 자체는 운영 설정이나 원장을 수정하지 않았습니다.

인증된 운영 점검의 `x-health-check-force-fresh: true`는 상품·매장 fallback과 상품 식별자 캐시를 우회하고, 재고 보강 오류를 그대로 보고합니다. 릴레이에 `x-relay-cache-bypass: true`를 전달하여 저장 응답과 진행 중인 동일 요청도 재사용하지 않습니다. 이때도 인증·호출 한도·동시 작업 상한을 적용합니다. 인증되지 않은 공개 요청은 이 우회를 사용할 수 없습니다.
20 changes: 20 additions & 0 deletions docs/service-freshness-checks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# 운영 헬스 체크의 캐시 우회

`GET /api/health/checks?fresh=true`를 새 원본 점검으로 실행하려면 운영 시크릿 인증과 `x-health-check-force-fresh: true`를 함께 보냅니다. Better Stack 자동 모니터 요청에는 기존 결과 재사용 정책이 적용됩니다. 인증된 새 점검은 요약 캐시와 하위 REST 응답 캐시를 우회하고, 신뢰된 원점에만 운영 인증 및 새 점검 헤더를 전달합니다. 인증되지 않은 공개 요청의 헤더로 내부 캐시를 우회할 수 없습니다.

GS25·CU·세븐일레븐·CGV는 인증된 새 점검에서 중계에 `x-relay-cache-bypass: true`를 전달합니다. 중계는 Bearer 토큰을 먼저 검증하며, 이 요청은 저장 응답이나 기존 진행 작업을 사용하지 않고 기존 동시 실행 상한·대기 기한·호출 예산을 그대로 적용받습니다. 새 점검 결과는 일반 요청 캐시에 저장하지 않습니다. 세븐일레븐 직접 상품·매장·인기 검색어 캐시와 GS25 직접 매장 캐시도 우회합니다. 디트릭스 중계에는 응답 캐시가 없습니다.

상품 검색 성공은 재고 확인 성공을 의미하지 않습니다. 세븐일레븐 재고 점검은 매장별 실제 확인 여부와 수량을 따로 검증합니다. 원본 오류가 발생한 상태에서는 과거 상품 검색 성공을 사용해 점검을 정상으로 판정하지 않습니다.

## 메가박스 지점 조회

근처 지점의 정확한 거리순 정렬에는 해당 지역의 모든 지점 좌표가 필요합니다. 반환 개수가 1개여도 좌표 조회 전에 후보를 자르면 실제 최단 거리 지점을 놓칠 수 있습니다.

일반 조회는 공식 지점 페이지에서 확인한 주소·좌표를 최대 256개, 1시간 보관합니다. 동일 지점의 진행 중 조회를 공유하고 반환 객체를 분리합니다. 오류 및 좌표 미확인 결과는 저장하지 않습니다. 상영 목록·회차·잔여 좌석은 이 캐시에 저장하지 않고 매번 원본을 조회합니다. 인증된 새 운영 점검은 정적 지점 캐시와 진행 작업도 우회합니다. 따라서 새 원본 점검의 지연은 그대로 관찰되며, 정상 판정 기준을 높여 감추지 않습니다.

## 회귀 검증

- 원본 성공으로 캐시를 채운 뒤 원본이 403을 반환하면 인증된 새 세븐일레븐 점검은 정상으로 판정하지 않습니다.
- 따뜻한 `caches.default`가 있어도 인증된 운영 요청은 GS25·CU·세븐일레븐·CGV 응답 캐시를 우회합니다.
- 토큰이 틀린 요청은 캐시 우회나 중계 예산 사용 권한을 얻지 못합니다.
- 메가박스 지점 캐시는 만료·용량·동시 요청·객체 변경·오류·좌표 누락·새 점검 우회를 검증합니다.
13 changes: 12 additions & 1 deletion scripts/relay/http-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function createHttpLifecycle() {
let starting: Promise<void> | undefined;
let active = false;
let calls = 0;
let lastFailure: { status: number | null; at: string } | null = null;
const execute = (check: boolean, input?: string) => {
pending = new Promise<string>((resolve, reject) => {
child = execFile(
Expand All @@ -27,7 +28,13 @@ export function createHttpLifecycle() {
maxBuffer: LIMIT,
encoding: 'utf8',
},
(error, stdout) => {
(error, stdout, stderr) => {
if (error && !check && !isClosed()) {
const match = /^HTTP relay request failed upstream_status=([1-5][0-9]{2})\n?$/.exec(
stderr || '',
);
lastFailure = { status: match ? Number(match[1]) : null, at: new Date().toISOString() };
}
child = undefined;
if (error || isClosed()) reject(new Error('HTTP relay request failed'));
else resolve(stdout);
Expand Down Expand Up @@ -59,6 +66,7 @@ export function createHttpLifecycle() {
if (state !== 'ready') throw new Error('HTTP lifecycle not ready');
if (active) throw new Error('HTTP lifecycle already active');
active = true;
lastFailure = null;
calls++;
try {
const input = JSON.stringify({ path, body });
Expand All @@ -72,8 +80,10 @@ export function createHttpLifecycle() {
Array.isArray(result.data)
)
throw new Error();
lastFailure = null;
return result;
} catch {
lastFailure ??= { status: null, at: new Date().toISOString() };
throw new Error('HTTP relay request failed');
} finally {
active = false;
Expand All @@ -90,6 +100,7 @@ export function createHttpLifecycle() {
status: () => ({
state,
transport: 'http',
lastFailure,
browserRequired: false,
pages: 0,
active,
Expand Down
62 changes: 44 additions & 18 deletions scripts/relay/http-relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ export interface HttpRelayOptions {
signal: AbortSignal,
fetcher: typeof fetch,
) => Promise<unknown>;
takeQuota: (() => Promise<boolean>) & { status?: () => Pick<QuotaStatus, 'blockedBy' | 'retryAfter'> };
takeQuota: (() => Promise<boolean>) & {
status?: () => Pick<QuotaStatus, 'blockedBy' | 'retryAfter'>;
};
fetcher?: typeof fetch;
onEvent?: (event: {
stage: string;
Expand All @@ -36,8 +38,13 @@ export class RelayError extends Error {
upstreamStatus?: number,
) {
super('Relay failure');
if (typeof upstreamStatus === 'number' && Number.isInteger(upstreamStatus) &&
upstreamStatus >= 100 && upstreamStatus <= 599) this.upstreamStatus = upstreamStatus;
if (
typeof upstreamStatus === 'number' &&
Number.isInteger(upstreamStatus) &&
upstreamStatus >= 100 &&
upstreamStatus <= 599
)
this.upstreamStatus = upstreamStatus;
}
}
const reply = (status: number, body: unknown, headers: Record<string, string> = {}) =>
Expand Down Expand Up @@ -122,7 +129,13 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) {
queue.push(start);
});
}
function startWork(key: string, name: string, params: unknown, deadline: number): SharedWork {
function startWork(
key: string,
name: string,
params: unknown,
deadline: number,
bypassCache = false,
): SharedWork {
const controller = new AbortController();
const work: SharedWork = {
controller,
Expand Down Expand Up @@ -162,8 +175,13 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) {
const snapshot = options.takeQuota.status?.();
const reason = snapshot?.blockedBy;
const retry = snapshot?.retryAfter;
if ((reason === 'minute' || reason === 'daily') && typeof retry === 'number' &&
Number.isInteger(retry) && retry >= 1 && retry <= 86400) {
if (
(reason === 'minute' || reason === 'daily') &&
typeof retry === 'number' &&
Number.isInteger(retry) &&
retry >= 1 &&
retry <= 86400
) {
quota = { quotaReason: reason, retryAfter: retry };
}
} catch {
Expand All @@ -180,7 +198,7 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) {
controller.signal.throwIfAborted();
const body = JSON.stringify(data);
if (body === undefined) throw new RelayError(502);
if (cache) cache.set(key, body, options.cacheTtl!(name));
if (cache && !bypassCache) cache.set(key, body, options.cacheTtl!(name));
return body;
} catch (error) {
if (error instanceof RelayError) work.upstreamStatus = error.upstreamStatus;
Expand All @@ -197,7 +215,7 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) {
}
}
})();
if (cache) pending.set(key, work);
if (cache && !bypassCache) pending.set(key, work);
return work;
}
return async (request: Request): Promise<Response> => {
Expand Down Expand Up @@ -237,7 +255,8 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) {
try {
const p = options.validate(await readJson(request.body, 16384, controller.signal, 400), name);
const key = canonicalKey(name, p as Record<string, unknown>);
const cached = cache?.get(key);
const bypassCache = request.headers.get('x-relay-cache-bypass') === 'true';
const cached = bypassCache ? undefined : cache?.get(key);
if (cached !== undefined) {
controller.signal.throwIfAborted();
stage = 'cache';
Expand All @@ -247,9 +266,9 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) {
}
controller.signal.throwIfAborted();
workKey = key;
work = cache ? pending.get(key) : undefined;
work = cache && !bypassCache ? pending.get(key) : undefined;
const shared = work !== undefined;
work ??= startWork(key, name, p, deadline);
work ??= startWork(key, name, p, deadline, bypassCache);
work.waiters++;
stage = shared ? 'coalesced' : 'upstream';
const body = await abortable(work.result, controller.signal);
Expand All @@ -265,13 +284,20 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) {
? error.status
: 502;
resultStatus = status;
return reply(error instanceof RelayError && error.upstreamError ? 200 : status, {
...(error instanceof RelayError && error.upstreamError ? { success: false } : {}),
error: 'Relay request failed',
upstreamError: error instanceof RelayError ? error.upstreamError : undefined,
}, status === 429 && work?.stage === 'quota' && error instanceof RelayError && error.quota
? { 'x-relay-quota-reason': error.quota.quotaReason, 'Retry-After': String(error.quota.retryAfter) }
: {});
return reply(
error instanceof RelayError && error.upstreamError ? 200 : status,
{
...(error instanceof RelayError && error.upstreamError ? { success: false } : {}),
error: 'Relay request failed',
upstreamError: error instanceof RelayError ? error.upstreamError : undefined,
},
status === 429 && work?.stage === 'quota' && error instanceof RelayError && error.quota
? {
'x-relay-quota-reason': error.quota.quotaReason,
'Retry-After': String(error.quota.retryAfter),
}
: {},
);
} finally {
try {
void Promise.resolve(
Expand Down
Loading
Loading
0