8000 fix: 무료 릴레이 요청 제한과 교체 중 만료 완화 by hmmhmmhm · Pull Request #205 · 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: 10 additions & 0 deletions docs/relay-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,13 @@ health.cache의 `hits`, `misses`, `expired`, `evicted`는 프로세스 시작
quick/full 모두 새 검사 요약을 요청한다. 실패 후 한 번 재시도할 때는 REST 캐시도 우회한다. 공통 OY 릴레이 캐시는 유지한다. HTTP 실패 후 이전 성공 파일을 재사용하지 않으며, 비정상 JSON·빈 검사 목록·모순된 요약은 실패로 처리한다. 각 요청은 최대 240초, 전체 job은 최대 10분이다. 시도별 원본 JSON과 오류 요약은 Actions artifact로 7일 보관한다.

CGV는 `CGV_UPSTREAM_UNAVAILABLE`, API HTTP 503, 원본 HTTP 403이 모두 확인될 때만 `degraded`로 분류한다. 이는 해당 기능이 제한된 상태이며 복구를 뜻하지 않는다. 인증 오류, 타임아웃, JSON 파싱 실패 등은 계속 `fail`이다. 선택 데이터가 없는 `skipped` 검사는 정상 요약에 포함될 수 있다. Actions 성공 여부와 별도로 요약의 저하 서비스 목록을 확인해야 한다. 유료 Zyte 재시도는 활성화하지 않는다.

## 1.2.8 요청 집중 및 교체 시간 개선

소비자별 신규 실행은 분당 **24회**로 완화했다. 전역 분당 30회·UTC 일일 3,000회는 그대로이며, 소비자별 동시 2개·전체 8개 슬롯과 메모리 상한도 유지한다. 단일 소비자가 지속적으로 요청하면 여전히 제한될 수 있다.

`consumer-busy`는 순간적인 동시 요청/소비자 표 포화이며 `Retry-After: 1`을 제공한다. Worker는 검증된 이 응답에만 한 번 짧게 재시도한다. `consumer`는 실제 분당 예산 소진이며 분이 바뀔 때까지 기다린다. `minute`/`daily`는 전역 제한이다. 403/5xx나 분당·일일 예산 거절은 자동으로 반복하지 않는다.

무료 릴레이 요청의 기본 제한 시간은 대기와 재시도를 합쳐 60초다. 직접 조회는 기존 15초이며 명시적인 timeout은 우선한다. 릴레이 큐는 최대 30초 대기를 허용한다. 브라우저 교체가 22~23초 걸릴 때 Worker가 먼저 연결을 끊던 문제를 줄이기 위한 조정이며, 새 브라우저나 탭을 병렬로 만들지 않는다. `queue/canceled`는 클라이언트 취소, `queue/expired`는 큐 대기 초과로 구분한다. 따라서 예전 expired 수치와 단순 비교하면 안 된다.

Zyte의 키·계정 상태와 관계없이 유료 경로는 계속 차단한다. 정상 조회는 직접 연결과 무료 릴레이로 처리하며, 릴레이 중단·전역 한도 소진·원본 차단까지 무조건 성공으로 바꾸지는 않는다.
25 changes: 25 additions & 0 deletions docs/superpowers/plans/2026-09-29-relay-burst-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Relay Burst Recovery Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task.

**Goal:** Zyte 없이 소비자 429와 브라우저 교체 중 취소를 줄인다.
**Architecture:** 기존 bounded relay의 소비자 예산·busy 사유를 조정하고 transport 한곳에서 deadline 안의 단일 busy 재시도를 처리한다. 브라우저 수와 전역 예산 유지.
**Tech Stack:** TypeScript, Vitest, Cloudflare Worker, Node24, Playwright.

## Task 1: 소비자 분류 및 대기 시간

- [x] `tests/relay/consumer.test.ts`, `fairness.test.ts`, `oliveyoung.test.ts`에서 24실행 후25번째 거절, consumer-busy 1초, 23초 queued성공/30초 만료, 취소 분리 테스트를 먼저 추가하고 실패 확인.
- [x] `scripts/relay/consumer.ts`의 예약 상한을24로, `oliveyoung.ts`의 admission 실패 응답을consumer-busy/1초로 분리. queue cutoff30000. 취소는 queue/canceled, 대기초과는queue/expired. 기존 취소 응답503과 예약해제 유지.
- [x] `scripts/relay/logging.ts` 고정 reason/outcome 목록에 새로운 분류 추가, 민감정보 미기록 테스트 유지.

## Task 2: Worker 단일 busy 재시도

- [x] `tests/utils/relayQuota.test.ts`, `tests/services/oliveyoung/cooldown.test.ts`에 consumer-busy의 scope격리, 한 번만 retry, timeout합산, timeout짧을때 미재시도, 잘못된응답미재시도, cachedbusy복구 테스트 RED.
- [x] `src/utils/relayQuota.ts`에 consumer-busy 허용 및 소비자 scope. `src/services/oliveyoung/transport.ts`에서 relay기본60000/direct15000, 호출시작 기준 remaining deadline으로 fetch timeout설정. 검증된 busy만 1000ms이하 기다려 한번재호출, 다른원인은 기존동작 유지. HttpError→ServiceError diagnostics 경로 유지.
- [x] 신규 분류의 REST/MCP 전달·Zyte 비호출·bounded cleanup 테스트. 모든 변경 파일 coverage100%.

## Task 3: 통합 검증 및 배포

- [ ] 독립 spec/code review, npm run check/build/test:coverage/audit. package 1.2.8 및 운영 문서 갱신.
- [ ] PR CI 확인 후 병합, Worker 및 맥 runtime 교체(이전 runtime rollback보존), npm tag와 publish 일치 확인.
- [ ] 적은 수의 live REST/MCP, health, 브라우저 소유 그룹, 디스크·로그 상태 확인. 기존 알려진 degraded와 실제 실패 구분. 완료 푸시 및 요약.
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# 무료 릴레이 요청 집중 및 교체 시간 개선

사용자는 Zyte를 사용할 수 없어도 계속 작동하며 사용자별 제한과 대기 만료를 줄이도록 승인했다. 구현·검증·배포까지 진행한다.

## 확인한 원인

1.2.7 이후 17:40 기준 OY 실행 221건 성공, consumer 429 137건, expired 503 3건. 503은 브라우저 교체 22~23초 동안 Worker의 15초 timeout으로 연결이 취소된 경우다. 일부는 실제 upstream 성공 후 취소로 결과를 돌려주지 못했다. 기존 consumer 사유는 동시 2개 제한과 분당 12회를 구분하지 않아 순간 충돌도 분말까지 cooldown된다.

## 선택

전역 제한을 높이거나 무제한 대기를 추가하지 않는다. 소비자 제한 완화 + 짧은 충돌 재시도 + 교체를 감당하는 제한 시간 정렬을 적용한다. 이번 개선에서는 오래된 재고를 성공으로 반환하는 새 fallback을 추가하지 않는다. 기존 캐시 정책은 유지한다.

- consumer 신규 실행 24/분. 전역 30/분·3000/UTC일, 소비자 동시 2, 전체 8슬롯, 소비자 상태 1024, 캐시 상한 유지.
- admission 진입 실패는 consumer-busy, Retry-After 1초. 실제 소비자 분당 제한은 consumer와 분말 reset 유지. 표가 가득 찬 경우도 busy로 잠시 후 재시도.
- Retry-After가 정확히 1초인 검증된 consumer-busy만 허용하며 다른 값은 cooldown에 저장하지 않는다. 검증된 consumer-busy는 해당 소비자만 cooldown, Worker는 1초 대기 후 한 번만 재시도한다. 분당/일일/consumer 제한, 403/5xx, 잘못된 헤더는 자동 재시도하지 않는다. 이전 cooldown에서 busy를 만나도 같은 전체 요청 deadline 안에서 한 번만 재시도. 명시한 timeout은 전체 시도/대기 시간을 포함한다.
- 무료 relay 기본 요청 deadline 60초, direct 기본 15초 유지. 명시적 timeout 우선. REST의 세 핸들러 및 MCP 도구의 기본값/스키마도 동일하게 맞춘다. 릴레이 큐 대기 30초까지 허용하되 전체 슬롯/예약 반환 유지. 취소와 큐 대기 만료를 각각 로그로 구분한다.
- 브라우저 안전 규칙(페이지1, 30분/200회/1GiB, 정체 확인 후 회수) 유지. 새 탭이나 병렬 브라우저를 추가하지 않는다.
- quotaReason consumer-busy를 REST/MCP·검증된 cooldown·고정 로그 분류에 포함한다. IP/본문/소비자 식별자 기록 금지.
- Zyte paid 차단 회귀를 유지. 기본 경로는 직접/무료 릴레이만 사용.

## 검증

실제 외부 조회를 대량 발생시키지 않는 가짜 시계/브라우저 테스트로 24회·25회 제한, busy 후 정상 복구, 별도 소비자 격리, 예약 반환, 23초 교체 중 취소되지 않는 timeout, 30초 큐 만료, 재시도 전체 deadline 및 횟수 상한을 검증한다. 전체 check/build/coverage100%/audit, 독립 검토, PR CI 후 Worker+맥 릴레이+패키지 배포. 실제 조회 소수와 로그/메모리/슬롯을 확인한다. 운영 감소율은 배포 후 관찰이 필요하다.
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.7",
"version": "1.2.8",
"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
2 changes: 1 addition & 1 deletion scripts/relay/consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function createConsumerQuota(now = Date.now, maxEntries = 1024) {
return {
reserve() {
resetMinute();
if (entry.count + entry.reserved >= 12) return null;
if (entry.count + entry.reserved >= 24) return null;
entry.reserved++;
let reserved = true;
return {
Expand Down
12 changes: 6 additions & 6 deletions scripts/relay/oliveyoung.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ export function createOliveyoungRelay(
const expected = Buffer.from(`Bearer ${token}`);
const cache = createResponseCache();
const consumers = createConsumerQuota();
const consumerDenied = () => Response.json({ error: 'Relay quota exceeded', reason: 'consumer' }, {
const consumerDenied = (busy = false) => Response.json({ error: 'Relay quota exceeded', reason: busy ? 'consumer-busy' : 'consumer' }, {
status: 429,
headers: { 'x-relay-quota-reason': 'consumer', 'retry-after': String(consumers.retryAfter()) },
headers: { 'x-relay-quota-reason': busy ? 'consumer-busy' : 'consumer', 'retry-after': String(busy ? 1 : consumers.retryAfter()) },
});
const ttl: Record<string, number> = {
'find-store': 300000,
Expand Down Expand Up @@ -112,7 +112,7 @@ export function createOliveyoungRelay(
const consumerHeader = request.headers.get('x-relay-consumer') || '';
const consumer = /^[a-f0-9]{64}$/.test(consumerHeader) ? consumerHeader : 'legacy';
const admission = consumers.enter(consumer);
if (!admission) return consumerDenied();
if (!admission) return consumerDenied(true);
outstanding++;
try {
let body: Record<string, unknown>;
Expand Down Expand Up @@ -189,7 +189,7 @@ export function createOliveyoungRelay(
entry!.startedAt = Date.now();
if (
[...signals].every(
([signal, arrived]) => signal.aborted || entry!.startedAt - arrived >= 15000,
([signal, arrived]) => signal.aborted || entry!.startedAt - arrived >= 30000,
)
)
return null;
Expand Down Expand Up @@ -224,8 +224,8 @@ export function createOliveyoungRelay(
}
try {
const result = await entry.task;
if (request.signal.aborted || result === null || entry.startedAt - queuedAt >= 15000) {
emit('queue', 'expired');
if (request.signal.aborted || result === null || entry.startedAt - queuedAt >= 30000) {
emit('queue', request.signal.aborted ? 'canceled' : 'expired');
return error(503, 'Request expired');
}
if (result === 'quota') {
Expand Down
15 changes: 9 additions & 6 deletions src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ export async function handleOliveyoungSearchProducts(c: ApiContext) {
const size = parseInt(c.req.query('size') || '20');
const sort = c.req.query('sort') || '01';
const includeSoldOut = c.req.query('includeSoldOut') === 'true';
const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10);
const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000;
const defaultTimeoutMs = c.env?.OY_RELAY_URL ? 60000 : 15000;
const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '', 10);
const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : defaultTimeoutMs;

if (keyword.trim().length === 0) {
return errorResponse(c, 'MISSING_QUERY', '검색어(keyword)를 입력해주세요.');
Expand Down Expand Up @@ -98,8 +99,9 @@ export async function handleOliveyoungFindStores(c: ApiContext) {
const lng = parseFloat(c.req.query('lng') || '126.978');
const pageIdx = parseInt(c.req.query('pageIdx') || '1');
const limit = parseInt(c.req.query('limit') || '20');
const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10);
const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000;
const defaultTimeoutMs = c.env?.OY_RELAY_URL ? 60000 : 15000;
const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '', 10);
const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : defaultTimeoutMs;

try {
const result = await fetchOliveyoungStores(
Expand Down Expand Up @@ -146,8 +148,9 @@ export async function handleOliveyoungCheckInventory(c: ApiContext) {
const sort = c.req.query('sort') || '01';
const includeSoldOut = c.req.query('includeSoldOut') === 'true';
const storeLimit = parseInt(c.req.query('storeLimit') || '10');
const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10);
const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000;
const defaultTimeoutMs = c.env?.OY_RELAY_URL ? 60000 : 15000;
const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '', 10);
const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : defaultTimeoutMs;
const parsedStockCheckLimit = parseInt(c.req.query('stockCheckLimit') || '5', 10);
const stockCheckLimit =
Number.isFinite(parsedStockCheckLimit) && parsedStockCheckLimit >= 0 ? parsedStockCheckLimit : 5;
Expand Down
14 changes: 7 additions & 7 deletions src/services/oliveyoung/tools/checkInventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ async function checkInventory(args: CheckInventoryArgs, transport: OliveyoungReq
includeSoldOut = false,
storeLimit = 10,
stockCheckLimit = 5,
timeoutMs = 15000,
timeoutMs = transport.timeout ?? (transport.relayUrl ? 60000 : 15000),
zyteApiKey,
} = args;

Expand All @@ -55,9 +55,9 @@ async function checkInventory(args: CheckInventoryArgs, transport: OliveyoungReq
searchWords: storeKeyword,
},
{
timeout: timeoutMs,
apiKey: zyteApiKey,
...transport,
...transport,
timeout: timeoutMs,
}
),
fetchOliveyoungProducts(
Expand All @@ -69,9 +69,9 @@ async function checkInventory(args: CheckInventoryArgs, transport: OliveyoungReq
includeSoldOut,
},
{
timeout: timeoutMs,
apiKey: zyteApiKey,
...transport,
...transport,
timeout: timeoutMs,
}
),
]);
Expand All @@ -84,9 +84,9 @@ async function checkInventory(args: CheckInventoryArgs, transport: OliveyoungReq
maxProducts: Math.min(productResult.products.length, Math.max(0, stockCheckLimit)),
},
{
timeout: timeoutMs,
apiKey: zyteApiKey,
...transport,
timeout: timeoutMs,
}
);

Expand Down Expand Up @@ -151,7 +151,7 @@ export function createCheckInventoryTool(apiKey?: string, transport: OliveyoungR
.optional()
.default(5)
.describe('주변 매장별 재고를 보강할 상품 수 (기본값: 5, 최대: 5, 낮출수록 빠름)'),
timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
timeoutMs: z.number().optional().default(transport.timeout ?? (transport.relayUrl ? 60000 : 15000)).describe('개별 조회 제한 시간(ms, 기본값: 무료 릴레이 60000 / 직접 15000)'),
},
},
handler: ((args: CheckInventoryArgs) =>
Expand Down
6 changes: 3 additions & 3 deletions src/services/oliveyoung/tools/findNearbyStores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ async function findNearbyStores(args: FindNearbyStoresArgs, transport: Oliveyoun
keyword = '',
pageIdx = 1,
limit = 20,
timeoutMs = 15000,
timeoutMs = transport.timeout ?? (transport.relayUrl ? 60000 : 15000),
zyteApiKey,
} = args;

Expand All @@ -36,9 +36,9 @@ async function findNearbyStores(args: FindNearbyStoresArgs, transport: Oliveyoun
searchWords: keyword,
},
{
timeout: timeoutMs,
apiKey: zyteApiKey,
...transport,
timeout: timeoutMs,
}
);

Expand Down Expand Up @@ -76,7 +76,7 @@ export function createFindNearbyStoresTool(apiKey?: string, transport: Oliveyoun
.describe('매장 검색어 (예: 강남, 명동, 신촌). 비우면 주변 매장 전체 조회'),
pageIdx: z.number().optional().default(1).describe('매장 결과 페이지 번호 (기본값: 1)'),
limit: z.number().optional().default(20).describe('반환할 최대 매장 수 (기본값: 20)'),
timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
timeoutMs: z.number().optional().default(transport.timeout ?? (transport.relayUrl ? 60000 : 15000)).describe('개별 조회 제한 시간(ms, 기본값: 무료 릴레이 60000 / 직접 15000)'),
},
},
handler: ((args: FindNearbyStoresArgs) =>
Expand Down
6 changes: 3 additions & 3 deletions src/services/oliveyoung/tools/searchProducts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ async function searchProducts(args: SearchProductsArgs, transport: OliveyoungReq
size = 20,
sort = '01',
includeSoldOut = false,
timeoutMs = 15000,
timeoutMs = transport.timeout ?? (transport.relayUrl ? 60000 : 15000),
zyteApiKey,
} = args;

Expand All @@ -41,9 +41,9 @@ async function searchProducts(args: SearchProductsArgs, transport: OliveyoungReq
includeSoldOut,
},
{
timeout: timeoutMs,
apiKey: zyteApiKey,
...transport,
timeout: timeoutMs,
},
);

Expand Down Expand Up @@ -84,7 +84,7 @@ export function createSearchProductsTool(apiKey?: string, transport: OliveyoungR
size: z.number().optional().default(20).describe('페이지당 상품 수 (기본값: 20)'),
sort: z.string().optional().default('01').describe('정렬 코드 (기본값: 01)'),
includeSoldOut: z.boolean().optional().default(false).describe('품절 상품 포함 여부 (기본값: false)'),
timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
timeoutMs: z.number().optional().default(transport.timeout ?? (transport.relayUrl ? 60000 : 15000)).describe('개별 조회 제한 시간(ms, 기본값: 무료 릴레이 60000 / 직접 15000)'),
},
},
handler: ((args: SearchProductsArgs) =>
Expand Down
Loading
Loading
0