8000 fix: 릴레이 요청 집중과 헬스체크 재검사 개선 by hmmhmmhm · Pull Request #204 · hmmhmmhm/daiso-mcp · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 44 additions & 5 deletions .github/workflows/health-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ concurrency:
jobs:
health-checks:
runs-on: ubuntu-latest
timeout-minutes: 10
name: Service health checks
steps:
- name: Run health checks
Expand All @@ -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) => {
Expand All @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions docs/relay-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 재시도는 활성화하지 않는다.
12 changes: 12 additions & 0 deletions docs/superpowers/plans/2026-09-29-relay-fairness-health.md
Original file line number Diff line number Diff line change
@@ -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실행결과확인. 로그/디스크상한유지·운영기록.
Original file line number Diff line number Diff line change
@@ -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사용안함.
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "daiso",
"version": "1.2.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": {
Expand Down
10BC8
9 changes: 6 additions & 3 deletions scripts/relay/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,21 @@ export function canonicalKey(operation: string, body: Record<string, unknown>):
export function createResponseCache(maxEntries = 256, maxBytes = 16 * 1024 * 1024, now = Date.now) {
const entries = new Map<string, { value: string; bytes: number; expires: number }>();
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;
Expand All @@ -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 };
},
};
}
48 changes: 48 additions & 0 deletions scripts/relay/consumer.ts
10BC8
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/** 만료된 예산만 정리하는 소비자별 본문·대기 슬롯 및 신규 작업 상한. */
export function createConsumerQuota(now = Date.now, maxEntries = 1024) {
const entries = new Map<string, { minute: number; count: number; active: number; reserved: number }>();
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--; },
};
},
};
}
15 changes: 15 additions & 0 deletions scripts/relay/oliveyoung.ts
8
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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<string, number> = {
'find-store': 300000,
'product-search-v3': 300000,
Expand Down Expand Up @@ -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<string, unknown>;
Expand Down Expand Up @@ -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<Result> => {
Expand All @@ -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]);
Expand All @@ -201,6 +214,7 @@ export function createOliveyoungRelay(
})
.finally(() => {
pending.delete(key);
reservation.release();
});
entry = { task, signals, startedAt: 0, requestId };
pending.set(key, entry);
Expand Down Expand Up @@ -238,6 +252,7 @@ export function createOliveyoungRelay(
}
} finally {
outstanding--;
admission.release();
}
};
}
Loading
Loading
0