From 4e078c3c10d00fa1317fe57fce8aba353922938f Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Tue, 29 Sep 2026 17:53:35 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=AC=B4=EB=A3=8C=20=EB=A6=B4=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=20=EC=9A=94=EC=B2=AD=20=EC=A0=9C=ED=95=9C=EA=B3=BC=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4=20=EC=A4=91=20=EB=A7=8C=EB=A3=8C=20=EC=99=84?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/relay-observability.md | 10 ++ .../plans/2026-09-29-relay-burst-recovery.md | 25 +++ .../2026-09-29-relay-burst-recovery-design.md | 23 +++ package-lock.json | 4 +- package.json | 2 +- scripts/relay/consumer.ts | 2 +- scripts/relay/oliveyoung.ts | 12 +- src/api/handlers.ts | 15 +- .../oliveyoung/tools/checkInventory.ts | 14 +- .../oliveyoung/tools/findNearbyStores.ts | 6 +- .../oliveyoung/tools/searchProducts.ts | 6 +- src/services/oliveyoung/transport.ts | 79 +++++---- src/utils/relayQuota.ts | 7 +- tests/api/oliveyoung-timeout-defaults.test.ts | 82 ++++++++++ tests/api/relay-quota.test.ts | 10 +- .../core/registry-mcp-error-contract.test.ts | 17 ++ tests/relay/consumer.test.ts | 6 +- tests/relay/fairness.test.ts | 27 ++-- tests/relay/logging.test.ts | 10 ++ tests/relay/oliveyoung.test.ts | 31 +++- tests/services/oliveyoung/burst.test.ts | 151 ++++++++++++++++++ tests/services/oliveyoung/relayErrors.test.ts | 4 +- tests/utils/relayQuota.test.ts | 8 + 23 files changed, 462 insertions(+), 89 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-29-relay-burst-recovery.md create mode 100644 docs/superpowers/specs/2026-09-29-relay-burst-recovery-design.md create mode 100644 tests/api/oliveyoung-timeout-defaults.test.ts create mode 100644 tests/services/oliveyoung/burst.test.ts diff --git a/docs/relay-observability.md b/docs/relay-observability.md index ac3c53ec..a48d4af1 100644 --- a/docs/relay-observability.md +++ b/docs/relay-observability.md @@ -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의 키·계정 상태와 관계없이 유료 경로는 계속 차단한다. 정상 조회는 직접 연결과 무료 릴레이로 처리하며, 릴레이 중단·전역 한도 소진·원본 차단까지 무조건 성공으로 바꾸지는 않는다. diff --git a/docs/superpowers/plans/2026-09-29-relay-burst-recovery.md b/docs/superpowers/plans/2026-09-29-relay-burst-recovery.md new file mode 100644 index 00000000..fdefdedb --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-relay-burst-recovery.md @@ -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와 실제 실패 구분. 완료 푸시 및 요약. diff --git a/docs/superpowers/specs/2026-09-29-relay-burst-recovery-design.md b/docs/superpowers/specs/2026-09-29-relay-burst-recovery-design.md new file mode 100644 index 00000000..7aaff1b3 --- /dev/null +++ b/docs/superpowers/specs/2026-09-29-relay-burst-recovery-design.md @@ -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+맥 릴레이+패키지 배포. 실제 조회 소수와 로그/메모리/슬롯을 확인한다. 운영 감소율은 배포 후 관찰이 필요하다. diff --git a/package-lock.json b/package-lock.json index 56fa7431..f59f39cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daiso", - "version": "1.2.7", + "version": "1.2.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daiso", - "version": "1.2.7", + "version": "1.2.8", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index 31cf1d60..77efe45f 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/relay/consumer.ts b/scripts/relay/consumer.ts index c93c2454..bc0a65a0 100644 --- a/scripts/relay/consumer.ts +++ b/scripts/relay/consumer.ts @@ -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 { diff --git a/scripts/relay/oliveyoung.ts b/scripts/relay/oliveyoung.ts index 3aa01bcd..58d7b6cb 100644 --- a/scripts/relay/oliveyoung.ts +++ b/scripts/relay/oliveyoung.ts @@ -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 = { 'find-store': 300000, @@ -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; @@ -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; @@ -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') { diff --git a/src/api/handlers.ts b/src/api/handlers.ts index e28e414b..74e94648 100644 --- a/src/api/handlers.ts +++ b/src/api/handlers.ts @@ -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)를 입력해주세요.'); @@ -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( @@ -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; diff --git a/src/services/oliveyoung/tools/checkInventory.ts b/src/services/oliveyoung/tools/checkInventory.ts index 5e6a009e..f0597dde 100644 --- a/src/services/oliveyoung/tools/checkInventory.ts +++ b/src/services/oliveyoung/tools/checkInventory.ts @@ -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; @@ -55,9 +55,9 @@ async function checkInventory(args: CheckInventoryArgs, transport: OliveyoungReq searchWords: storeKeyword, }, { - timeout: timeoutMs, apiKey: zyteApiKey, - ...transport, + ...transport, + timeout: timeoutMs, } ), fetchOliveyoungProducts( @@ -69,9 +69,9 @@ async function checkInventory(args: CheckInventoryArgs, transport: OliveyoungReq includeSoldOut, }, { - timeout: timeoutMs, apiKey: zyteApiKey, - ...transport, + ...transport, + timeout: timeoutMs, } ), ]); @@ -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, } ); @@ -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) => diff --git a/src/services/oliveyoung/tools/findNearbyStores.ts b/src/services/oliveyoung/tools/findNearbyStores.ts index d5aee7e4..8cc5ef92 100644 --- a/src/services/oliveyoung/tools/findNearbyStores.ts +++ b/src/services/oliveyoung/tools/findNearbyStores.ts @@ -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; @@ -36,9 +36,9 @@ async function findNearbyStores(args: FindNearbyStoresArgs, transport: Oliveyoun searchWords: keyword, }, { - timeout: timeoutMs, apiKey: zyteApiKey, ...transport, + timeout: timeoutMs, } ); @@ -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) => diff --git a/src/services/oliveyoung/tools/searchProducts.ts b/src/services/oliveyoung/tools/searchProducts.ts index 54fc2b8f..4179372f 100644 --- a/src/services/oliveyoung/tools/searchProducts.ts +++ b/src/services/oliveyoung/tools/searchProducts.ts @@ -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; @@ -41,9 +41,9 @@ async function searchProducts(args: SearchProductsArgs, transport: OliveyoungReq includeSoldOut, }, { - timeout: timeoutMs, apiKey: zyteApiKey, ...transport, + timeout: timeoutMs, }, ); @@ -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) => diff --git a/src/services/oliveyoung/transport.ts b/src/services/oliveyoung/transport.ts index 83dd3b4c..f8849eb2 100644 --- a/src/services/oliveyoung/transport.ts +++ b/src/services/oliveyoung/transport.ts @@ -42,7 +42,8 @@ export async function requestOliveyoung( body: Record, options: OliveyoungRequestOptions = {}, ): Promise { - const { timeout = 15000, relayUrl, relayToken } = options; + const { relayUrl, relayToken, timeout = relayUrl ? 60000 : 15000 } = options; + const deadline = Date.now() + timeout; const headers: Record = { 'Content-Type': 'application/json', Accept: 'application/json', @@ -79,39 +80,53 @@ export async function requestOliveyoung( } if (relayUrl) { scope = await relayCredentialScope([relayUrl.replace(/\/$/, ''), relayToken, options.accessClientId, options.accessClientSecret]); - const quota = cooldown.get(scope, consumer); - if (quota) throw toOliveyoungRelayError(new HttpError(429, '', '', new Headers({ - 'x-relay-quota-reason': quota.quotaReason, 'retry-after': String(quota.retryAfter), - }))); } - let result: OliveyoungApiResponse; - try { - result = await fetchJson(url, { - method: 'POST', - // Workers는 error 모드를 지원하지 않으므로 따라가지 않고 아래 200 검사로 거절합니다. - redirect: 'manual', - headers, - body: JSON.stringify(body), - timeout, - retries: 0, - expectedStatus: 200, - }); - } catch (error) { - if (relayUrl) { - if (error instanceof HttpError && error.quota) cooldown.set(scope, consumer, error.quota); - throw toOliveyoungRelayError(error); + let retried = false; + while (true) { + let result: OliveyoungApiResponse; + const cachedQuota = relayUrl ? cooldown.get(scope, consumer) : undefined; + try { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new DOMException('Request deadline exceeded', 'AbortError'); + if (cachedQuota) throw new HttpError(429, '', '', new Headers({ + 'x-relay-quota-reason': cachedQuota.quotaReason, + 'retry-after': String(cachedQuota.retryAfter), + })); + result = await fetchJson(url, { + method: 'POST', + // Workers는 error 모드를 지원하지 않으므로 따라가지 않고 아래 200 검사로 거절합니다. + redirect: 'manual', + headers, + body: JSON.stringify(body), + timeout: remaining, + retries: 0, + expectedStatus: 200, + }); + } catch (error) { + if (relayUrl) { + if (error instanceof HttpError && error.quota) { + if (!cachedQuota) cooldown.set(scope, consumer, error.quota); + if (!retried && error.quota.quotaReason === 'consumer-busy' && + deadline - Date.now() > 1000) { + retried = true; + await new Promise(resolve => setTimeout(resolve, 1000)); + continue; + } + } + throw toOliveyoungRelayError(error); + } + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('올리브영 API 요청 시간 초과'); + } + throw new Error( + '올리브영 직접 요청 실패. 운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN으로 브라우저 릴레이를 설정해주세요.', + { cause: error }, + ); } - if (error instanceof Error && error.name === 'AbortError') { - throw new Error('올리브영 API 요청 시간 초과'); + if (result?.status !== 'SUCCESS') { + if (relayUrl) throw toOliveyoungRelayError(new SyntaxError()); + throw new Error(`올리브영 API 상태 오류: ${result?.status || 'UNKNOWN'}`); } - throw new Error( - '올리브영 직접 요청 실패. 운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN으로 브라우저 릴레이를 설정해주세요.', - { cause: error }, - ); - } - if (result?.status !== 'SUCCESS') { - if (relayUrl) throw toOliveyoungRelayError(new SyntaxError()); - throw new Error(`올리브영 API 상태 오류: ${result?.status || 'UNKNOWN'}`); + return result; } - return result; } diff --git a/src/utils/relayQuota.ts b/src/utils/relayQuota.ts index 563f810c..493492b1 100644 --- a/src/utils/relayQuota.ts +++ b/src/utils/relayQuota.ts @@ -1,13 +1,14 @@ /** 인증 릴레이의 제한 헤더만 허용하는 유한한 재시도 계약. */ export interface RelayQuota { - quotaReason: 'minute' | 'daily' | 'consumer'; + quotaReason: 'minute' | 'daily' | 'consumer' | 'consumer-busy'; retryAfter: number; } export function parseRelayQuota(status: number, headers?: Headers): RelayQuota | undefined { if (status !== 429 || !headers) return undefined; const reason = headers.get('x-relay-quota-reason'); const retry = headers.get('retry-after') || ''; - if (reason !== 'minute' && reason !== 'daily' && reason !== 'consumer') return undefined; + if (reason !== 'minute' && reason !== 'daily' && reason !== 'consumer' && reason !== 'consumer-busy') return undefined; + if (reason === 'consumer-busy' && retry !== '1') return undefined; if (!/^[1-9][0-9]{0,4}$/.test(retry) || Number(retry) > 86400) return undefined; return { quotaReason: reason, retryAfter: Number(retry) }; } @@ -27,7 +28,7 @@ export function createRelayCooldown(now = () => Date.now(), maxEntries = 1024) { }, set(scope: string, consumer: string, quota: RelayQuota) { prune(); - const key = quota.quotaReason === 'consumer' ? `${scope}:${consumer}` : scope; + const key = (quota.quotaReason === 'consumer' || quota.quotaReason === 'consumer-busy') ? `${scope}:${consumer}` : scope; if (!entries.has(key) && entries.size >= maxEntries) return; const until = now() + quota.retryAfter * 1000; if ((entries.get(key)?.until || 0) > until) return; diff --git a/tests/api/oliveyoung-timeout-defaults.test.ts b/tests/api/oliveyoung-timeout-defaults.test.ts new file mode 100644 index 00000000..12bdb9ae --- /dev/null +++ b/tests/api/oliveyoung-timeout-defaults.test.ts @@ -0,0 +1,82 @@ +/** REST/MCP 기본 제한 시간이 무료 릴레이까지 전달되는지 검증합니다. */ +import { afterEach, expect, it, vi } from 'vitest'; +import { + handleOliveyoungSearchProducts, + handleOliveyoungFindStores, + handleOliveyoungCheckInventory, +} from '../../src/api/handlers.js'; +import { createSearchProductsTool } from '../../src/services/oliveyoung/tools/searchProducts.js'; +import { createFindNearbyStoresTool } from '../../src/services/oliveyoung/tools/findNearbyStores.js'; +import { createCheckInventoryTool } from '../../src/services/oliveyoung/tools/checkInventory.js'; +import * as client from '../../src/services/oliveyoung/client.js'; +vi.mock('../../src/services/oliveyoung/client.js', () => ({ + fetchOliveyoungProducts: vi.fn(async () => ({ products: [], totalCount: 0 })), + fetchOliveyoungStores: vi.fn(async () => ({ stores: [], totalCount: 0 })), + enrichOliveyoungProductsWithNearbyStoreInventory: vi.fn(async () => ({ + products: [], + checkedCount: 0, + })), +})); +afterEach(() => vi.clearAllMocks()); +function expectTimeout(expected: number) { + const calls = [ + vi.mocked(client.fetchOliveyoungProducts), + vi.mocked(client.fetchOliveyoungStores), + vi.mocked(client.enrichOliveyoungProductsWithNearbyStoreInventory), + ].flatMap((fn) => fn.mock.calls); + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) expect(call.at(-1)).toMatchObject({ timeout: expected }); +} +for (const handler of [ + handleOliveyoungSearchProducts, + handleOliveyoungFindStores, + handleOliveyoungCheckInventory, +]) { + it.each([ + [undefined, undefined, 15000], + ['https://relay.example', undefined, 60000], + ['https://relay.example', 'bad', 60000], + ['https://relay.example', '1234', 1234], + ] as const)( + `${handler.name} REST 기본/명시 제한 시간 %s/%s`, + async (relayUrl, timeoutMs, expected) => { + const query: Record = { keyword: 'test', timeoutMs }; + const context = { + env: { OY_RELAY_URL: relayUrl }, + req: { query: (key: string) => query[key] }, + json: vi.fn(), + } as unknown as Parameters[0]; + await handler(context); + expectTimeout(expected); + }, + ); +} +for (const create of [ + createSearchProductsTool, + createFindNearbyStoresTool, + createCheckInventoryTool, +]) { + it.each([ + [undefined, undefined, 15000], + ['https://relay.example', undefined, 60000], + ['https://relay.example', 1234, 1234], + ] as const)( + `${create.name} MCP 기본/명시 제한 시간 %s/%s`, + async (relayUrl, timeoutMs, expected) => { + const tool = create(undefined, { relayUrl }); + const parsed = tool.metadata.inputSchema.timeoutMs.parse(timeoutMs); + expect(parsed).toBe(expected); + await tool.handler({ keyword: 'test', timeoutMs }); + expectTimeout(expected); + }, + ); + it(`${create.name} 명시 인자가 설정 timeout보다 우선한다`, async () => { + const tool = create(undefined, { relayUrl: 'https://relay.example', timeout: 5000 }); + expect(tool.metadata.inputSchema.timeoutMs.parse(undefined)).toBe(5000); + await tool.handler({ keyword: 'test', timeoutMs: 1234 }); + expectTimeout(1234); + vi.clearAllMocks(); + await tool.handler({ keyword: 'test' }); + expectTimeout(5000); + }); +} diff --git a/tests/api/relay-quota.test.ts b/tests/api/relay-quota.test.ts index 67f179c9..0501b423 100644 --- a/tests/api/relay-quota.test.ts +++ b/tests/api/relay-quota.test.ts @@ -7,21 +7,21 @@ import { ServiceError } from '../../src/core/errors.js'; import { requestOliveyoung } from '../../src/services/oliveyoung/transport.js'; import { relayConsumer, withDiagnostics } from '../../src/utils/diagnostics.js'; afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); -it('공개 REST 응답에 재시도 시간과 원인을 유지하고 위조 consumer 헤더는 무시한다', async () => { +it.each(['consumer', 'consumer-busy'])('공개 REST 응답에 재시도 시간과 원인을 유지하고 위조 consumer 헤더는 무시한다: %s', async reason => { vi.spyOn(console, 'error').mockImplementation(() => undefined); - const fetch = vi.fn().mockResolvedValue(new Response('', { status: 429, headers: { 'retry-after': '12', 'x-relay-quota-reason': 'consumer' } })); + const fetch = vi.fn().mockResolvedValue(new Response('', { status: 429, headers: { 'retry-after': reason === 'consumer-busy' ? '1' : '12', 'x-relay-quota-reason': reason } })); vi.stubGlobal('fetch', fetch); const app = new Hono<{ Bindings: AppBindings }>(); app.use('*', diagnosticsMiddleware); app.get('/', async c => { - try { await requestOliveyoung('/p', {}, { relayUrl: 'https://rest-quota.example', relayToken: 'test' }); } + try { await requestOliveyoung('/p', {}, { relayUrl: `https://rest-quota-${reason}.example`, relayToken: 'test', timeout: 500 }); } catch (error) { return serviceErrorResponse(c, error as ServiceError, 'test'); } return c.text('unexpected'); }); const response = await app.request('https://example.com/', { headers: { 'CF-Connecting-IP': '192.0.2.10', 'x-relay-consumer': 'spoofed' } }); expect(response.status).toBe(429); - expect(response.headers.get('retry-after')).toBe('12'); - expect(await response.json()).toMatchObject({ diagnostics: { quotaReason: 'consumer', retryAfter: 12, upstreamStatus: 429 } }); + expect(response.headers.get('retry-after')).toBe(reason === 'consumer-busy' ? '1' : '12'); + expect(await response.json()).toMatchObject({ diagnostics: { quotaReason: reason, retryAfter: reason === 'consumer-busy' ? 1 : 12, upstreamStatus: 429 } }); const headers = fetch.mock.calls[0][1].headers; expect(headers['x-relay-consumer']).toBe(await withDiagnostics(() => relayConsumer('test'), '192.0.2.10')); expect(JSON.stringify(response.headers)).not.toContain('192.0.2.10'); diff --git a/tests/core/registry-mcp-error-contract.test.ts b/tests/core/registry-mcp-error-contract.test.ts index 7739768c..cc228a0a 100644 --- a/tests/core/registry-mcp-error-contract.test.ts +++ b/tests/core/registry-mcp-error-contract.test.ts @@ -127,3 +127,20 @@ it.each([429, 502, 503])('실제 릴레이 HTTP %i를 MCP 클라이언트 진단 expect(JSON.stringify(result)).not.toContain('private-'); expect(fetch).toHaveBeenCalledTimes(1); }); + +it('consumer-busy 사유와 재시도 시간을 MCP 클라이언트에 전달한다', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 429, + headers: { 'x-relay-quota-reason': 'consumer-busy', 'retry-after': '1' } }))); + const result = await callRegisteredTool({ + name: 'oliveyoung_search_products', + metadata: { title: '릴레이 오류', description: '릴레이 오류 진단', inputSchema: {} }, + handler: async () => { + await requestOliveyoung('/p', {}, { relayUrl: 'https://mcp-busy.example', relayToken: 'private-token', timeout: 500 }); + return { content: [] }; + }, + }); + expect(result.isError).toBe(true); + const content = result.content as Array<{ text: string }>; + expect(JSON.parse(content[0].text)).toMatchObject({ diagnostics: { status: 429, upstreamStatus: 429, quotaReason: 'consumer-busy', retryAfter: 1 } }); + expect(JSON.stringify(result)).not.toContain('private-'); +}); diff --git a/tests/relay/consumer.test.ts b/tests/relay/consumer.test.ts index 991bb883..c4c64a05 100644 --- a/tests/relay/consumer.test.ts +++ b/tests/relay/consumer.test.ts @@ -9,7 +9,7 @@ it('동시 요청과 분당 신규 작업을 제한하고 만료 예산만 정 expect(quota.enter('a')).toBeNull(); expect(take()).toBe(true); b.release(); - for (let i = 1; i < 12; i++) expect(take()).toBe(true); + for (let i = 1; i < 24; i++) expect(take()).toBe(true); expect(take()).toBe(false); expect(quota.retryAfter()).toBe(60); const other = quota.enter('b')!; @@ -26,7 +26,7 @@ it('대기 예약을 상한에 포함하고 분이 바뀌면 실행 시점의 let now = 0; const quota = createConsumerQuota(() => now); const admission = quota.enter('a')!; - for (let i = 0; i < 11; i++) { const reservation = admission.reserve()!; reservation.commit(); reservation.release(); } + for (let i = 0; i < 23; i++) { const reservation = admission.reserve()!; reservation.commit(); reservation.release(); } const queued = admission.reserve()!; expect(admission.reserve()).toBeNull(); now = 60000; @@ -34,7 +34,7 @@ it('대기 예약을 상한에 포함하고 분이 바뀌면 실행 시점의 queued.release(); const canceled = admission.reserve()!; canceled.release(); - for (let i = 0; i < 11; i++) { const reservation = admission.reserve()!; reservation.commit(); reservation.release(); } + for (let i = 0; i < 23; i++) { const reservation = admission.reserve()!; reservation.commit(); reservation.release(); } expect(admission.reserve()).toBeNull(); admission.release(); }); diff --git a/tests/relay/fairness.test.ts b/tests/relay/fairness.test.ts index b9db763f..5bac5ff1 100644 --- a/tests/relay/fairness.test.ts +++ b/tests/relay/fairness.test.ts @@ -13,7 +13,8 @@ it('한 소비자의 느린 요청이 공용 슬롯을 독점하지 못한다', const second = relay(request('2')); const denied = await relay(request('3')); expect(denied.status).toBe(429); - expect(denied.headers.get('x-relay-quota-reason')).toBe('consumer'); + expect(denied.headers.get('x-relay-quota-reason')).toBe('consumer-busy'); + expect(denied.headers.get('retry-after')).toBe('1'); const other = relay(request('3', 'b'.repeat(64))); finish({ status: 'SUCCESS' }); expect((await Promise.all([first, second, other])).map(r => r.status)).toEqual([200, 200, 200]); @@ -22,18 +23,18 @@ it('캐시와 공유 작업은 분당 예산을 소비하지 않으며 다음 vi.spyOn(Date, 'now').mockReturnValue(0); const run = vi.fn().mockResolvedValue({ status: 'SUCCESS' }); const relay = createOliveyoungRelay('test', run); - for (let i = 0; i < 12; i++) expect((await relay(request(String(i)))).status).toBe(200); + for (let i = 0; i < 24; i++) expect((await relay(request(String(i)))).status).toBe(200); expect((await relay(request('0'))).status).toBe(200); - expect((await relay(request('12'))).status).toBe(429); - expect((await relay(request('12', 'b'.repeat(64)))).status).toBe(200); + expect((await relay(request('24'))).status).toBe(429); + expect((await relay(request('24', 'b'.repeat(64)))).status).toBe(200); vi.spyOn(Date, 'now').mockReturnValue(60000); - expect((await relay(request('13'))).status).toBe(200); + expect((await relay(request('25'))).status).toBe(200); }); it('실행 전에 취소된 작업은 소비자 분당 사용량에 포함하지 않는다', async () => { const relay = createOliveyoungRelay('test', vi.fn().mockResolvedValue({ status: 'SUCCESS' })); const controller = new AbortController(); controller.abort(); - for (let i = 0; i < 12; i++) expect((await relay(new Request(request(String(i)), { signal: controller.signal }))).status).toBe(503); + for (let i = 0; i < 24; i++) expect((await relay(new Request(request(String(i)), { signal: controller.signal }))).status).toBe(503); expect((await relay(request('next'))).status).toBe(200); }); it('병합된 요청은 두 번째 소비자의 분당 예산을 차감하지 않는다', async () => { @@ -47,8 +48,8 @@ it('병합된 요청은 두 번째 소비자의 분당 예산을 차감하지 finish({ status: 'SUCCESS' }); expect((await first).status).toBe(200); expect((await second).headers.get('x-relay-cache')).toBe('coalesced'); - for (let i = 0; i < 12; i++) expect((await relay(request(String(i), 'b'.repeat(64)))).status).toBe(200); - expect((await relay(request('13', 'b'.repeat(64)))).status).toBe(429); + for (let i = 0; i < 24; i++) expect((await relay(request(String(i), 'b'.repeat(64)))).status).toBe(200); + expect((await relay(request('25', 'b'.repeat(64)))).status).toBe(429); }); it('본문 업로드부터 소비자별 두 슬롯을 적용하며 잘못된 식별자는 legacy를 공유한다', async () => { const run = vi.fn().mockResolvedValue({ status: 'SUCCESS' }); @@ -66,11 +67,11 @@ it('본문 업로드부터 소비자별 두 슬롯을 적용하며 잘못된 식 it('예산이 소진된 소비자의 신규 요청에 다른 소비자가 묶이지 않는다', async () => { const run = vi.fn().mockResolvedValue({ status: 'SUCCESS' }); const relay = createOliveyoungRelay('test', run); - for (let i = 0; i < 12; i++) await relay(request(`used-${i}`)); + for (let i = 0; i < 24; i++) await relay(request(`used-${i}`)); let finish!: (value: { status: string }) => void; run.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); const blocker = relay(request('blocker', 'c'.repeat(64))); - await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(13)); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(25)); const exhausted = relay(request('shared')); const fresh = relay(request('shared', 'b'.repeat(64))); await new Promise(resolve => setTimeout(resolve, 0)); @@ -83,10 +84,10 @@ it('글로벌 예산 거절과 확인 오류는 소비자 사용량을 차감하 const takeQuota = vi.fn().mockResolvedValue(false); const run = vi.fn().mockResolvedValue({ status: 'SUCCESS' }); const relay = createOliveyoungRelay('test', run, { takeQuota }); - for (let i = 0; i < 12; i++) expect((await relay(request(`denied-${i}`))).status).toBe(429); + for (let i = 0; i < 24; i++) expect((await relay(request(`denied-${i}`))).status).toBe(429); takeQuota.mockRejectedValueOnce(new Error('unavailable')).mockResolvedValue(true); expect((await relay(request('unavailable'))).status).toBe(503); - for (let i = 0; i < 12; i++) expect((await relay(request(`allowed-${i}`))).status).toBe(200); + for (let i = 0; i < 24; i++) expect((await relay(request(`allowed-${i}`))).status).toBe(200); expect((await relay(request('exhausted'))).status).toBe(429); - expect(run).toHaveBeenCalledTimes(12); + expect(run).toHaveBeenCalledTimes(24); }); diff --git a/tests/relay/logging.test.ts b/tests/relay/logging.test.ts index b11cf3fb..8bcd719c 100644 --- a/tests/relay/logging.test.ts +++ b/tests/relay/logging.test.ts @@ -236,3 +236,13 @@ it('기록 이후에도 최소 2GiB의 여유 공간을 보존한다', async () expect(logger.status()).toMatchObject({ lowSpace: true, written: 0, dropped: 1 }); expect(await readdir(dir)).toEqual([]); }); +it('busy 및 취소 분류를 기록하면서 소비자 식별자와 본문은 버린다', async () => { + const dir = await directory(); + const logger = createRelayLogger(dir, { service: 'oliveyoung', statfs: space }); + logger.append({ stage: 'queue', outcome: 'canceled', quotaReason: 'consumer-busy', consumer: 'private-consumer', body: 'private-body' }); + await logger.flush(); + const [name] = await readdir(dir); + const line = await readFile(join(dir, name), 'utf8'); + expect(JSON.parse(line)).toMatchObject({ stage: 'queue', outcome: 'canceled', quotaReason: 'consumer-busy' }); + expect(line).not.toContain('private'); +}); diff --git a/tests/relay/oliveyoung.test.ts b/tests/relay/oliveyoung.test.ts index 92aa8232..ffe78864 100644 --- a/tests/relay/oliveyoung.test.ts +++ b/tests/relay/oliveyoung.test.ts @@ -230,6 +230,7 @@ it('동일 요청을 병합하고 첫 요청 취소가 다른 대기자를 중 release({ status: 'SUCCESS' }); await first; expect((await original).status).toBe(503); + expect(events).toContainEqual(expect.objectContaining({ stage: 'queue', outcome: 'canceled' })); expect((await duplicate).headers.get('x-relay-cache')).toBe('coalesced'); expect(runner).toHaveBeenCalledTimes(2); expect(events).toContainEqual( @@ -257,12 +258,12 @@ it('만료된 대기는 실행하지 않고 실패한 응답을 캐시하지 않 await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(1)); const queued = relay(request(undefined, JSON.stringify({ goodsNo: 'other' }))); await new Promise((resolve) => setTimeout(resolve, 0)); - clock.mockReturnValue(16000); + clock.mockReturnValue(31000); release({ status: 'SUCCESS' }); await first; expect((await queued).status).toBe(503); expect(runner).toHaveBeenCalledTimes(1); - clock.mockReturnValue(616000); + clock.mockReturnValue(631000); expect((await relay(request())).status).toBe(502); expect((await relay(request())).status).toBe(502); expect(runner).toHaveBeenCalledTimes(3); @@ -314,7 +315,7 @@ it('취소된 오래된 요청에 합류한 새 요청은 자기 대기 시간 await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(1)); const old = relay(request()); await new Promise((resolve) => setTimeout(resolve, 0)); - clock.mockReturnValue(16000); + clock.mockReturnValue(31000); const fresh = relay(request()); await new Promise((resolve) => setTimeout(resolve, 0)); release({ status: 'SUCCESS' }); @@ -405,3 +406,27 @@ it('공유 요청의 부모 ID와 실제 예산 소비를 기록한다', async ( expect((await first).status).toBe(502); expect((await child).status).toBe(502); }); + +it('브라우저 교체 중 23초 기다린 작업은 실행하고 30초 대기는 만료로 기록한다', async () => { + const clock = vi.spyOn(Date, 'now').mockReturnValue(0); + const events: unknown[] = []; + let release!: (value: { status: string }) => void; + const runner = vi.fn().mockImplementationOnce(() => new Promise(resolve => { release = resolve; })) + .mockResolvedValue({ status: 'SUCCESS' }); + const relay = createOliveyoungRelay('test', runner, { onEvent: event => { events.push(event); } }); + const first = relay(request()); + await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(1)); + const expired = relay(request(undefined, JSON.stringify({ goodsNo: 'expired' }))); + await new Promise(resolve => setTimeout(resolve, 0)); + clock.mockReturnValue(7000); + const valid = relay(request(undefined, JSON.stringify({ goodsNo: 'valid' }))); + await new Promise(resolve => setTimeout(resolve, 0)); + clock.mockReturnValue(30000); + release({ status: 'SUCCESS' }); + expect((await first).status).toBe(200); + expect((await expired).status).toBe(503); + expect((await valid).status).toBe(200); + expect(events).toContainEqual(expect.objectContaining({ stage: 'queue', outcome: 'expired' })); + expect(runner).toHaveBeenCalledTimes(2); + clock.mockRestore(); +}); diff --git a/tests/services/oliveyoung/burst.test.ts b/tests/services/oliveyoung/burst.test.ts new file mode 100644 index 00000000..41adaf58 --- /dev/null +++ b/tests/services/oliveyoung/burst.test.ts @@ -0,0 +1,151 @@ +import { setTimeout as realDelay } from 'node:timers/promises'; +import { afterEach, expect, it, vi } from 'vitest'; +import { isValidOliveyoungRelayUrl, requestOliveyoung } from '../../../src/services/oliveyoung/transport.js'; +import { withDiagnostics } from '../../../src/utils/diagnostics.js'; + +const busy = (retry = '1') => new Response('', { status: 429, headers: { 'x-relay-quota-reason': 'consumer-busy', 'retry-after': retry } }); +const success = () => Response.json({ status: 'SUCCESS' }); +const options = (name: string) => ({ relayUrl: `https://${name}.example`, relayToken: 'test', apiKey: 'unused-paid-key' }); +afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); +async function waitForSetup(ready: () => boolean) { + for (let attempt = 0; attempt < 200; attempt++) { + if (ready()) { await realDelay(0); return; } + await realDelay(5); + } + throw new Error('요청 준비가 완료되지 않았습니다.'); +} +function fakeTime() { vi.useFakeTimers(); vi.setSystemTime(0); } + +it('busy는 1초 기다린 뒤 한 번만 재시도하며 유료 경로를 사용하지 않는다', async () => { + fakeTime(); + const fetch = vi.fn().mockResolvedValueOnce(busy()).mockResolvedValueOnce(success()); + vi.stubGlobal('fetch', fetch); + const result = requestOliveyoung('/p', {}, options('busy-success')).catch(e => e); + await waitForSetup(() => vi.getTimerCount() > 0); + expect(fetch).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(999); + expect(fetch).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(await result).toEqual({ status: 'SUCCESS' }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls.every(([url]) => url === 'https://busy-success.example/v1/oliveyoung/p')).toBe(true); +}); + +it('반복 busy도 최대 두 번 호출하고 같은 소비자만 휴지한다', async () => { + fakeTime(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const fetch = vi.fn().mockImplementation(() => Promise.resolve(busy())); + vi.stubGlobal('fetch', fetch); + const config = options('busy-twice'); + const first = withDiagnostics(() => requestOliveyoung('/p', {}, config), '192.0.2.1').catch(e => e); + await waitForSetup(() => vi.getTimerCount() > 0); + await vi.advanceTimersByTimeAsync(1000); + expect(await first).toMatchObject({ status: 429, quotaReason: 'consumer-busy', retryAfter: 1 }); + expect(fetch).toHaveBeenCalledTimes(2); + fetch.mockResolvedValueOnce(success()); + await expect(withDiagnostics(() => requestOliveyoung('/p', {}, config), '192.0.2.2')).resolves.toMatchObject({ status: 'SUCCESS' }); + expect(fetch).toHaveBeenCalledTimes(3); +}); + +it('이전 busy 휴지도 요청의 단일 재시도 예산 안에서 복구한다', async () => { + fakeTime(); + const config = { ...options('busy-cached'), timeout: 500 }; + const fetch = vi.fn().mockResolvedValueOnce(busy()).mockResolvedValueOnce(busy()); + vi.stubGlobal('fetch', fetch); + await expect(requestOliveyoung('/p', {}, config)).rejects.toMatchObject({ quotaReason: 'consumer-busy' }); + const result = requestOliveyoung('/p', {}, { ...config, timeout: 5000 }).catch(e => e); + await waitForSetup(() => vi.getTimerCount() > 0); + expect(fetch).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1000); + expect(await result).toMatchObject({ quotaReason: 'consumer-busy' }); + expect(fetch).toHaveBeenCalledTimes(2); +}); + +it('첫 호출 시간과 재시도 대기 시간을 명시한 전체 deadline에 합산한다', async () => { + fakeTime(); + const fetch = vi.fn().mockImplementationOnce(() => new Promise(resolve => setTimeout(() => resolve(busy()), 500))) + .mockImplementationOnce((_url, init) => new Promise((_resolve, reject) => init.signal.addEventListener('abort', () => reject(new DOMException('timeout', 'AbortError'))))); + vi.stubGlobal('fetch', fetch); + const result = requestOliveyoung('/p', {}, { ...options('busy-deadline'), timeout: 2000 }).catch(e => e); + await waitForSetup(() => vi.getTimerCount() > 0); + await vi.advanceTimersByTimeAsync(1500); + expect(fetch).toHaveBeenCalledTimes(2); + const signal = fetch.mock.calls[1][1].signal; + await vi.advanceTimersByTimeAsync(499); + expect(signal.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(signal.aborted).toBe(true); + expect(await result).toMatchObject({ code: 'OLIVEYOUNG_RELAY_TIMEOUT' }); +}); + +it.each([500, 1000])('남은 deadline %ims가 대기 시간 이하이면 즉시 busy를 반환한다', async timeout => { + fakeTime(); + const fetch = vi.fn().mockResolvedValueOnce(busy()); + vi.stubGlobal('fetch', fetch); + await expect(requestOliveyoung('/p', {}, { ...options(`busy-short-${timeout}`), timeout })).rejects.toMatchObject({ quotaReason: 'consumer-busy' }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); +}); + +it.each(['0', '1evil', '2'])('busy retry-after %s는 자동 재시도하지 않는다', async retry => { + const fetch = vi.fn().mockResolvedValueOnce(busy(retry)); + vi.stubGlobal('fetch', fetch); + await expect(requestOliveyoung('/p', {}, options(`busy-invalid-${retry}`))).rejects.toMatchObject({ status: 429 }); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +it.each([['relay', 60000], ['direct', 15000]] as const)('%s 기본 deadline을 적용한다', async (mode, timeout) => { + fakeTime(); + const fetch = vi.fn().mockImplementation((_url, init) => new Promise((_resolve, reject) => init.signal.addEventListener('abort', () => reject(new DOMException('timeout', 'AbortError'))))); + vi.stubGlobal('fetch', fetch); + const result = requestOliveyoung('/p', {}, mode === 'relay' ? options('default-deadline') : {}).catch(e => e); + await waitForSetup(() => vi.getTimerCount() > 0); + const signal = fetch.mock.calls[0][1].signal; + await vi.advanceTimersByTimeAsync(timeout - 1); + expect(signal.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(signal.aborted).toBe(true); + expect(await result).toBeInstanceOf(Error); +}); + +it('이전 busy 휴지가 풀리면 성공 응답을 받는다', async () => { + fakeTime(); + const config = options('cached-success'); + const fetch = vi.fn().mockResolvedValueOnce(busy()).mockResolvedValueOnce(success()); + vi.stubGlobal('fetch', fetch); + await expect(requestOliveyoung('/p', {}, { ...config, timeout: 500 })).rejects.toMatchObject({ quotaReason: 'consumer-busy' }); + const result = requestOliveyoung('/p', {}, config).catch(e => e); + await waitForSetup(() => vi.getTimerCount() > 0); + await vi.advanceTimersByTimeAsync(1000); + expect(await result).toEqual({ status: 'SUCCESS' }); + expect(fetch).toHaveBeenCalledTimes(2); +}); + +it('재시도 대기가 지연되어 전체 deadline이 지나면 새 요청을 보내지 않는다', async () => { + fakeTime(); + const fetch = vi.fn().mockResolvedValueOnce(busy()); + vi.stubGlobal('fetch', fetch); + const result = requestOliveyoung('/p', {}, { ...options('delayed-wait'), timeout: 2000 }).catch(e => e); + await waitForSetup(() => vi.getTimerCount() > 0); + vi.setSystemTime(2000); + await vi.advanceTimersByTimeAsync(1000); + expect(await result).toMatchObject({ code: 'OLIVEYOUNG_RELAY_TIMEOUT' }); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +it('빈 릴레이 주소는 유효한 설정이 아니다', () => { + expect(isValidOliveyoungRelayUrl(undefined)).toBe(false); + expect(isValidOliveyoungRelayUrl(' ')).toBe(false); +}); + +it.each(['2', '86400'])('지원하지 않는 busy 대기 %s는 시간이 지나도 휴지나 재시도로 바뀌지 않는다', async retry => { + fakeTime(); + const fetch = vi.fn().mockImplementation(() => Promise.resolve(busy(retry))); + vi.stubGlobal('fetch', fetch); + const config = options(`unsupported-busy-${retry}`); + await expect(requestOliveyoung('/p', {}, config)).rejects.toMatchObject({ status: 429, quotaReason: undefined, retryAfter: undefined }); + vi.setSystemTime((Number(retry) - 1) * 1000); + await expect(requestOliveyoung('/p', {}, config)).rejects.toMatchObject({ status: 429, quotaReason: undefined, retryAfter: undefined }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/tests/services/oliveyoung/relayErrors.test.ts b/tests/services/oliveyoung/relayErrors.test.ts index a8000a72..21dd5f86 100644 --- a/tests/services/oliveyoung/relayErrors.test.ts +++ b/tests/services/oliveyoung/relayErrors.test.ts @@ -1,3 +1,4 @@ +import { setTimeout as realDelay } from 'node:timers/promises'; import { afterEach, expect, it, vi } from 'vitest'; import { requestOliveyoung } from '../../../src/services/oliveyoung/transport.js'; @@ -63,7 +64,8 @@ it('릴레이 응답 본문을 읽는 중 실제 제한 시간이 지나도 시 try { const request = requestOliveyoung('/p', {}, { ...options, timeout: 10 }); const assertion = expect(request).rejects.toMatchObject({ code: 'OLIVEYOUNG_RELAY_TIMEOUT', status: 504, retryable: true }); - await vi.waitFor(() => expect(fetch).toHaveBeenCalled()); + for (let attempt = 0; attempt < 200 && !vi.mocked(fetch).mock.calls.length; attempt++) await realDelay(5); + expect(fetch).toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(10); await assertion; } finally { diff --git a/tests/utils/relayQuota.test.ts b/tests/utils/relayQuota.test.ts index ca5d5cdf..3420dc4d 100644 --- a/tests/utils/relayQuota.test.ts +++ b/tests/utils/relayQuota.test.ts @@ -20,3 +20,11 @@ it('휴지 표는 용량을 넘지 않고 더 짧은 휴지로 기존 시간을 cooldown.set('other', 'b', { quotaReason: 'minute', retryAfter: 10 }); expect(cooldown.get('other', 'a')?.retryAfter).toBe(10); }); +it('busy 헤더를 검증하고 해당 소비자만 휴지한다', () => { + const quota = parseRelayQuota(429, new Headers({ 'x-relay-quota-reason': 'consumer-busy', 'retry-after': '1' })); + expect(quota).toEqual({ quotaReason: 'consumer-busy', retryAfter: 1 }); + const cooldown = createRelayCooldown(() => 0); + cooldown.set('scope', 'a', quota!); + expect(cooldown.get('scope', 'a')).toEqual(quota); + expect(cooldown.get('scope', 'b')).toBeUndefined(); +});