From 8c78808d92858e53d55bec78eec6bd465dcc4ae1 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Tue, 29 Sep 2026 15:00:14 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=A6=B4=EB=A0=88=EC=9D=B4=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EC=A7=91=EC=A4=91=EA=B3=BC=20=ED=97=AC=EC=8A=A4?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20=EC=9E=AC=EA=B2=80=EC=82=AC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/health-checks.yml | 49 +++++++++- docs/relay-observability.md | 17 ++++ .../plans/2026-09-29-relay-fairness-health.md | 12 +++ ...2026-09-29-relay-fairness-health-design.md | 15 +++ package-lock.json | 4 +- package.json | 2 +- scripts/relay/cache.ts | 9 +- scripts/relay/consumer.ts | 48 ++++++++++ scripts/relay/oliveyoung.ts | 15 +++ src/api/cgvHandlers.ts | 8 +- src/api/healthChecks.ts | 22 +++-- src/api/healthFailurePolicy.ts | 13 +++ src/api/response.ts | 1 + src/core/errors.ts | 11 +++ src/middleware/dailyRateLimit.ts | 2 +- src/middleware/diagnostics.ts | 3 +- src/services/cgv/errors.ts | 14 ++- src/services/oliveyoung/errors.ts | 1 + src/services/oliveyoung/transport.ts | 23 ++++- src/utils/diagnostics.ts | 17 +++- src/utils/http.ts | 9 +- src/utils/relayQuota.ts | 43 +++++++++ tests/api/health-cgv-classification.test.ts | 80 ++++++++++++++++ tests/api/relay-quota.test.ts | 28 ++++++ tests/app/app-health-checks.test.ts | 2 +- tests/relay/cache.test.ts | 13 +++ tests/relay/consumer.test.ts | 40 ++++++++ tests/relay/fairness.test.ts | 92 +++++++++++++++++++ tests/relay/oliveyoung.test.ts | 10 +- tests/scripts/repository-config.test.ts | 4 +- tests/services/oliveyoung/cooldown.test.ts | 51 ++++++++++ tests/services/oliveyoung/relayErrors.test.ts | 1 + tests/utils/relayConsumer.test.ts | 29 ++++++ tests/utils/relayQuota.test.ts | 22 +++++ tests/workflows/health-runner.test.ts | 88 ++++++++++++++++++ vitest.config.ts | 2 +- 36 files changed, 756 insertions(+), 44 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-29-relay-fairness-health.md create mode 100644 docs/superpowers/specs/2026-09-29-relay-fairness-health-design.md create mode 100644 scripts/relay/consumer.ts create mode 100644 src/api/healthFailurePolicy.ts create mode 100644 src/utils/relayQuota.ts create mode 100644 tests/api/health-cgv-classification.test.ts create mode 100644 tests/api/relay-quota.test.ts create mode 100644 tests/relay/consumer.test.ts create mode 100644 tests/relay/fairness.test.ts create mode 100644 tests/services/oliveyoung/cooldown.test.ts create mode 100644 tests/utils/relayConsumer.test.ts create mode 100644 tests/utils/relayQuota.test.ts create mode 100644 tests/workflows/health-runner.test.ts diff --git a/.github/workflows/health-checks.yml b/.github/workflows/health-checks.yml index 75a3b09f..6340442b 100644 --- a/.github/workflows/health-checks.yml +++ b/.github/workflows/health-checks.yml @@ -18,6 +18,7 @@ concurrency: jobs: health-checks: runs-on: ubuntu-latest + timeout-minutes: 10 name: Service health checks steps: - name: Run health checks @@ -35,20 +36,44 @@ jobs: HEALTH_CHECK_ENDPOINT="${HEALTH_CHECK_URL}/api/health/checks?mode=full&fresh=true&includeSamples=true&timeoutMs=20000&slowThresholdMs=9000" HEALTH_CHECK_FORCE_FRESH="true" else - HEALTH_CHECK_ENDPOINT="${HEALTH_CHECK_URL}/api/health/checks?mode=quick&timeoutMs=5000&slowThresholdMs=3000" - HEALTH_CHECK_FORCE_FRESH="false" + HEALTH_CHECK_ENDPOINT="${HEALTH_CHECK_URL}/api/health/checks?mode=quick&fresh=true&timeoutMs=5000&slowThresholdMs=3000" + HEALTH_CHECK_FORCE_FRESH="true" fi + HEALTH_CHECK_ATTEMPT=0 run_health_check() { - curl -fsS \ + HEALTH_CHECK_ATTEMPT=$((HEALTH_CHECK_ATTEMPT + 1)) + rm -f health-checks.json + if ! curl -fsS --connect-timeout 10 --max-time 240 \ -H "Authorization: Bearer ${HEALTH_CHECK_SECRET}" \ -H "x-health-check-force-fresh: ${HEALTH_CHECK_FORCE_FRESH}" \ "${HEALTH_CHECK_ENDPOINT}" \ - -o health-checks.json + -o health-checks.json; then + echo "Health check HTTP request failed on attempt ${HEALTH_CHECK_ATTEMPT}" > health-check-summary.txt + cp health-check-summary.txt "health-checks-attempt-${HEALTH_CHECK_ATTEMPT}.txt" + return 1 + fi + cp health-checks.json "health-checks-attempt-${HEALTH_CHECK_ATTEMPT}.json" || return 1 node <<'NODE' const fs = require('node:fs'); - const payload = JSON.parse(fs.readFileSync('health-checks.json', 'utf8')); + let payload; + try { payload = JSON.parse(fs.readFileSync('health-checks.json', 'utf8')); } catch {} + const statuses = ['ok', 'degraded', 'fail']; + const valid = payload && statuses.includes(payload.status) && + Array.isArray(payload.checks) && payload.checks.length > 0 && + payload.checks.every(check => check && typeof check.id === 'string' && check.id.length > 0 && + [...statuses, 'skipped'].includes(check.status) && typeof check.message === 'string') && + new Set(payload.checks.map(check => check.id)).size === payload.checks.length && + payload.status === (payload.checks.some(check => check.status === 'fail') ? 'fail' : + payload.checks.some(check => check.status === 'degraded') ? 'degraded' : 'ok'); + if (!valid) { + const summary = 'Health check returned an invalid or inconsistent payload'; + fs.writeFileSync('health-check-summary.txt', summary); + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary + '\n'); + console.log(summary); + process.exitCode = 1; + } else { const failedChecks = (payload.checks || []).filter((check) => check.status === 'fail'); const degradedChecks = (payload.checks || []).filter((check) => check.status === 'degraded'); const lines = [...failedChecks, ...degradedChecks].map((check) => { @@ -71,15 +96,29 @@ jobs: if (failedChecks.length > 0 || payload.status === 'fail') { process.exitCode = 1; } + } NODE } if ! run_health_check; then echo "Health check failed on attempt 1; retrying once" sleep 10 + HEALTH_CHECK_ENDPOINT="${HEALTH_CHECK_ENDPOINT}&cacheBust=true" run_health_check fi + - name: Preserve health check attempts + if: always() + uses: actions/upload-artifact@v7 + with: + name: health-checks-${{ github.run_id }}-${{ github.run_attempt }} + path: | + health-checks-attempt-*.json + health-checks-attempt-*.txt + health-check-summary.txt + retention-days: 7 + if-no-files-found: warn + - name: Notify health failure if: failure() && github.event_name == 'schedule' && github.event.schedule == '10 15 * * *' env: diff --git a/docs/relay-observability.md b/docs/relay-observability.md index 54ab4262..ac3c53ec 100644 --- a/docs/relay-observability.md +++ b/docs/relay-observability.md @@ -48,3 +48,20 @@ rg --fixed-strings '확인할-request-id' "$OY_RELAY_STATE_DIR/logs" "$DTRYX_REL 인증과 payload 검증 뒤 공통 릴레이 캐시를 확인한다. REST와 MCP가 같은 조건을 요청하면 공유된다. 상품 검색/매장은 5분, 상품 ID는 10분, 재고는 1분 TTL이다. 최대 256항목 및 키·값 직렬화 16MiB, 오래된 항목부터 퇴출한다. 성공만 저장하며 동일 진행 요청은 합친다. 탭이나 브라우저를 추가하지 않는다. 캐시 적중은 quota를 소비하지 않는다. `x-relay-cache: hit/miss/coalesced` 및 health.cache로 확인한다. 서로 다른 검색이 대부분이면 절감 효과는 작다. quota는 기존 분당30/UTC 일일3000을 유지한다. REST 캐시도 별도로 존재하며 products5분/stores24시간/inventory10분이므로 외부 REST 응답의 최신성은 두 계층의 영향을 받는다. `_healthCheck`는 REST 캐시를 새로 조회하지만 공통 릴레이 캐시는 계속 적용된다. + + +## OY 요청 집중 보호 (1.2.7) + +전역 상한(분당 30회, UTC 하루 3,000회)에 더해, 익명 소비자별 실제 신규 실행을 분당 12회로 제한한다. 요청 본문을 읽기 전부터 동시 미완료 요청은 소비자당 2개만 허용한다. 따라서 세 번째 동시 요청은 캐시 적중 여부를 확인하기 전에 거절될 수 있다. 캐시 적중과 진행 요청 합치기는 실행 횟수를 차감하지 않는다. 대기 작업은 용량을 예약하고 전역 quota를 통과한 실행 직전에 차감하며, 취소·만료·전역 quota 오류는 예약을 반환한다. + +Worker는 Cloudflare 연결 IP(교차 존 Worker는 기존 일일 제한과 같은 CF-Worker 식별 기준)를 일별 HMAC으로 변환한다. 클라이언트가 보낸 소비자 헤더는 무시하며, 원문 IP와 HMAC 값은 로그에 기록하지 않는다. 같은 네트워크를 공유하는 사용자는 같은 한도를 공유할 수 있다. CLI처럼 HTTP 요청 문맥이 없는 호출은 공통 legacy 버킷을 사용한다. 소비자 표는 최대 1,024개이며 만료된 비활성 항목만 정리한다. 표가 가득 찬 동안 새 소비자는 제한한다. + +검증된 릴레이 429의 `minute`/`daily` 제한은 해당 릴레이·인증 구성 전체에, `consumer` 제한은 해당 익명 소비자에만 Worker 재시도 대기 시간을 적용한다. 대기 정보도 최대 1,024개, 최대 24시간이며 Worker 인스턴스 안에서만 유지된다. REST의 `Retry-After`와 REST/MCP diagnostics의 `quotaReason`, `retryAfter`를 확인한다. 이미 있는 REST 캐시는 계속 사용할 수 있다. 이 기능은 허용량을 늘리지 않으며 정상적인 보호 429는 계속 발생할 수 있다. + +health.cache의 `hits`, `misses`, `expired`, `evicted`는 프로세스 시작 이후 집계다. `expired`는 정리된 만료 항목 수이며, 개별 miss가 만료 때문인지 추적하는 별도 기록은 만들지 않는다. + +## GitHub Health Checks 판정과 보존 + +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 재시도는 활성화하지 않는다. diff --git a/docs/superpowers/plans/2026-09-29-relay-fairness-health.md b/docs/superpowers/plans/2026-09-29-relay-fairness-health.md new file mode 100644 index 00000000..16de0eaa --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-relay-fairness-health.md @@ -0,0 +1,12 @@ +# Relay Fairness and Health Implementation Plan + +> **For agentic workers:** Use superpowers:subagent-driven-development for isolated rate-control implementation and independent reviews. + +**Goal:** OY 공용예산 독점을 방지하고 헬스체크 재검사와 외부403 분류를 바로잡는다. +**Architecture:** request-scoped anonymous consumer→relay admission→bounded cooldown; typed upstream diagnostics→health classifier→strict workflow runner. +**Tech Stack:** Node24, TypeScript, Cloudflare Workers ALS, Hono, Vitest, GitHub Actions. + +- [ ] Rate-control task: 소비자identity 및 HMAC headers, relay bounded admission12/min+2inflight, 캐시bypass, retryAfter metadata/publicpropagation/cooldown scope를 tests먼저구현. 소비자격리·spoof·UTCreset·queue해제·boundedmaps·취소·신선캐시우선·RetryAfter감소·globalvsconsumer 범위 검증. Owner agent. +- [ ] Health task: CgvUpstreamUnavailableError의원본status를 API diagnostics로보존. 건강검사 typed403분류와 CLI경로scope. 401/timeouts/unrelatederrors실패테스트. Owner root. +- [ ] Workflow task: scripts/ops health-summary validator/runner 및workflow. curl실패/잘못된JSON/0checks/fail/degraded/forcedfreshretry/timeout/아티팩트테스트. Owner root. +- [ ] 전체검증·스펙/품질검토 후버전갱신 및PR. mainCI성공후Worker/Mac배포,public/MCP조회,수동Health Checks실행결과확인. 로그/디스크상한유지·운영기록. diff --git a/docs/superpowers/specs/2026-09-29-relay-fairness-health-design.md b/docs/superpowers/specs/2026-09-29-relay-fairness-health-design.md new file mode 100644 index 00000000..597fd735 --- /dev/null +++ b/docs/superpowers/specs/2026-09-29-relay-fairness-health-design.md @@ -0,0 +1,15 @@ +# 요청 집중 완화와 헬스체크 복구 + +## 근거 + +1.2.6 배포 이후 OY 릴레이429392건은 모두 분당30회 상한이며, 상품검색에387건 집중됐다. 캐시hit33건,50387건,upstream실패2건. 현재health 정상이나 burst가 공용quota/queue를 점유한다. 최신GitHub검사는CGV 원본403만 fail; Mac 및 두 Worker주소에서 cache-bust 조회는 현재정상이다. quick재시도는 같은5분summary캐시를 재사용한다. + +## 승인된 개선 방향 + +- 글로벌30/min·3000/UTCday 유지. OY 신규 upstream 실행은 익명 소비자별12/min, 동시미완료2로 제한. inventory최대12fanout예산을 허용하고 독점적burst가공용8슬롯을채우는것을막는다. 캐시hit/coalesced에는 소비자 사용량을 차감하지 않는다. 제한응답은정상적인보호동작으로남으며모든429제거를약속하지않는다. +- Worker는 Cloudflare client identity를 요청컨텍스트로 전달하고 relayToken을 키로 UTCday별HMAC 식별자를 만든다. 교차존Worker의공통IP는기존일일제한과같은CF-Worker기준을사용. 클라이언트가보낸consumer헤더는신뢰하지않음. 원문IP/식별자로그미기록. CLI/이전Worker는legacy버킷. 소비자표는고정크기/만료로메모리제한. +- 릴레이 minute/daily/consumer 제한과 retryAfter를 ServiceError/REST Retry-After/MCP diagnostics까지보존. Worker전송은검증된429에한해재시도기한동안bounded cooldown 적용. global과consumer차단scope를구분하고403/5xx/잘못된headers는휴지로저장하지않음. 빠른재시도로Mac을두드리는현상을줄임. +- 캐시 hit/miss 및 만료 정리·용량 퇴출 횟수를 원문/키 없이 집계한다. 개별 miss 원인 추적용 별도 기록은 만들지 않는다. 기존256항목16MiB캐시와디스크안전로그경계유지. +- 헬스체크CGV는정확한CGV_UPSTREAM_UNAVAILABLE + upstream403인경우에만degraded. 401/5xx/timeouts/parse/auth오류는fail. 원본정보를구조화진단에보존하며CLI계약검사에도일관적용. +- GitHub재검사는fresh=true+명시적forceheader로이전summary를피하고curl실패시기존파일을읽지않음. 네트워크/전체실행timeout,엄격한JSON요약검증,매실행원본JSON/요약artifact보존. 0검사/잘못된payload가성공으로처리되지않도록함. 알려진서비스제한은degraded상태와세부내용을보고하며전체복구로표현하지않음. +- 전체check/build/100%coverage,독립검토,PR/CI/Worker+Mac배포,수동GitHubworkflow실행으로검증. Zyte사용안함. diff --git a/package-lock.json b/package-lock.json index f659ee57..56fa7431 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daiso", - "version": "1.2.6", + "version": "1.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daiso", - "version": "1.2.6", + "version": "1.2.7", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index 88bddd87..31cf1d60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "daiso", - "version": "1.2.6", + "version": "1.2.7", "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/cache.ts b/scripts/relay/cache.ts index c88ea0ab..9fab7195 100644 --- a/scripts/relay/cache.ts +++ b/scripts/relay/cache.ts @@ -10,19 +10,21 @@ export function canonicalKey(operation: string, body: Record): export function createResponseCache(maxEntries = 256, maxBytes = 16 * 1024 * 1024, now = Date.now) { const entries = new Map(); let bytes = 0; + const counters = { hits: 0, misses: 0, expired: 0, evicted: 0 }; const remove = (key: string) => { bytes -= entries.get(key)!.bytes; entries.delete(key); }; const prune = () => { const time = now(); - for (const [key, entry] of entries) if (entry.expires <= time) remove(key); + for (const [key, entry] of entries) if (entry.expires <= time) { remove(key); counters.expired++; } }; return { get(key: string): string | undefined { prune(); const entry = entries.get(key); - if (!entry) return undefined; + if (!entry) { counters.misses++; return undefined; } + counters.hits++; entries.delete(key); entries.set(key, entry); return entry.value; @@ -34,13 +36,14 @@ export function createResponseCache(maxEntries = 256, maxBytes = 16 * 1024 * 102 if (size > maxBytes) return; while (entries.size >= maxEntries || bytes + size > maxBytes) { remove(entries.keys().next().value!); + counters.evicted++; } entries.set(key, { value, bytes: size, expires: now() + ttl }); bytes += size; }, stats() { prune(); - return { entries: entries.size, bytes, maxEntries, maxBytes }; + return { entries: entries.size, bytes, maxEntries, maxBytes, ...counters }; }, }; } diff --git a/scripts/relay/consumer.ts b/scripts/relay/consumer.ts new file mode 100644 index 00000000..c93c2454 --- /dev/null +++ b/scripts/relay/consumer.ts @@ -0,0 +1,48 @@ +/** 만료된 예산만 정리하는 소비자별 본문·대기 슬롯 및 신규 작업 상한. */ +export function createConsumerQuota(now = Date.now, maxEntries = 1024) { + const entries = new Map(); + return { + retryAfter: () => Math.max(1, Math.ceil((60000 - now() % 60000) / 1000)), + enter(identity: string) { + const minute = Math.floor(now() / 60000); + for (const [key, value] of entries) { + if (value.active === 0 && value.minute < minute) entries.delete(key); + } + let entry = entries.get(identity); + if (!entry) { + if (entries.size >= maxEntries) return null; + entry = { minute, count: 0, active: 0, reserved: 0 }; + entries.set(identity, entry); + } + if (entry.active >= 2) return null; + entry.active++; + const resetMinute = () => { + const currentMinute = Math.floor(now() / 60000); + if (entry.minute < currentMinute) { + entry.minute = currentMinute; + entry.count = 0; + } + }; + return { + reserve() { + resetMinute(); + if (entry.count + entry.reserved >= 12) return null; + entry.reserved++; + let reserved = true; + return { + commit() { + resetMinute(); + entry.count++; + entry.reserved--; + reserved = false; + }, + release() { + if (reserved) entry.reserved--; + }, + }; + }, + release() { entry.active--; }, + }; + }, + }; +} diff --git a/scripts/relay/oliveyoung.ts b/scripts/relay/oliveyoung.ts index 2eae3541..3aa01bcd 100644 --- a/scripts/relay/oliveyoung.ts +++ b/scripts/relay/oliveyoung.ts @@ -2,6 +2,7 @@ import { randomUUID, timingSafeEqual } from 'node:crypto'; import { OLIVEYOUNG_API } from '../../src/services/oliveyoung/api.js'; import type { OliveyoungApiResponse } from '../../src/services/oliveyoung/types.js'; +import { createConsumerQuota } from './consumer.js'; import { canonicalKey, createResponseCache } from './cache.js'; import type { QuotaStatus } from './quota.js'; export type BrowserRunner = ( @@ -66,6 +67,11 @@ export function createOliveyoungRelay( if (!token.trim()) throw new Error('OY_RELAY_TOKEN이 필요합니다.'); const expected = Buffer.from(`Bearer ${token}`); const cache = createResponseCache(); + const consumers = createConsumerQuota(); + const consumerDenied = () => Response.json({ error: 'Relay quota exceeded', reason: 'consumer' }, { + status: 429, + headers: { 'x-relay-quota-reason': 'consumer', 'retry-after': String(consumers.retryAfter()) }, + }); const ttl: Record = { 'find-store': 300000, 'product-search-v3': 300000, @@ -103,6 +109,10 @@ export function createOliveyoungRelay( return error(404, 'Not found'); // 느린 업로드도 슬롯을 점유하므로 본문 읽기 전에 상한을 검사합니다. if (outstanding >= 8) return error(503, 'Relay busy'); + 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(); outstanding++; try { let body: Record; @@ -171,6 +181,8 @@ export function createOliveyoungRelay( emit('cache', mode, entry?.requestId); const queuedAt = Date.now(); if (!entry) { + const reservation = admission.reserve(); + if (!reservation) return consumerDenied(); const signals = new Map([[request.signal, queuedAt]]); const task = tail .then(async (): Promise => { @@ -193,6 +205,7 @@ export function createOliveyoungRelay( return 'unavailable'; } } + reservation.commit(); const result = await run(paths[operation], body); if (result?.status === 'SUCCESS') cache.set(key, JSON.stringify(result), ttl[operation]); @@ -201,6 +214,7 @@ export function createOliveyoungRelay( }) .finally(() => { pending.delete(key); + reservation.release(); }); entry = { task, signals, startedAt: 0, requestId }; pending.set(key, entry); @@ -238,6 +252,7 @@ export function createOliveyoungRelay( } } finally { outstanding--; + admission.release(); } }; } diff --git a/src/api/cgvHandlers.ts b/src/api/cgvHandlers.ts index 58326b76..1355ff37 100644 --- a/src/api/cgvHandlers.ts +++ b/src/api/cgvHandlers.ts @@ -11,7 +11,7 @@ import { import { isCgvUpstreamUnavailableError } from '../services/cgv/errors.js'; import { fetchCgvNearbyTheaters, resolveCgvNearestTheater } from '../services/cgv/location.js'; import { filterAndSortTimetable } from '../services/cgv/timetable.js'; -import { type ApiContext, errorResponse, successResponse } from './response.js'; +import { type ApiContext, errorResponse, serviceErrorResponse, successResponse } from './response.js'; function parseOptionalNumber(value: string | undefined): number | undefined { if (!value) { @@ -82,7 +82,7 @@ export async function handleCgvFindTheaters(c: ApiContext) { ); } catch (error) { if (isCgvUpstreamUnavailableError(error)) { - return errorResponse(c, 'CGV_UPSTREAM_UNAVAILABLE', error.message, 503); + return serviceErrorResponse(c, error, 'theaters'); } const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'CGV_THEATER_SEARCH_FAILED', message, 500); @@ -154,7 +154,7 @@ export async function handleCgvSearchMovies(c: ApiContext) { ); } catch (error) { if (isCgvUpstreamUnavailableError(error)) { - return errorResponse(c, 'CGV_UPSTREAM_UNAVAILABLE', error.message, 503); + return serviceErrorResponse(c, error, 'movies'); } const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'CGV_MOVIE_SEARCH_FAILED', message, 500); @@ -237,7 +237,7 @@ export async function handleCgvGetTimetable(c: ApiContext) { ); } catch (error) { if (isCgvUpstreamUnavailableError(error)) { - return errorResponse(c, 'CGV_UPSTREAM_UNAVAILABLE', error.message, 503); + return serviceErrorResponse(c, error, 'timetable'); } const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'CGV_TIMETABLE_FETCH_FAILED', message, 500); diff --git a/src/api/healthChecks.ts b/src/api/healthChecks.ts index 6e3e6653..b18f5c8e 100644 --- a/src/api/healthChecks.ts +++ b/src/api/healthChecks.ts @@ -1,3 +1,4 @@ +import { isKnownCgvBlock, type HealthFailureDetails } from './healthFailurePolicy.js'; /** * 개별 서비스 헬스 체크 실행기 */ @@ -134,11 +135,13 @@ function aggregateStatus(checks: HealthCheckResult[]): HealthCheckStatus { return 'ok'; } -function shouldDegradeFailedResponse(check: HealthCheckDefinition, message: string): boolean { +function shouldDegradeFailedResponse(check: HealthCheckDefinition, message: string, status: number, body: HealthFailureDetails): boolean { + if (isKnownCgvBlock(check.path, status, body)) return true; return check.degradedFailurePatterns?.some((pattern) => message.includes(pattern)) ?? false; } -function shouldDegradeCliContractPath(path: string, message: string): boolean { +function shouldDegradeCliContractPath(path: string, message: string, status: number, body: HealthFailureDetails): boolean { + if (isKnownCgvBlock(path, status, body)) return true; if (path.startsWith('/api/gs25/')) { return GS25_CLOUDFRONT_403_PATTERNS.some((pattern) => message.includes(pattern)); } @@ -215,12 +218,13 @@ async function runCliContractCheck( const body = (await response.json().catch(() => ({}))) as { success?: boolean; status?: string; - error?: { message?: string }; + error?: { message?: string; code?: string }; + diagnostics?: { upstreamStatus?: number }; }; if (!response.ok || !isCliCompatibleEnvelope(path, body)) { const message = body.error?.message || `${path} CLI 계약 응답이 올바르지 않습니다.`; - if (shouldDegradeCliContractPath(path, message)) { + if (shouldDegradeCliContractPath(path, message, response.status, body)) { degradedMessages.push(`${path}: ${message}`); continue; } @@ -280,10 +284,14 @@ async function runSingleCheck( signal: AbortSignal.timeout(timeoutMs), }, ); - const body = (await response.json().catch(() => ({}))) as { + const body = (await response.json().catch(() => { + if (response.ok) throw new Error('invalid JSON response'); + return {}; + })) as { success?: boolean; data?: unknown; - error?: { message?: string }; + error?: { message?: string; code?: string }; + diagnostics?: { upstreamStatus?: number }; meta?: { total?: number }; }; const durationMs = params.now() - startedAt; @@ -294,7 +302,7 @@ async function runSingleCheck( id: check.id, service: check.service, target: check.target, - status: shouldDegradeFailedResponse(check, message) ? 'degraded' : 'fail', + status: shouldDegradeFailedResponse(check, message, response.status, body) ? 'degraded' : 'fail', durationMs, httpStatus: response.status, message, diff --git a/src/api/healthFailurePolicy.ts b/src/api/healthFailurePolicy.ts new file mode 100644 index 00000000..286bb5b0 --- /dev/null +++ b/src/api/healthFailurePolicy.ts @@ -0,0 +1,13 @@ +/** 원본 CGV403으로 확인된 운영 제한만 별도로 분류합니다. */ +export interface HealthFailureDetails { + error?: { code?: string }; + diagnostics?: { upstreamStatus?: number }; +} +export function isKnownCgvBlock(path: string, status: number, body: HealthFailureDetails): boolean { + return ( + path.startsWith('/api/cgv/') && + status === 503 && + body.error?.code === 'CGV_UPSTREAM_UNAVAILABLE' && + body.diagnostics?.upstreamStatus === 403 + ); +} diff --git a/src/api/response.ts b/src/api/response.ts index 9f9cb2ee..2062d59a 100644 --- a/src/api/response.ts +++ b/src/api/response.ts @@ -79,6 +79,7 @@ export function errorResponse( /** 원문이 제거된 외부 서비스 오류를 실제 상태와 함께 반환합니다. */ export function serviceErrorResponse(c: ApiContext, error: ServiceError, operation: string) { + if (error.retryAfter !== undefined) c.header('Retry-After', String(error.retryAfter)); return c.json>({ success: false, error: { code: error.code, message: error.message }, diff --git a/src/core/errors.ts b/src/core/errors.ts index e59b8234..0d4829e9 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -2,6 +2,8 @@ * MCP/API 공통 에러 진단 구조 */ +import type { RelayQuota } from '../utils/relayQuota.js'; + export const ZYTE_COST_POLICY_MESSAGE = 'Zyte 유료 호출은 비용 정책에 따라 비활성화되어 있습니다.'; export interface StandardErrorDiagnostics { @@ -12,20 +14,27 @@ export interface StandardErrorDiagnostics { service?: string; operation?: string; upstreamStatus?: number; + quotaReason?: RelayQuota['quotaReason']; + retryAfter?: number; hint: string; } /** 외부 응답 원문 없이 API와 MCP에 전달할 오류 진단입니다. */ export class ServiceError extends Error { + readonly quotaReason?: RelayQuota['quotaReason']; + readonly retryAfter?: number; constructor( readonly code: string, message: string, readonly status: 429 | 502 | 503 | 504, readonly retryable: boolean, readonly upstreamStatus?: number, + quota?: RelayQuota, ) { super(message); this.name = 'ServiceError'; + this.quotaReason = quota?.quotaReason; + this.retryAfter = quota?.retryAfter; } } @@ -153,6 +162,8 @@ export function toServiceErrorDiagnostics(error: ServiceError, operation: string operation, }), retryable: error.retryable, + quotaReason: error.quotaReason, + retryAfter: error.retryAfter, hint: buildHint(error.retryable), }; } diff --git a/src/middleware/dailyRateLimit.ts b/src/middleware/dailyRateLimit.ts index ca5f05b5..1ec1c041 100644 --- a/src/middleware/dailyRateLimit.ts +++ b/src/middleware/dailyRateLimit.ts @@ -64,7 +64,7 @@ export async function hashRateLimitIdentity(ip: string): Promise { return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); } -function resolveRateLimitIdentity(request: Request): string | null { +export function resolveRateLimitIdentity(request: Request): string | null { const ip = request.headers.get('CF-Connecting-IP')?.trim(); if (!ip || ip !== CROSS_ZONE_WORKER_CLIENT_IP) { return ip || null; diff --git a/src/middleware/diagnostics.ts b/src/middleware/diagnostics.ts index 4ec80b70..0fc4f01b 100644 --- a/src/middleware/diagnostics.ts +++ b/src/middleware/diagnostics.ts @@ -1,5 +1,6 @@ /** REST와 MCP의 공통 요청 경계를 기록합니다. */ import type { MiddlewareHandler } from 'hono'; +import { resolveRateLimitIdentity } from './dailyRateLimit.js'; import { withDiagnostics, diagnosticEvent, @@ -21,4 +22,4 @@ export const diagnosticsMiddleware: MiddlewareHandler = async (c, next) => durationMs: performance.now() - start, }); } - }); + }, resolveRateLimitIdentity(c.req.raw) || undefined); diff --git a/src/services/cgv/errors.ts b/src/services/cgv/errors.ts index 77c9c91c..47325f74 100644 --- a/src/services/cgv/errors.ts +++ b/src/services/cgv/errors.ts @@ -2,12 +2,18 @@ * CGV 원본 및 대체 전송 경로를 사용할 수 없는 상태 */ -import { ZYTE_COST_POLICY_MESSAGE } from '../../core/errors.js'; +import { ServiceError, ZYTE_COST_POLICY_MESSAGE } from '../../core/errors.js'; -export class CgvUpstreamUnavailableError extends Error { - constructor(readonly upstreamStatus?: number) { +export class CgvUpstreamUnavailableError extends ServiceError { + constructor(upstreamStatus?: number) { const statusSuffix = upstreamStatus === undefined ? '' : ` (HTTP ${upstreamStatus})`; - super(`CGV 원본 서비스에 연결할 수 없습니다. ${ZYTE_COST_POLICY_MESSAGE}${statusSuffix}`); + super( + 'CGV_UPSTREAM_UNAVAILABLE', + `CGV 원본 서비스에 연결할 수 없습니다. ${ZYTE_COST_POLICY_MESSAGE}${statusSuffix}`, + 503, + false, + upstreamStatus, + ); this.name = 'CgvUpstreamUnavailableError'; } } diff --git a/src/services/oliveyoung/errors.ts b/src/services/oliveyoung/errors.ts index 9d2161c3..9bbcde37 100644 --- a/src/services/oliveyoung/errors.ts +++ b/src/services/oliveyoung/errors.ts @@ -10,6 +10,7 @@ export function toOliveyoungRelayError(error: unknown): ServiceError { [429, 502, 503].includes(error.status) ? error.status as 429 | 502 | 503 : 502, error.status >= 500 || error.status === 408 || error.status === 429, error.status, + error.quota, ); } if (error instanceof Error && ['AbortError', 'TimeoutError'].includes(error.name)) { diff --git a/src/services/oliveyoung/transport.ts b/src/services/oliveyoung/transport.ts index 8157cab9..83dd3b4c 100644 --- a/src/services/oliveyoung/transport.ts +++ b/src/services/oliveyoung/transport.ts @@ -1,10 +1,13 @@ -import { diagnosticHeaders } from '../../utils/diagnostics.js'; +import { diagnosticHeaders, relayConsumer } from '../../utils/diagnostics.js'; /** 올리브영 무료 직접 요청 및 운영자가 설정한 브라우저 릴레이 전송. */ -import { fetchJson } from '../../utils/http.js'; +import { createRelayCooldown, relayCredentialScope } from '../../utils/relayQuota.js'; +import { HttpError, fetchJson } from '../../utils/http.js'; import { toOliveyoungRelayError } from './errors.js'; import { OLIVEYOUNG_API } from './api.js'; import type { OliveyoungApiResponse } from './types.js'; +const cooldown = createRelayCooldown(); + export interface OliveyoungRequestOptions { /** 기존 호출부와의 호환용이며 유료 요청에는 사용하지 않습니다. */ apiKey?: string; @@ -45,6 +48,8 @@ export async function requestOliveyoung( Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest', }; + let scope = ''; + let consumer = 'legacy'; let url = `${OLIVEYOUNG_API.BASE_URL}${path}`; if (relayUrl) { if (!isValidOliveyoungRelayUrl(relayUrl)) { @@ -54,6 +59,8 @@ export async function requestOliveyoung( url = `${relayUrl.replace(/\/$/, '')}/v1/oliveyoung/${path.split('/').pop()}`; Object.assign(headers, diagnosticHeaders()); headers.Authorization = `Bearer ${relayToken}`; + consumer = (await relayConsumer(relayToken)) || 'legacy'; + if (consumer !== 'legacy') headers['x-relay-consumer'] = consumer; const { accessClientId, accessClientSecret } = options; if (accessClientId !== undefined || accessClientSecret !== undefined) { if (!accessClientId?.trim() || !accessClientSecret?.trim()) { @@ -70,6 +77,13 @@ export async function requestOliveyoung( headers.Referer = `${OLIVEYOUNG_API.BASE_URL}/`; headers['Accept-Language'] = 'ko-KR,ko;q=0.9'; } + 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, { @@ -83,7 +97,10 @@ export async function requestOliveyoung( expectedStatus: 200, }); } catch (error) { - if (relayUrl) throw toOliveyoungRelayError(error); + if (relayUrl) { + if (error instanceof HttpError && error.quota) cooldown.set(scope, consumer, error.quota); + throw toOliveyoungRelayError(error); + } if (error instanceof Error && error.name === 'AbortError') { throw new Error('올리브영 API 요청 시간 초과'); } diff --git a/src/utils/diagnostics.ts b/src/utils/diagnostics.ts index 8dec3490..fd6b08d3 100644 --- a/src/utils/diagnostics.ts +++ b/src/utils/diagnostics.ts @@ -4,6 +4,7 @@ interface DiagnosticContext { requestId: string; sampled: boolean; events: number; + consumerIdentity?: string; } const context = new AsyncLocalStorage(); export interface DiagnosticEvent { @@ -13,10 +14,10 @@ export interface DiagnosticEvent { status?: number; durationMs?: number; } -export function withDiagnostics(work: () => T): T { +export function withDiagnostics(work: () => T, consumerIdentity?: string): T { if (context.getStore()) return work(); return context.run( - { requestId: crypto.randomUUID(), sampled: Math.random() < 0.01, events: 0 }, + { requestId: crypto.randomUUID(), sampled: Math.random() < 0.01, events: 0, consumerIdentity }, work, ); } @@ -88,3 +89,15 @@ export function diagnosticOperation(url: string): string { return `${parts[2]}.${parts[3]}`; return 'other'; } + +/** 릴레이 전용 일별 익명 식별자이며 로그나 공개 응답에는 포함하지 않습니다. */ +export async function relayConsumer(token: string): Promise { + const identity = context.getStore()?.consumerIdentity; + if (!identity) return undefined; + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey('raw', encoder.encode(token), + { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + const signature = await crypto.subtle.sign('HMAC', key, + encoder.encode(`${Math.floor(Date.now() / 86400000)}:${identity}`)); + return Array.from(new Uint8Array(signature), byte => byte.toString(16).padStart(2, '0')).join(''); +} diff --git a/src/utils/http.ts b/src/utils/http.ts index 6b8e9f4d..715d6cf5 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -1,3 +1,4 @@ +import { parseRelayQuota, type RelayQuota } from './relayQuota.js'; import { diagnosticEvent } from './diagnostics.js'; /** * 공용 HTTP 유틸리티 @@ -27,11 +28,12 @@ export interface FetchRetryEvent { } export class HttpError extends Error { + readonly quota?: RelayQuota; readonly status: number; readonly statusText: string; readonly bodyText: string; - constructor(status: number, statusText: string, bodyText: string) { + constructor(status: number, statusText: string, bodyText: string, headers?: Headers) { const normalizedBody = bodyText.trim().replace(/\s+/g, ' ').slice(0, 300); const detail = normalizedBody.length > 0 ? ` - ${normalizedBody}` : ''; super(`API 요청 실패: ${status} ${statusText}${detail}`); @@ -39,6 +41,7 @@ export class HttpError extends Error { this.status = status; this.statusText = statusText; this.bodyText = bodyText; + this.quota = parseRelayQuota(status, headers); } } @@ -207,7 +210,7 @@ export async function fetchJson(url: string, options: FetchOptions = {}): Pro const { response, body } = await readTextResponse(url, options); if (!response.ok || (options.expectedStatus !== undefined && response.status !== options.expectedStatus)) { - throw new HttpError(response.status, response.statusText, body); + throw new HttpError(response.status, response.statusText, body, response.headers); } if (/^\s*(?: 86400) return undefined; + return { quotaReason: reason, retryAfter: Number(retry) }; +} + +/** 만료 후 정리하고 고정 크기에서 새 휴지 기록을 생략합니다. */ +export function createRelayCooldown(now = () => Date.now(), maxEntries = 1024) { + const entries = new Map(); + const prune = () => { + for (const [key, entry] of entries) if (entry.until <= now()) entries.delete(key); + }; + return { + get(scope: string, consumer: string): RelayQuota | undefined { + prune(); + const entry = entries.get(scope) || entries.get(`${scope}:${consumer}`); + if (!entry) return undefined; + return { quotaReason: entry.quotaReason, retryAfter: Math.ceil((entry.until - now()) / 1000) }; + }, + set(scope: string, consumer: string, quota: RelayQuota) { + prune(); + const key = quota.quotaReason === 'consumer' ? `${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; + entries.set(key, { quotaReason: quota.quotaReason, until }); + }, + }; +} + +/** 자격증명 원문을 저장하지 않는 릴레이/Access 구성별 키. */ +export async function relayCredentialScope(values: Array): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(values))); + return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); +} diff --git a/tests/api/health-cgv-classification.test.ts b/tests/api/health-cgv-classification.test.ts new file mode 100644 index 00000000..c05239bb --- /dev/null +++ b/tests/api/health-cgv-classification.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from 'vitest'; +import { runHealthChecks } from '../../src/api/healthChecks.js'; +import { ServiceError, toServiceErrorDiagnostics } from '../../src/core/errors.js'; +import { CgvUpstreamUnavailableError } from '../../src/services/cgv/errors.js'; +const blocked = (status = 403, code = 'CGV_UPSTREAM_UNAVAILABLE') => + Response.json( + { + success: false, + error: { code, message: 'CGV unavailable' }, + diagnostics: { upstreamStatus: status }, + }, + { status: 503 }, + ); +describe('CGV 원본 차단만 구분', () => { + it('HTTP200이더라도 JSON 파싱 실패는 fail로 처리한다', async () => { + const result = await runHealthChecks({ + baseUrl: 'https://test', + service: 'cgv', + fresh: true, + fetchImpl: vi.fn(async () => new Response('not json')), + }); + expect(result.status).toBe('fail'); + expect(result.checks[0].message).toContain('invalid JSON'); + }); + it('CGV 오류가 원본상태를 구조화 진단으로 보존한다', () => { + const error = new CgvUpstreamUnavailableError(403); + expect(error).toBeInstanceOf(ServiceError); + expect(toServiceErrorDiagnostics(error as ServiceError, 'theaters')).toMatchObject({ + code: 'CGV_UPSTREAM_UNAVAILABLE', + upstreamStatus: 403, + }); + }); + it('확인된403은degraded지만401/다른코드/시간초과는fail이다', async () => { + for (const [upstream, code, expected] of [ + [403, 'CGV_UPSTREAM_UNAVAILABLE', 'degraded'], + [401, 'CGV_UPSTREAM_UNAVAILABLE', 'fail'], + [403, 'OTHER', 'fail'], + ] as const) { + const r = await runHealthChecks({ + baseUrl: 'https://test', + service: 'cgv', + fresh: true, + fetchImpl: vi.fn(async () => blocked(upstream, code)), + }); + expect(r.status).toBe(expected); + } + const result = await runHealthChecks({ + baseUrl: 'https://test', + service: 'cgv', + fresh: true, + fetchImpl: vi.fn(async () => { + throw Error('timeout'); + }), + }); + expect(result.status).toBe('fail'); + }); + it('CLI 계약 검사도 CGV에만 동일한403정책을 적용한다', async () => { + const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { + const path = new URL(String(input)).pathname; + if (path.startsWith('/api/cgv/')) return blocked(); + return Response.json(path === '/health' ? { status: 'ok' } : { success: true, data: {} }); + }); + const result = await runHealthChecks({ + baseUrl: 'https://test', + check: 'cli.contract', + mode: 'full', + fresh: true, + fetchImpl, + }); + expect(result.status).toBe('degraded'); + fetchImpl.mockImplementation(async () => blocked()); + const unrelated = await runHealthChecks({ + baseUrl: 'https://test', + service: 'oliveyoung', + fresh: true, + fetchImpl, + }); + expect(unrelated.status).toBe('fail'); + }); +}); diff --git a/tests/api/relay-quota.test.ts b/tests/api/relay-quota.test.ts new file mode 100644 index 00000000..67f179c9 --- /dev/null +++ b/tests/api/relay-quota.test.ts @@ -0,0 +1,28 @@ +import { Hono } from 'hono'; +import { expect, it, vi, afterEach } from 'vitest'; +import { diagnosticsMiddleware } from '../../src/middleware/diagnostics.js'; +import { serviceErrorResponse } from '../../src/api/response.js'; +import type { AppBindings } from '../../src/api/response.js'; +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 () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const fetch = vi.fn().mockResolvedValue(new Response('', { status: 429, headers: { 'retry-after': '12', 'x-relay-quota-reason': 'consumer' } })); + 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' }); } + 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 } }); + 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/app/app-health-checks.test.ts b/tests/app/app-health-checks.test.ts index daa54cb3..8f4e0d45 100644 --- a/tests/app/app-health-checks.test.ts +++ b/tests/app/app-health-checks.test.ts @@ -732,7 +732,7 @@ it('인증된 내부 health도 원본 차단 후 Zyte를 호출하지 않는다' ); expect(response.status).toBe(200); const data = await response.json() as { checks: Array<{ status: string; message: string }> }; - expect(data.checks[0].status).toBe('fail'); + expect(data.checks[0].status).toBe('degraded'); expect(data.checks[0].message).toContain('비용 정책'); expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch.mock.calls.every(([url]) => new URL(String(url)).hostname !== 'api.zyte.com')).toBe(true); diff --git a/tests/relay/cache.test.ts b/tests/relay/cache.test.ts index e98c8f8a..69bd9c8b 100644 --- a/tests/relay/cache.test.ts +++ b/tests/relay/cache.test.ts @@ -26,3 +26,16 @@ it('기본 캐시도 성공 문자열을 보관한다', () => { cache.set('key', '{}', 1000); expect(cache.get('key')).toBe('{}'); }); +it('키를 노출하지 않고 miss와 만료·축출 누계를 집계한다', () => { + let now = 0; + const cache = createResponseCache(1, 100, () => now); + cache.get('cold'); + cache.set('a', '{}', 10); + cache.get('a'); + cache.set('b', '{}', 10); + cache.get('a'); + now = 10; + cache.get('b'); + expect(cache.stats()).toMatchObject({ hits: 1, misses: 3, expired: 1, evicted: 1 }); + expect(JSON.stringify(cache.stats())).not.toContain('cold'); +}); diff --git a/tests/relay/consumer.test.ts b/tests/relay/consumer.test.ts new file mode 100644 index 00000000..991bb883 --- /dev/null +++ b/tests/relay/consumer.test.ts @@ -0,0 +1,40 @@ +import { expect, it } from 'vitest'; +import { createConsumerQuota } from '../../scripts/relay/consumer.js'; +it('동시 요청과 분당 신규 작업을 제한하고 만료 예산만 정리한다', () => { + let now = 0; + const quota = createConsumerQuota(() => now, 2); + const a = quota.enter('a')!; + const take = () => { const reservation = a.reserve(); if (!reservation) return false; reservation.commit(); reservation.release(); return true; }; + const b = quota.enter('a')!; + expect(quota.enter('a')).toBeNull(); + expect(take()).toBe(true); + b.release(); + for (let i = 1; i < 12; i++) expect(take()).toBe(true); + expect(take()).toBe(false); + expect(quota.retryAfter()).toBe(60); + const other = quota.enter('b')!; + other.release(); + expect(quota.enter('c')).toBeNull(); + now = 60000; + expect(quota.enter('c')).not.toBeNull(); + expect(take()).toBe(true); + a.release(); + now = 120000; + expect(quota.enter('d')).not.toBeNull(); +}); +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(); } + const queued = admission.reserve()!; + expect(admission.reserve()).toBeNull(); + now = 60000; + queued.commit(); + queued.release(); + const canceled = admission.reserve()!; + canceled.release(); + for (let i = 0; i < 11; 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 new file mode 100644 index 00000000..b9db763f --- /dev/null +++ b/tests/relay/fairness.test.ts @@ -0,0 +1,92 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { createOliveyoungRelay } from '../../scripts/relay/oliveyoung.js'; +const request = (goodsNo: string, consumer = 'a'.repeat(64)) => new Request('http://localhost/v1/oliveyoung/stock-goods-info-v3', { + method: 'POST', headers: { authorization: 'Bearer test', 'x-relay-consumer': consumer }, body: JSON.stringify({ goodsNo }), +}); +afterEach(() => vi.restoreAllMocks()); +it('한 소비자의 느린 요청이 공용 슬롯을 독점하지 못한다', async () => { + let finish!: (value: { status: string }) => void; + const run = vi.fn().mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })).mockResolvedValue({ status: 'SUCCESS' }); + const relay = createOliveyoungRelay('test', run); + const first = relay(request('1')); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1)); + 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'); + 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]); +}); +it('캐시와 공유 작업은 분당 예산을 소비하지 않으며 다음 분에 복구한다', async () => { + 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); + 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); + vi.spyOn(Date, 'now').mockReturnValue(60000); + expect((await relay(request('13'))).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); + expect((await relay(request('next'))).status).toBe(200); +}); +it('병합된 요청은 두 번째 소비자의 분당 예산을 차감하지 않는다', async () => { + let finish!: (value: { status: string }) => void; + const run = vi.fn().mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })).mockResolvedValue({ status: 'SUCCESS' }); + const relay = createOliveyoungRelay('test', run); + const first = relay(request('shared')); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1)); + const second = relay(request('shared', 'b'.repeat(64))); + await new Promise(resolve => setTimeout(resolve, 0)); + 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); +}); +it('본문 업로드부터 소비자별 두 슬롯을 적용하며 잘못된 식별자는 legacy를 공유한다', async () => { + const run = vi.fn().mockResolvedValue({ status: 'SUCCESS' }); + const relay = createOliveyoungRelay('test', run); + const streams: ReadableStreamDefaultController[] = []; + const pending = ['invalid-one', 'invalid-two'].map(consumer => relay(new Request('http://localhost/v1/oliveyoung/stock-goods-info-v3', { + method: 'POST', headers: { authorization: 'Bearer test', 'x-relay-consumer': consumer }, + body: new ReadableStream({ start(controller) { streams.push(controller); } }), duplex: 'half', + } as RequestInit & { duplex: 'half' }))); + expect((await relay(request('third', ''))).status).toBe(429); + expect(run).not.toHaveBeenCalled(); + for (const stream of streams) { stream.enqueue(new TextEncoder().encode('{"goodsNo":"shared"}')); stream.close(); } + expect((await Promise.all(pending)).every(response => response.status === 200)).toBe(true); +}); +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}`)); + 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)); + const exhausted = relay(request('shared')); + const fresh = relay(request('shared', 'b'.repeat(64))); + await new Promise(resolve => setTimeout(resolve, 0)); + finish({ status: 'SUCCESS' }); + expect((await blocker).status).toBe(200); + expect((await exhausted).status).toBe(429); + expect((await fresh).status).toBe(200); +}); +it('글로벌 예산 거절과 확인 오류는 소비자 사용량을 차감하지 않는다', async () => { + 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); + 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); + expect((await relay(request('exhausted'))).status).toBe(429); + expect(run).toHaveBeenCalledTimes(12); +}); diff --git a/tests/relay/oliveyoung.test.ts b/tests/relay/oliveyoung.test.ts index d8c5254e..92aa8232 100644 --- a/tests/relay/oliveyoung.test.ts +++ b/tests/relay/oliveyoung.test.ts @@ -1,10 +1,12 @@ import { expect, it, vi } from 'vitest'; import { createOliveyoungRelay } from '../../scripts/relay/oliveyoung.js'; +let consumerSequence = 0; +const consumerHeaders = () => ({ 'x-relay-consumer': (++consumerSequence).toString(16).padStart(64, '0') }); const payload = { goodsNo: 'A1' }; const request = (path = 'stock-goods-info-v3', body = JSON.stringify(payload), token = 'test') => new Request(`http://localhost/v1/oliveyoung/${path}`, { method: 'POST', - headers: { Authorization: `Bearer ${token}` }, + headers: { Authorization: `Bearer ${token}`, ...consumerHeaders() }, body, }); it('인증 실패 시 브라우저를 호출하지 않는다', async () => { @@ -79,7 +81,7 @@ it('누락 인증, 빈 토큰, 잘못된 메서드와 요청 형태를 거절한 ( await relay( new Request('http://localhost/v1/oliveyoung/find-store', { - headers: { Authorization: 'Bearer test' }, + headers: { Authorization: 'Bearer test', ...consumerHeaders() }, }), ) ).status, @@ -89,7 +91,7 @@ it('누락 인증, 빈 토큰, 잘못된 메서드와 요청 형태를 거절한 await relay( new Request('http://localhost/v1/oliveyoung/find-store', { method: 'POST', - headers: { Authorization: 'Bearer test' }, + headers: { Authorization: 'Bearer test', ...consumerHeaders() }, }), ) ).status, @@ -121,7 +123,7 @@ it('본문을 읽는 중인 요청도 슬롯 상한에 포함한다', async () = relay( new Request('http://localhost/v1/oliveyoung/stock-goods-info-v3', { method: 'POST', - headers: { Authorization: 'Bearer test' }, + headers: { Authorization: 'Bearer test', ...consumerHeaders() }, body: new ReadableStream({ start(controller) { streams.push(controller); diff --git a/tests/scripts/repository-config.test.ts b/tests/scripts/repository-config.test.ts index 9e36c4f5..0a67d907 100644 --- a/tests/scripts/repository-config.test.ts +++ b/tests/scripts/repository-config.test.ts @@ -224,9 +224,9 @@ describe('repository maintenance configuration', () => { expect(workflow).toContain( '/api/health/checks?mode=full&fresh=true&includeSamples=true&timeoutMs=20000&slowThresholdMs=9000', ); - expect(workflow).toContain('/api/health/checks?mode=quick&timeoutMs=5000&slowThresholdMs=3000'); + expect(workflow).toContain('/api/health/checks?mode=quick&fresh=true&timeoutMs=5000&slowThresholdMs=3000'); expect(workflow).toContain('HEALTH_CHECK_FORCE_FRESH="true"'); - expect(workflow).toContain('HEALTH_CHECK_FORCE_FRESH="false"'); + expect(workflow).not.toContain('HEALTH_CHECK_FORCE_FRESH="false"'); expect(workflow).toContain('x-health-check-force-fresh: ${HEALTH_CHECK_FORCE_FRESH}'); expect(workflow).toContain('failedChecks'); expect(workflow).toContain('degradedChecks'); diff --git a/tests/services/oliveyoung/cooldown.test.ts b/tests/services/oliveyoung/cooldown.test.ts new file mode 100644 index 00000000..164bb18f --- /dev/null +++ b/tests/services/oliveyoung/cooldown.test.ts @@ -0,0 +1,51 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { fetchOliveyoungProducts, __testOnlyClearOliveyoungCaches } from '../../../src/services/oliveyoung/client.js'; +import { requestOliveyoung } from '../../../src/services/oliveyoung/transport.js'; +import { withDiagnostics } from '../../../src/utils/diagnostics.js'; +import { toServiceErrorDiagnostics, ServiceError } from '../../../src/core/errors.js'; +afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); +it.each(['minute', 'daily', 'consumer'])('검증된 %s 429만 범위에 맞게 휴지하고 진단을 보존한다', async reason => { + vi.spyOn(Date, 'now').mockReturnValue(0); + const options = { relayUrl: `https://${reason}.example`, relayToken: 'test' }; + const fetch = vi.fn().mockImplementation(() => Promise.resolve(new Response('', { status: 429, headers: { 'retry-after': '10', 'x-relay-quota-reason': reason } }))); + vi.stubGlobal('fetch', fetch); + const call = (identity: string) => withDiagnostics(() => requestOliveyoung('/p', {}, options), identity).catch(e => e); + const error = await call('192.0.2.1'); + expect(error).toMatchObject({ status: 429, retryAfter: 10, quotaReason: reason }); + expect(toServiceErrorDiagnostics(error as ServiceError, 'test')).toMatchObject({ retryAfter: 10, quotaReason: reason }); + await call('192.0.2.1'); + expect(fetch).toHaveBeenCalledTimes(1); + await call('192.0.2.2'); + expect(fetch).toHaveBeenCalledTimes(reason === 'consumer' ? 2 : 1); + vi.spyOn(Date, 'now').mockReturnValue(10000); + await call('192.0.2.1'); + expect(fetch).toHaveBeenCalledTimes(reason === 'consumer' ? 3 : 2); +}); +it.each([[403, '10', 'minute'], [503, '10', 'daily'], [429, '0', 'minute'], [429, '86401', 'minute'], [429, '1evil', 'minute'], [429, '10', 'unknown']])('잘못된 헤더나 상태는 휴지하지 않는다 %s/%s/%s', async (status, retry, reason) => { + const options = { relayUrl: `https://invalid-${status}-${retry}-${reason}.example`, relayToken: 'test' }; + const fetch = vi.fn().mockImplementation(() => Promise.resolve(new Response('', { status: Number(status), headers: { 'retry-after': String(retry), 'x-relay-quota-reason': String(reason) } }))); + vi.stubGlobal('fetch', fetch); + for (let i = 0; i < 2; i++) await expect(requestOliveyoung('/p', {}, options)).rejects.toMatchObject({ retryAfter: undefined }); + expect(fetch).toHaveBeenCalledTimes(2); +}); +it('서로 다른 릴레이 자격증명은 글로벌 휴지를 공유하지 않는다', async () => { + const options = { relayUrl: 'https://credentials.example', relayToken: 'first' }; + const fetch = vi.fn().mockImplementation(() => Promise.resolve(new Response('', { status: 429, headers: { 'retry-after': '10', 'x-relay-quota-reason': 'daily' } }))); + vi.stubGlobal('fetch', fetch); + for (const relayToken of ['first', 'second', 'first']) await requestOliveyoung('/p', {}, { ...options, relayToken }).catch(() => undefined); + expect(fetch).toHaveBeenCalledTimes(2); +}); + +it('성공 캐시는 휴지 중에도 반환하고 새 요청을 릴레이로 보내지 않는다', async () => { + __testOnlyClearOliveyoungCaches(); + const options = { relayUrl: 'https://cached-cooldown.example', relayToken: 'test' }; + const params = { keyword: 'cached', page: 1, size: 10, sort: '', includeSoldOut: false }; + const fetch = vi.fn().mockResolvedValueOnce(Response.json({ status: 'SUCCESS', data: { searchList: [], totalCount: 7 } })) + .mockResolvedValueOnce(new Response('', { status: 429, headers: { 'retry-after': '10', 'x-relay-quota-reason': 'minute' } })); + vi.stubGlobal('fetch', fetch); + const success = await fetchOliveyoungProducts(params, options); + await expect(requestOliveyoung('/different', {}, options)).rejects.toMatchObject({ status: 429 }); + expect(await fetchOliveyoungProducts(params, options)).toEqual(success); + expect(fetch).toHaveBeenCalledTimes(2); + __testOnlyClearOliveyoungCaches(); +}); diff --git a/tests/services/oliveyoung/relayErrors.test.ts b/tests/services/oliveyoung/relayErrors.test.ts index 558126e9..a8000a72 100644 --- a/tests/services/oliveyoung/relayErrors.test.ts +++ b/tests/services/oliveyoung/relayErrors.test.ts @@ -63,6 +63,7 @@ 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()); await vi.advanceTimersByTimeAsync(10); await assertion; } finally { diff --git a/tests/utils/relayConsumer.test.ts b/tests/utils/relayConsumer.test.ts new file mode 100644 index 00000000..de79d89b --- /dev/null +++ b/tests/utils/relayConsumer.test.ts @@ -0,0 +1,29 @@ +import { expect, it, vi, afterEach } from 'vitest'; +import { withDiagnostics, relayConsumer } from '../../src/utils/diagnostics.js'; +import { resolveRateLimitIdentity } from '../../src/middleware/dailyRateLimit.js'; +afterEach(() => vi.restoreAllMocks()); +it('컨텍스트가 없으면 legacy를 쓰고 신뢰하는 요청 identity만 일별 HMAC으로 보낸다', async () => { + vi.spyOn(Date, 'now').mockReturnValue(0); + expect(await relayConsumer('token')).toBeUndefined(); + const first = await withDiagnostics(() => relayConsumer('token'), '192.0.2.1'); + expect(first).toMatch(/^[a-f0-9]{64}$/); + expect(await withDiagnostics(() => relayConsumer('token'), '192.0.2.1')).toBe(first); + expect(await withDiagnostics(() => relayConsumer('other'), '192.0.2.1')).not.toBe(first); + expect(await withDiagnostics(() => relayConsumer('token'), '192.0.2.2')).not.toBe(first); + vi.spyOn(Date, 'now').mockReturnValue(86400000); + expect(await withDiagnostics(() => relayConsumer('token'), '192.0.2.1')).not.toBe(first); +}); +it('교차 존 Worker identity 정규화를 재사용한다', () => { + expect(resolveRateLimitIdentity(new Request('https://example.com', { headers: { + 'CF-Connecting-IP': '2a06:98c0:3600::103', 'CF-Worker': ' EXAMPLE.COM ', 'x-relay-consumer': 'spoof', + } }))).toBe('worker-zone:example.com'); +}); +it('비동기 요청이 겹쳐도 identity를 섞지 않는다', async () => { + const expected = await withDiagnostics(() => relayConsumer('token'), '192.0.2.1'); + const results = await Promise.all([ + withDiagnostics(async () => { await new Promise(resolve => setTimeout(resolve, 10)); return relayConsumer('token'); }, '192.0.2.1'), + withDiagnostics(() => relayConsumer('token'), '192.0.2.2'), + ]); + expect(results[0]).toBe(expected); + expect(results[1]).not.toBe(expected); +}); diff --git a/tests/utils/relayQuota.test.ts b/tests/utils/relayQuota.test.ts new file mode 100644 index 00000000..ca5d5cdf --- /dev/null +++ b/tests/utils/relayQuota.test.ts @@ -0,0 +1,22 @@ +import { expect, it } from 'vitest'; +import { createRelayCooldown, parseRelayQuota } from '../../src/utils/relayQuota.js'; +it('헤더 없는 응답이나 누락된 재시도 값을 거부한다', () => { + expect(parseRelayQuota(429)).toBeUndefined(); + expect(parseRelayQuota(429, new Headers({ 'x-relay-quota-reason': 'minute' }))).toBeUndefined(); +}); +it('휴지 표는 용량을 넘지 않고 더 짧은 휴지로 기존 시간을 줄이지 않는다', () => { + let now = 0; + const cooldown = createRelayCooldown(() => now, 1); + cooldown.set('scope', 'a', { quotaReason: 'consumer', retryAfter: 10 }); + cooldown.set('other', 'b', { quotaReason: 'daily', retryAfter: 10 }); + expect(cooldown.get('other', 'b')).toBeUndefined(); + expect(cooldown.get('scope', 'b')).toBeUndefined(); + cooldown.set('scope', 'a', { quotaReason: 'consumer', retryAfter: 5 }); + expect(cooldown.get('scope', 'a')).toEqual({ quotaReason: 'consumer', retryAfter: 10 }); + cooldown.set('scope', 'a', { quotaReason: 'consumer', retryAfter: 11 }); + now = 1000; + expect(cooldown.get('scope', 'a')?.retryAfter).toBe(10); + now = 11000; + cooldown.set('other', 'b', { quotaReason: 'minute', retryAfter: 10 }); + expect(cooldown.get('other', 'a')?.retryAfter).toBe(10); +}); diff --git a/tests/workflows/health-runner.test.ts b/tests/workflows/health-runner.test.ts new file mode 100644 index 00000000..d23fa97f --- /dev/null +++ b/tests/workflows/health-runner.test.ts @@ -0,0 +1,88 @@ +import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { afterEach, describe, it, expect } from 'vitest'; +const dirs: string[] = []; +const workflow = readFileSync('.github/workflows/health-checks.yml', 'utf8'); +const script = workflow + .split(' run: |\n')[1] + .split('\n - name:')[0] + .split('\n') + .map((l) => (l.startsWith(' ') ? l.slice(10) : l)) + .join('\n'); +const payload = JSON.stringify({ + status: 'ok', + cached: false, + checks: [{ id: 'test.products', status: 'ok', message: '1 item' }], +}); +function execute(curl: string, stale = false) { + const dir = mkdtempSync(join(tmpdir(), 'health-runner-')); + dirs.push(dir); + mkdirSync(join(dir, 'bin')); + writeFileSync(join(dir, 'bin/curl'), '#!/bin/bash\n' + curl, { mode: 0o700 }); + writeFileSync(join(dir, 'bin/sleep'), '#!/bin/bash\nexit 0\n', { mode: 0o700 }); + if (stale) writeFileSync(join(dir, 'health-checks.json'), payload); + const result = spawnSync('/bin/bash', ['-e', '-c', script], { + cwd: dir, + encoding: 'utf8', + env: { + ...process.env, + PATH: join(dir, 'bin') + ':' + process.env.PATH, + HEALTH_CHECK_SECRET: 'test-only', + HEALTH_CHECK_URL: 'https://test.invalid', + GITHUB_EVENT_NAME: 'schedule', + HEALTH_CHECK_SCHEDULE: 'quick', + GITHUB_STEP_SUMMARY: join(dir, 'summary'), + GITHUB_SERVER_URL: 'https://github.com', + GITHUB_REPOSITORY: 'owner/repo', + GITHUB_RUN_ID: '1', + }, + timeout: 5000, + }); + return { dir, result }; +} +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); +describe('실제 Health workflow shell', () => { + it('선택 검사가 skipped인 정상 결과를 허용한다', () => { + const body = JSON.stringify({ + status: 'ok', + checks: [{ id: 'optional', status: 'skipped', message: 'empty' }], + }); + const { result } = execute(`printf '%s' '${body}' > health-checks.json`); + expect(result.status, result.stderr).toBe(0); + }); + it('curl실패시 예전 성공파일을 재사용하지 않는다', () => { + const { result } = execute('exit 7', true); + expect(result.status).not.toBe(0); + }); + it.each([ + '{}', + 'null', + 'broken', + JSON.stringify({ status: 'ok', checks: [] }), + JSON.stringify({ status: 'ok', checks: [{ id: 'x', status: 'fail', message: 'failed' }] }), + JSON.stringify({ + status: 'ok', + checks: [ + { id: 'x', status: 'ok', message: 'ok' }, + { id: 'x', status: 'ok', message: 'ok' }, + ], + }), + ])('잘못된 응답을 실패로 처리한다: %s', (body) => { + const { dir, result } = execute(`printf '%s' '${body}' > health-checks.json`); + expect(result.status).not.toBe(0); + expect(readFileSync(join(dir, 'health-check-summary.txt'), 'utf8')).toContain('invalid'); + }); + it('매 요청을 fresh로 검사하고 재시도 원본을 보존한다', () => { + const { dir, result } = execute( + `printf '%s\\n' "$@" >> calls.txt\nif ! [[ "$*" == *fresh=true* && "$*" == *"x-health-check-force-fresh: true"* ]]; then exit 22; fi\nif [ ! -e first ]; then touch first; printf '%s' '${payload.replace('"status":"ok"', '"status":"fail"').replace('"status":"ok"', '"status":"fail"')}' > health-checks.json; else printf '%s' '${payload}' > health-checks.json; fi`, + ); + expect(result.status, result.stderr + result.stdout).toBe(0); + expect(readFileSync(join(dir, 'health-checks-attempt-1.json'), 'utf8')).toContain('fail'); + expect(readFileSync(join(dir, 'health-checks-attempt-2.json'), 'utf8')).toContain('ok'); + expect(readFileSync(join(dir, 'calls.txt'), 'utf8')).toContain('cacheBust=true'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 8727f413..32d1e668 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,7 +30,7 @@ export default defineConfig({ reporter: ['text', 'text-summary', 'html', 'json'], // 커버리지 대상 파일 - include: ['src/**/*.ts', 'scripts/relay/cache.ts', 'scripts/relay/logging.ts', 'scripts/relay/observed.ts', 'scripts/relay/oliveyoung.ts', 'scripts/relay/dtryx.ts', 'scripts/relay/browser.ts', 'scripts/relay/lifecycle.ts', 'scripts/relay/ownership.ts', 'scripts/relay/guard-controller.ts', 'scripts/relay/guard-runtime.ts', 'scripts/relay/supervisor.ts', 'scripts/relay/quota.ts', 'scripts/relay/browser-environment.ts'], + include: ['src/**/*.ts', 'scripts/relay/cache.ts', 'scripts/relay/logging.ts', 'scripts/relay/observed.ts', 'scripts/relay/oliveyoung.ts', 'scripts/relay/dtryx.ts', 'scripts/relay/browser.ts', 'scripts/relay/lifecycle.ts', 'scripts/relay/ownership.ts', 'scripts/relay/guard-controller.ts', 'scripts/relay/guard-runtime.ts', 'scripts/relay/supervisor.ts', 'scripts/relay/quota.ts', 'scripts/relay/consumer.ts', 'scripts/relay/browser-environment.ts'], // 커버리지 제외 파일 exclude: [