8000 feat: 정기 점검으로 직접 조회와 중계 경로 선택 by hmmhmmhm · Pull Request #235 · 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
44 changes: 44 additions & 0 deletions docs/ADAPTIVE-ROUTING.md
10000
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 직접 호출과 중계 경로 자동 선택

Cloudflare Worker가 5분마다 조회 종류별 직접 API와 맥 중계 상태를 확인합니다. 사용자 조회는 이 결과로 경로를 고릅니다. 매 조회 전에 연결 시험을 하거나 상태 저장소 응답을 기다리지 않습니다.

## 경로를 고르는 기준

| 상황 | 조회 경로 |
| --- | --- |
| 최근 점검에서 직접 API가 정상 | 직접 호출 |
| 직접 API가 차단되거나 응답 형식이 비정상 | 기존 맥 중계 |
| 서버에 상태가 없거나 30초 상태 캐시가 만료 | 중계로 응답하면서 상태만 백그라운드 갱신 |
| 마지막 점검이 10분을 초과함 | 중계 |
| 직접 조회 중 401·403·429·5xx, 연결·응답 오류 발생 | 직접 경로를 5분 차단하고 남은 시간 안에서 중계로 복구 |
| 직접과 중계가 모두 최근 점검에서 실패 | 연결 장애 503 반환 |
| GS25 재고 조회 | 정상 로그인 세션을 사용하는 기존 맥 중계 유지 |

상품 검색과 재고 조회의 판정은 따로 저장합니다. CU 3종, GS25 상품 검색, 세븐일레븐 8종, 올리브영 4종, 디트릭스 3종이 직접 점검 대상입니다. 다이소 등 원래 직접 호출하던 서비스에는 이 중계 선택을 추가하지 않습니다.

올리브영 중계는 브라우저 없이 동작하는 기존 HTTP 방식입니다. 이 기능은 중계 프로그램을 재시작하거나 로그인 세션을 옮기지 않습니다. Google 지오코딩과 Zyte도 다시 활성화하지 않습니다.

## 정기 점검의 비용과 제한

`wrangler.toml`의 Cron `*/5 * * * *`가 실행합니다. 직접 요청은 고정 대표 상품·매장으로 최대 3초, 본문 2 MiB, 재시도 없이 수행합니다. 최대 네 연결만 동시에 사용합니다. 실제 조회와 점검은 같은 URL·헤더·요청 구성·응답 검증을 사용합니다. HTTP 200만 보고 성공으로 판단하지 않습니다.

하루 상한은 직접 API 5,472회(19종 × 288회)와 중계 헬스 864회(3곳 × 288회)입니다. 중계 헬스는 상품·재고 요청을 하지 않으므로 중계 원장의 일일 한도를 소비하지 않습니다. 사용자 요청에 적용되는 기존 인증과 한도는 그대로 유지합니다.

## 저장과 오류 복구

공유 상태는 `UPSTREAM_ROUTE_HEALTH` 바인딩의 `UpstreamRouteHealth` Durable Object에 저장합니다. 처음 배포할 때 SQLite migration `v2`가 추가됩니다. 개별 Worker의 캐시는 판정의 짧은 사본이며, 공유 저장소를 대신하지 않습니다.

실제 직접 조회 실패는 해당 Worker에서 즉시 차단하고 공유 저장소에 비동기로 기록합니다. 늦게 도착한 점검 성공이나 실패 기록이 더 최근 실패를 지우지 않도록 순서와 차단 시간을 검증합니다. 기록 전송이 한 번 실패하더라도 차단 시간이 지난 뒤 새 점검이 성공하면 직접 경로로 복귀할 수 있습니다.

400·404·422와 사용자 취소는 연결 장애로 취급하지 않습니다. 중계 429도 중계 서버 장애로 취급하지 않습니다. 공식 API에는 중계 Bearer 토큰이나 Cloudflare Access 키를 보내지 않습니다.

## 운영 확인

`HEALTH_CHECK_SECRET`과 기존 운영 헬스 인증 방식을 사용합니다. 시크릿을 명령 인수·로그·저장소에 넣지 마세요.

- `GET /api/health/routes`: 저장된 조회별 판정과 점검 시각을 확인합니다. 직접 API를 시험하지 않습니다.
- `POST /api/health/routes`: 고정된 전체 점검을 실행하고 결과를 반환합니다. 임의 URL이나 상품 입력은 받지 않습니다.

인증 헤더는 `Authorization: Bearer <운영 시크릿>` 또는 `x-health-check-key`입니다. 새 배포 직후에는 POST로 판정을 채우고, 이후 GET의 `checkedAt`이 Cron 실행으로 갱신되는지 확인합니다. 공유 저장소 바인딩이 없는 기존 설치·로컬 사용은 기존 중계 우선 동작을 유지합니다.

장애 확인 시 직접 점검의 `reason`, 점검 시각, `failedAt`과 `blockedUntil`, 중계 그룹의 `healthy`를 먼저 확인합니다. 원문 응답·쿠키·인증 코드는 저장하지 않으며 고정된 진단 코드만 남깁니다.
22 changes: 22 additions & 0 deletions docs/superpowers/plans/2026-10-06-adaptive-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# 주기적 경로 선택 구현 계획

**Goal:** 사용자 조회의 시험 호출 지연을 제거하고 검증된 직접 경로만 사용한다.
**Architecture:** Cron probes → DurableObject snapshot → request-local cached decision → direct 또는 relay. 실패는 cooldown과 단일 fallback.
**Tech Stack:** Workers scheduled/waitUntil, SQLite DO, AsyncLocalStorage, 기존fetchJson/ServiceError/Zod/Vitest.

- [ ] src/utils/routeHealth.ts 및 src/durableObjects/upstreamRouteHealth.ts를 테스트먼저 구현. cold/stale/namespace격리/nohotIO/후속실패/오래된probe/취소/시간예산/400보존. 계약은 아래와 같다.
- [ ] src/utils/directRoutes.ts, directRouteSpecs*.ts 및 src/utils/routeProbes.ts를 테스트먼저 구현. 고정20operation URL/method/payload/스키마/headers; GSstockpinned;19direct+3health 최악제한. 실제Workerprobe증거저장.
- [ ] 기존공통transport3곳에 routeOperation(key,timeout,direct,relay,signal?) 연결하고 모든클라이언트branch가 이 경로를 사용하는지 확인. 새binding없으면기존relay순서 유지.
- [ ] index.ts worker fetch context와scheduled, wrangler v2DO/Cron5분, AppBindings와인증된health route GET/POST 연결. index450줄 유지.
- [ ] npm run check; npm run test:coverage; npm run build; npm audit 및 독립요구사항/품질리뷰. 기능·schema실패가재고0으로반환되지않는회귀포함.
- [ ] PR/CI확인후병합·배포. 기존Macruntime·인증·원장은변경없음. 운영수동warm/probe와실제scheduled관측, REST/MCP/게시CLI 및state cache지연검증.
- [ ] 변경된Worker/CLIpackage를patch릴리스하고공식tarballgitHead/무결성검증. 운영기록/설계제약·실측·복구절차와완료알림.

## 병렬 모듈 계약

routeHealth.ts exports ROUTE_KEYS(oy-find-store,oy-product-search,oy-goods-info,oy-stock-stores,cu-prime,cu-stock,cu-store,gs25-products,gs25-stock,seven-goods,seven-store,seven-popwords,seven-stock-meta,seven-stock,seven-pages,seven-issues,seven-exhibitions,dtryx-movies,dtryx-play-dates,dtryx-timetable), RouteKey, RelayGroup=oliveyoung/convenience/dtryx.
RouteCheck={key,direct:boolean,checkedAt:number,reason?:string}; RelayCheck={group,healthy:boolean,checkedAt,reason?}. RouteSnapshot={routes:Partial<Record<RouteKey,RouteCheck&{failedAt?,blockedUntil?}>>,relays:Partial<Record<RelayGroup,RelayCheck>>}.
withRouteRouting<T>(namespace:DurableObjectNamespace|undefined,waitUntil:((p:Promise<unknown>)=>void)|undefined,work:()=>T):T.
routeOperation<T>(key:RouteKey,budgetMs:number,direct:(ms:number)=>Promise<T>,relay:(ms:number)=>Promise<T>,signal?:AbortSignal):Promise<T>.
readRouteSnapshot(namespace):Promise<RouteSnapshot>; writeRouteChecks(namespace,checks:RouteCheck[],relays:RelayCheck[]):Promise<void>.
DO internal GET/snapshot; POST/checks {checks,relays}; POST/failure {key,at}. metadata-only,no tokens. Coreagent owns core/DO+theirtests; probeagent owns specs/directRoutes/routeProbes+theirtests; root owns existingtransports/index/bindings/wrangler/healthroute/docs/tests integration. No agent deploys/commits/restarts.
15 changes: 15 additions & 0 deletions docs/superpowers/specs/2026-10-06-adaptive-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# 조회 경로의 주기적 판정

사용자가 직접 실패 시에만 중계를 쓰는 구조와 시간 단위 별도 점검을 승인했다. 매 사용자 요청에서 상태를 시험하지 않고, 저장된 판정으로 즉시 경로를 고른다.

Cloudflare scheduled는5분마다20개 조회 종류의 직접 API와3개 중계health를 점검한다. GS25 stock은 맥의 정상 로그인 세션을 사용하므로 직접 후보에서 제외하고 중계 유지. 점검은 사용자 조회/캐시를 통해 실행하지 않는다. 직접 점검은 같은 URL·헤더·본문·검증으로 수행하고 상품과 상세재고를 별도 판정한다. 고정 대표 상품/매장만 사용하며 최대3초·2MiB·재시도0, 병렬4, 모든 필드 오류는 실패로 본다. HTTP200의 빈 오류 HTML이나 비정상JSON을 성공/재고0으로 취급하지 않는다. 점검의 상한은19직접*288/일과3health*288/일이며 중계health는원장을 차감하지 않는다.

UpstreamRouteHealth SQLite DurableObject가 공유 판정을 영속화한다. 새 binding UPSTREAM_ROUTE_HEALTH와 migration v2. 상태는 key별 direct/checkedAt/failedAt/blockedUntil, 중계그룹별healthy/checkedAt로 구성한다. 실패의5분 cooldown은 나중에 도착한 오래된 probe 성공이 해제하지 못하게 한다. 스냅샷10분 초과는 직접 성공 근거로 사용하지 않는다.

Worker request context는 AsyncLocalStorage로 분리. isolate에는 namespace별 짧은 immutable snapshot cache만 둔다. 상태가없음/30초지난경우 사용자요청은 기다리지 않고 relay를 선택하며 waitUntil에서 DO snapshot만 갱신한다. 실시간 upstream health probe는 scheduled/admin만 수행한다. namespace가없는 기존 설치/로컬은 기존 relay 우선 유지. 새context없는 transport도 기존동작.

알려진 직접 경로를 선택한 뒤 예상치 못한403/401/429/5xx·network·shape 실패 시 직접을 즉시5분 차단하고 원래 총시간예산 안에서 중계로1회 복구한다. 정상적인400/404/422나 사용자취소는 경로장애로 판단하지 않는다. 동일 실패는비동기로DO에 전달한다. 직접/중계 모두 최근점검에서 불가하면503으로명확히실패. quota429는 중계가 죽었다는 의미로 사용하지 않는다.

공통 transport단계에서만 routing을 연결해REST/MCP/CLI·비교조회에동일하게적용한다. 편의점 requestConvenienceRelay, Dtryx requestDtryxRelay, OY requestOliveyoung의 relaybranch를 감싼다. 직접요청은공통requestDirectRoute를 사용하며 relay헤더/토큰은공식API에전달하지않는다. 기존 인증·quota·캐시와 브라우저없는OY중계 유지, Google/Zyte 재활성 없음.

API /api/health/routes GET은 기존운영secret으로snapshot조회, POST는명시적수동점검. frontend의 상품조회흐름에는운영진단정보를추가하지않는다. Cron5분 설정/실제Cloudflare직접probe·운영다른서비스·fallback주입·동시coldstart·stale·crossisolate race를검증하고PR/CI/병합/배포/CLI릴리스까지완료한다.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "daiso",
"version": "1.2.15",
"version": "1.2.16",
"description": "Korean retail and cinema MCP server, CLI, and Codex Skill for Daiso, convenience stores, marts, Olive Young, and theaters",
"main": "dist/index.js",
"bin": {
Expand Down
1 change: 1 addition & 0 deletions src/api/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ServiceError, toServiceErrorDiagnostics, toStandardErrorDiagnostics } f

export interface AppBindings {
DAILY_RATE_LIMITER?: DurableObjectNamespace;
UPSTREAM_ROUTE_HEALTH?: DurableObjectNamespace;
ZYTE_API_KEY?: string;
DTRYX_RELAY_URL?: string;
DTRYX_RELAY_TOKEN?: string;
Expand Down
45 changes: 45 additions & 0 deletions src/api/routes/routeHealthRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/** 인증된 운영자만 사전 경로 점검 상태를 읽거나 갱신할 수 있습니다. */
import type { Hono } from 'hono';
import { errorResponse, type AppBindings } from '../response.js';
import { authorizeOperationalRequest } from '../operationalAuth.js';
import { readRouteSnapshot } from '../../utils/routeHealth.js';
import { refreshRouteChecks } from '../../utils/routeProbes.js';

export function registerRouteHealthRoutes(app: Hono<{ Bindings: AppBindings }>): void {
app.on(['GET', 'POST'], '/api/health/routes', async (c) => {
const authorization = await authorizeOperationalRequest(
c.req.raw.headers,
c.env?.HEALTH_CHECK_SECRET,
);
if (authorization === 'not-configured') {
return errorResponse(
c,
'HEALTH_CHECK_SECRET_NOT_CONFIGURED',
'HEALTH_CHECK_SECRET이 설정되어 있지 않습니다.',
503,
);
}
if (authorization !== 'authorized') {
return errorResponse(
c,
'UNAUTHORIZED_HEALTH_CHECK',
'유효한 헬스 체크 시크릿 키가 필요합니다.',
401,
);
}
const namespace = c.env.UPSTREAM_ROUTE_HEALTH;
if (!namespace)
return errorResponse(
c,
'ROUTE_HEALTH_NOT_CONFIGURED',
'경로 점검 저장소가 설정되어 있지 않습니다.',
503,
);
try {
if (c.req.method === 'POST') await refreshRouteChecks(c.env);
return c.json({ success: true, data: await readRouteSnapshot(namespace) });
} catch {
return errorResponse(c, 'ROUTE_HEALTH_FAILED', '경로 점검 상태를 확인하지 못했습니다.', 503);
}
});
}
92 changes: 92 additions & 0 deletions src/durableObjects/upstreamRouteHealth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/** 내부 바인딩을 통해서만 접근하는 경로별 상태 저장소입니다. */
import {
ROUTE_COOLDOWN_MS,
isRouteKey,
validRelayCheck,
validRouteCheck,
validTime,
type RelayCheck,
type RouteCheck,
type RouteSnapshot,
} from '../utils/routeHealth.js';
const STORAGE_KEY = 'snapshot';
const empty = (): RouteSnapshot => ({ routes: {}, relays: {} });
export class UpstreamRouteHealth {
constructor(private readonly state: DurableObjectState) {}
async fetch(request: Request): Promise<Response> {
const path = new URL(request.url).pathname;
if (request.method === 'GET' && path === '/snapshot')
return Response.json((await this.state.storage.get<RouteSnapshot>(STORAGE_KEY)) || empty());
if (request.method !== 'POST' || !['/checks', '/failure'].includes(path))
return new Response(null, { status: 404 });
let input: Record<string, unknown>;
try {
const text = await request.text();
if (text.length > 16384) return new Response(null, { status: 400 });
const value: unknown = JSON.parse(text);
if (!value || typeof value !== 'object' || Array.isArray(value))
return new Response(null, { status: 400 });
input = value as Record<string, unknown>;
} catch {
return new Response(null, { status: 400 });
}
if (path === '/checks') {
if (
!Array.isArray(input.checks) ||
input.checks.length > 20 ||
!input.checks.every(validRouteCheck) ||
!Array.isArray(input.relays) ||
input.relays.length > 3 ||
!input.relays.every(validRelayCheck)
)
return new Response(null, { status: 400 });
await this.state.storage.transaction(async (storage) => {
const snapshot = (await storage.get<RouteSnapshot>(STORAGE_KEY)) || empty();
for (const row of input.checks as RouteCheck[]) {
const old = snapshot.routes[row.key];
if (old && (row.checkedAt < old.checkedAt || row.checkedAt <= (old.failedAt ?? -1)))
continue;
const blocked = (old?.blockedUntil || 0) > Date.now();
snapshot.routes[row.key] = {
key: row.key,
direct: row.key !== 'gs25-stock' && !blocked && row.direct,
checkedAt: row.checkedAt,
...(row.reason === undefined ? {} : { reason: row.reason }),
...(old?.failedAt === undefined ? {} : { failedAt: old.failedAt }),
...(old?.blockedUntil === undefined ? {} : { blockedUntil: old.blockedUntil }),
};
}
for (const row of input.relays as RelayCheck[]) {
if (row.checkedAt < (snapshot.relays[row.group]?.checkedAt || 0)) continue;
snapshot.relays[row.group] = {
group: row.group,
healthy: row.healthy,
checkedAt: row.checkedAt,
...(row.reason === undefined ? {} : { reason: row.reason }),
};
}
await storage.put(STORAGE_KEY, snapshot);
});
} else {
if (!isRouteKey(input.key) || !validTime(input.at))
return new Response(null, { status: 400 });
const key = input.key;
const at = input.at;
await this.state.storage.transaction(async (storage) => {
const snapshot = (await storage.get<RouteSnapshot>(STORAGE_KEY)) || empty();
const old = snapshot.routes[key];
if (!old || (at >= (old.failedAt ?? 0) && old.checkedAt < at + ROUTE_COOLDOWN_MS))
snapshot.routes[key] = {
key,
direct: false,
checkedAt: Math.max(old?.checkedAt ?? at, at),
reason: 'unavailable',
failedAt: at,
blockedUntil: at + ROUTE_COOLDOWN_MS,
};
await storage.put(STORAGE_KEY, snapshot);
});
}
return new Response(null, { status: 204 });
}
}
11 changes: 10 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { withRouteRouting } from './utils/routeHealth.js';
import { refreshRouteChecks } from './utils/routeProbes.js';
import { registerRouteHealthRoutes } from './api/routes/routeHealthRoutes.js';
export { UpstreamRouteHealth } from './durableObjects/upstreamRouteHealth.js';
import { convenienceTransportFromBindings } from './utils/convenienceTransport.js';
import { handleLotteMartFindStores } from './api/lottemartHandlers.js';
import { geocodeBindings } from './utils/geocode.js';
Expand Down Expand Up @@ -347,6 +351,7 @@ app.on(['POST', 'DELETE'], '/', handleRootMcpRequest);
// 헬스 체크 엔드포인트
app.get('/health', (c) => c.json({ status: 'ok', config: buildConfigStatus(c.env) }));
registerHealthRoutes(app);
registerRouteHealthRoutes(app);
registerRateLimitStatsRoutes(app);
registerServiceRoutes(app);

Expand Down Expand Up @@ -423,7 +428,11 @@ const worker = {
return createRootInfoResponse(request.method);
}

return app.fetch(request, env, executionCtx);
return withRouteRouting(env?.UPSTREAM_ROUTE_HEALTH, executionCtx?.waitUntil?.bind(executionCtx),
() => app.fetch(request, env, executionCtx));
},
scheduled(_controller: ScheduledController, env: AppBindings, ctx: ExecutionContext) {
ctx.waitUntil(refreshRouteChecks(env));
},
request: app.request.bind(app),
};
Expand Down
21 changes: 21 additions & 0 deletions src/services/dtryx/transport.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { requestDirectRoute } from '../../utils/directRoutes.js';
import { routeOperation } from '../../utils/routeHealth.js';
import { diagnosticHeaders } from '../../utils/diagnostics.js';
/** 디트릭스 전용 중계 설정과 인증 전송입니다. */
import { ServiceError } from '../../core/errors.js';
Expand Down Expand Up @@ -52,6 +54,15 @@ export async function requestDtryxRelay<T>(
options: DtryxTransportOptions,
timeout: number,
): Promise<T> {
validateRelay(options);
return routeOperation(
`dtryx-${path}`,
timeout,
(ms) => requestDirectRoute<T>(`dtryx-${path}`, body, ms),
(ms) => requestDtryxRelayRaw<T>(path, body, options, ms),
);
}
function validateRelay(options: DtryxTransportOptions): void {
const { relayUrl, relayToken, accessClientId, accessClientSecret } = options;
const accessPresent = accessClientId !== undefined || accessClientSecret !== undefined;
if (
Expand All @@ -66,6 +77,16 @@ export async function requestDtryxRelay<T>(
false,
);
}
}

async function requestDtryxRelayRaw<T>(
path: 'movies' | 'play-dates' | 'timetable',
body: { brandCode: string; cinemaCode: string; playDate?: string },
options: DtryxTransportOptions,
timeout: number,
): Promise<T> {
const { relayUrl, relayToken, accessClientId, accessClientSecret } = options;
const accessPresent = accessClientId !== undefined || accessClientSecret !== undefined;
const headers: Record<string, string> = {
...diagnosticHeaders(),
'Content-Type': 'application/json',
Expand Down
Loading
Loading
0