8000 ci: 자체 분당 제한에서 실패한 검사만 재확인 by hmmhmmhm · Pull Request #252 · 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
39 changes: 29 additions & 10 deletions .github/workflows/health-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ jobs:
timeout-minutes: 10
name: Service health checks
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '24'
- name: Run health checks
env:
HEALTH_CHECK_SECRET: ${{ secrets.HEALTH_CHECK_SECRET }}
Expand Down Expand Up @@ -55,25 +59,28 @@ jobs:
fi
cp health-checks.json "health-checks-attempt-${HEALTH_CHECK_ATTEMPT}.json" || return 1

node <<'NODE'
HEALTH_CHECK_ENDPOINT="${HEALTH_CHECK_ENDPOINT}" node <<'NODE'
const fs = require('node:fs');
(async () => {
const { isValidHealthPayload, retryMinuteChecks } = await import('./scripts/ops/health-check-retry.ts');
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');
const valid = isValidHealthPayload(payload);
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 firstFailures = payload.checks.filter(check => check.status === 'fail');
fs.writeFileSync('health-check-summary-first.txt', JSON.stringify({ status: payload.status, failedChecks: firstFailures }, null, 2));
const recovery = await retryMinuteChecks(payload, { endpoint: process.env.HEALTH_CHECK_ENDPOINT, secret: process.env.HEALTH_CHECK_SECRET });
for (const attempt of recovery.attempts) {
fs.writeFileSync(`health-checks-recheck-${attempt.id}.json`, JSON.stringify(attempt, null, 2));
}
payload = recovery.payload;
fs.writeFileSync('health-checks.json', JSON.stringify(payload, null, 2));
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 @@ -89,6 +96,9 @@ jobs:
`degraded=${degradedChecks.length}`,
`failedIds=${failedChecks.map((check) => check.id).join(',') || 'none'}`,
`degradedIds=${degradedChecks.map((check) => check.id).join(',') || 'none'}`,
`firstFailedIds=${firstFailures.map(check => check.id).join(',') || 'none'}`,
`retryCount=${recovery.attempts.length}`,
`recheckedIds=${recovery.attempts.map(attempt => attempt.id).join(',') || 'none'}`,
`run=${runUrl}`,
lines.join(' | '),
].filter(Boolean).join(' ');
Expand All @@ -99,11 +109,17 @@ jobs:
process.exitCode = 1;
}
}
})().catch(() => {
const summary = 'Health check retry runner failed; original attempts remain available';
fs.writeFileSync('health-check-summary.txt', summary);
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary + '\n');
process.exitCode = 1;
});
NODE
}

# 실패 직후 전체 검사를 반복하면 정상 경로까지 중계 한도를 다시 소비합니다.
# 원본 실패와 Retry-After는 첫 검사 기록으로 판단하고 다음 점검에서 재확인합니다.
# 검증된 분당 제한만 만료 후 해당 ID를 한 번 재확인하고 최초 실패를 보존합니다.
run_health_check

- name: Preserve health check attempts
Expand All @@ -115,6 +131,9 @@ jobs:
health-checks-attempt-*.json
health-checks-attempt-*.txt
health-check-summary.txt
health-check-summary-first.txt
health-checks-recheck-*.json
health-checks.json
retention-days: 7
if-no-files-found: warn

Expand Down
2 changes: 2 additions & 0 deletions docs/health-check-stock-and-quota.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ CU·GS25·세븐일레븐 Mac 중계는 같은 원장을 공유하며 분당 69
CGV·디트릭스 중계는 기존 디트릭스 원장을 공유합니다. 편의점 원장과 분리되어 있으며 이번 복구로 한도를 늘리거나 원장을 초기화하지 않습니다.

편의점 중계가 자체 분·일 한도로 거부하면 HTTP 429에 `x-relay-quota-reason: minute|daily`와 `Retry-After`를 반환합니다. Worker는 이 값을 공개 진단과 재시도 대기 시간에 반영합니다. 외부 서비스가 반환한 429에는 자체 한도 사유를 붙이지 않습니다.

CI는 검증된 자체 분당 제한(429·minute·1~60초 Retry-After)에 걸린 알려진 검사만 만료 후 한 번 재확인합니다. 전체 스위트를 다시 실행하지 않으며, 일일 제한·외부 오류·사유가 없는 오류는 재시도하지 않습니다. 항목당 20초·재확인 전체 120초 상한을 적용하고, 같은 검사 ID의 캐시되지 않은 정상 응답만 실패를 대체합니다. 선택 항목인 세븐일레븐 인기 검색어는 검증된 HTTP 200 빈 응답도 기존 `skipped` 기준을 유지합니다. 첫 실패·재확인·최종 결과와 재시도 항목을 아티팩트에 보존합니다. 재확인이 실패하면 CI도 계속 실패합니다.
96 changes: 96 additions & 0 deletions scripts/ops/health-check-retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/** 분당 중계 한도로 실패한 알려진 검사만 한 번 다시 확인합니다. */
import type { HealthCheckSummary, HealthCheckResult } from '../../src/api/healthCheckTypes.js';
export const MINUTE_RECHECK_IDS = [
'cu.stores', 'cu.inventory', 'gs25.products', 'gs25.stores', 'gs25.inventory',
'seveneleven.products', 'seveneleven.stores', 'seveneleven.popwords', 'seveneleven.inventory',
'cli.contract', 'oliveyoung.products', 'oliveyoung.inventory', 'dtryx.movies', 'cgv.theaters',
];
function relayErrorCode(id: string): string {
if (id.startsWith('oliveyoung.')) return 'OLIVEYOUNG_RELAY_HTTP_ERROR';
if (id.startsWith('dtryx.')) return 'DTRYX_RELAY_FAILED';
if (id.startsWith('cgv.')) return 'CGV_RELAY_FAILED';
return 'CONVENIENCE_RELAY_FAILED';
}
function aggregate(checks: HealthCheckResult[]): 'ok' | 'degraded' | 'fail' {
return checks.some(check => check.status === 'fail') ? 'fail'
: checks.some(check => check.status === 'degraded') ? 'degraded' : 'ok';
}
export function isValidHealthPayload(value: unknown): value is HealthCheckSummary {
if (!value || typeof value !== 'object') return false;
const payload = value as HealthCheckSummary;
return ['ok', 'degraded', 'fail'].includes(payload.status) &&
Array.isArray(payload.checks) && payload.checks.length > 0 &&
payload.checks.every(check => check && typeof check.id === 'string' && check.id.length > 0 &&
['ok', 'degraded', 'fail', 'skipped'].includes(check.status) && typeof check.message === 'string') &&
new Set(payload.checks.map(check => check.id)).size === payload.checks.length &&
payload.status === aggregate(payload.checks);
}
interface RetryOptions {
endpoint: string;
secret: string;
fetchImpl?: typeof fetch;
sleep?: (ms: number) => Promise<void>;
now?: () => number;
}
export async function retryMinuteChecks(payload: HealthCheckSummary, options: RetryOptions) {
const attempts: Array<{ id: string; response?: unknown; error?: string }> = [];
if (!isValidHealthPayload(payload)) throw new Error('Invalid health payload');
const candidates = payload.checks.filter(check => check.status === 'fail' &&
MINUTE_RECHECK_IDS.includes(check.id) && check.httpStatus === 429 && check.upstreamStatus === 429 &&
(check.id === 'cli.contract'
? ['CONVENIENCE_RELAY_FAILED', 'OLIVEYOUNG_RELAY_HTTP_ERROR', 'DTRYX_RELAY_FAILED', 'CGV_RELAY_FAILED'].includes(check.errorCode ?? '')
: check.errorCode === relayErrorCode(check.id)) && check.quotaReason === 'minute' &&
typeof check.retryAfter === 'number' && Number.isInteger(check.retryAfter) &&
check.retryAfter >= 1 && check.retryAfter <= 60);
if (!candidates.length) return { payload, attempts };
const now = options.now ?? Date.now;
const deadline = now() + 120000;
const sleep = options.sleep ?? (ms => new Promise<void>(resolve => setTimeout(resolve, ms)));
await sleep((Math.max(...candidates.map(check => check.retryAfter!)) + 1) * 1000);
const checks = [...payload.checks];
for (const check of candidates) {
const remaining = deadline - now();
if (remaining <= 0) break;
const url = new URL(options.endpoint);
url.searchParams.delete('service');
url.searchParams.set('check', check.id);
url.searchParams.set('mode', 'full');
url.searchParams.set('fresh', 'true');
url.searchParams.set('timeoutMs', String(Math.min(15000, remaining)));
const controller = new AbortController();
let timer!: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new Error('Health recheck timed out'));
}, Math.min(20000, remaining));
});
try {
const response: unknown = await Promise.race([(async () => {
const response = await (options.fetchImpl ?? fetch)(url, {
headers: { Authorization: `Bearer ${options.secret}`, 'x-health-check-force-fresh': 'true' },
redirect: 'error', signal: controller.signal,
});
if (!response.ok) throw new Error(`Health recheck HTTP ${response.status}`);
return response.json();
})(), timeout]);
attempts.push({ id: check.id, response });
if (isValidHealthPayload(response) && response.checks.length === 1 &&
response.checks[0].id === check.id && response.cached === false &&
(response.checks[0].status === 'ok' ||
(check.id === 'seveneleven.popwords' && response.checks[0].status === 'skipped' &&
response.checks[0].httpStatus === 200 && response.checks[0].errorCode === undefined &&
response.checks[0].upstreamStatus === undefined && response.checks[0].quotaReason === undefined &&
response.checks[0].retryAfter === undefined))) {
checks[checks.findIndex(original => original.id === check.id)] = response.checks[0];
}
} catch {
// 원본 본문과 인증 정보는 오류 아티팩트에 기록하지 않습니다.
attempts.push({ id: check.id, error: 'Health recheck failed or timed out' });
} finally {
clearTimeout(timer);
controller.abort();
}
}
return { payload: { ...payload, checks, status: aggregate(checks) }, attempts };
}
Loading
Loading
0