From 2fc584a6eaf943932a0dd3b9e14188dad53a1ed7 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Thu, 8 Oct 2026 09:23:29 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=A4=91=EA=B3=84=20=ED=95=9C=EB=8F=84?= =?UTF-8?q?=20=EC=82=AC=EC=9C=A0=EC=99=80=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=A0=84=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cgv-relay-recovery.md | 2 + docs/health-check-stock-and-quota.md | 2 + scripts/relay/http-relay.ts | 29 ++++++++++--- tests/relay/convenience-quota.test.ts | 5 ++- tests/relay/http-relay-quota-headers.test.ts | 44 ++++++++++++++++++++ 5 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 tests/relay/http-relay-quota-headers.test.ts diff --git a/docs/cgv-relay-recovery.md b/docs/cgv-relay-recovery.md index bfe536a2..bfe324fc 100644 --- a/docs/cgv-relay-recovery.md +++ b/docs/cgv-relay-recovery.md @@ -12,6 +12,8 @@ CGV 전용 설정이 하나도 없으면 `DTRYX_RELAY_URL`, `DTRYX_RELAY_TOKEN`, `/health`의 `config.cgvRelay`에서 `configured`, `urlValid`, `tokenConfigured`, `accessPairValid`를 확인합니다. 응답에는 URL·토큰·Access 원문을 노출하지 않습니다. 불완전한 설정은 `CGV_RELAY_CONFIG_ERROR`, 중계 실패는 `CGV_RELAY_FAILED`로 반환하며 HTTP 상태와 공개 진단을 REST·MCP에 보존합니다. +Cloudflare Tunnel의 공개 호스트 경로도 4320으로 연결해야 합니다. 운영 `oy-relay.aka.page`에서는 기존 Dtryx 규칙을 `^/v1/(dtryx|cgv)/.*$`로 지정하고, 올리브영 포트 4319의 `*` 규칙보다 앞에 둡니다. `/v1/convenience/`도 기존 4320 규칙을 유지합니다. CGV 경로가 4319로 전달되면 로컬 건강 응답이 정상이어도 공개 호출은 잘못된 토큰으로 HTTP 401을 받습니다. 호스트 인증과 Access 정책은 그대로 유지합니다. + ## 반영 및 확인 1. 같은 검증된 Git 상태의 `src`와 `scripts`를 런타임에 함께 반영합니다. 시작 파일 한 개만 복사하면 새 CGV 모듈·전송 계층·경로 선택 모듈이 누락될 수 있습니다. 상태 디렉터리와 호출 원장은 유지합니다. diff --git a/docs/health-check-stock-and-quota.md b/docs/health-check-stock-and-quota.md index d311e5d7..c15dd64d 100644 --- a/docs/health-check-stock-and-quota.md +++ b/docs/health-check-stock-and-quota.md @@ -18,3 +18,5 @@ CU·GS25·세븐일레븐 Mac 중계는 같은 원장을 공유하며 분당 69 헬스체크 결과에도 `errorCode`, `upstreamStatus`, `quotaReason`, `retryAfter`를 보존합니다. Actions는 실패 직후 전체 점검을 다시 실행하지 않습니다. 정상 경로까지 반복 호출하며 중계 한도를 소비하던 동작을 제거했습니다. 첫 실패 기록을 보존하고 다음 점검에서 재확인합니다. CGV·디트릭스 중계는 기존 디트릭스 원장을 공유합니다. 편의점 원장과 분리되어 있으며 이번 복구로 한도를 늘리거나 원장을 초기화하지 않습니다. + +편의점 중계가 자체 분·일 한도로 거부하면 HTTP 429에 `x-relay-quota-reason: minute|daily`와 `Retry-After`를 반환합니다. Worker는 이 값을 공개 진단과 재시도 대기 시간에 반영합니다. 외부 서비스가 반환한 429에는 자체 한도 사유를 붙이지 않습니다. diff --git a/scripts/relay/http-relay.ts b/scripts/relay/http-relay.ts index 0cd19ab0..2848c40d 100644 --- a/scripts/relay/http-relay.ts +++ b/scripts/relay/http-relay.ts @@ -1,3 +1,5 @@ +import type { RelayQuota } from '../../src/utils/relayQuota.js'; +import type { QuotaStatus } from './quota.js'; import type { SevenStockFailure } from '../../src/utils/sevenStockFailure.js'; import { canonicalKey, createResponseCache } from './cache.js'; /** 공개 API 중계의 인증·용량·기한·동시성 제한입니다. */ @@ -13,7 +15,7 @@ export interface HttpRelayOptions { signal: AbortSignal, fetcher: typeof fetch, ) => Promise; - takeQuota: () => Promise; + takeQuota: (() => Promise) & { status?: () => Pick }; fetcher?: typeof fetch; onEvent?: (event: { stage: string; @@ -28,14 +30,15 @@ export class RelayError extends Error { constructor( readonly status: number, readonly upstreamError?: SevenStockFailure, + readonly quota?: RelayQuota, ) { super('Relay failure'); } } -const reply = (status: number, body: unknown) => +const reply = (status: number, body: unknown, headers: Record = {}) => Response.json(body, { status, - headers: { 'Cache-Control': 'no-store' }, + headers: { 'Cache-Control': 'no-store', ...headers }, }); /** 취소 신호를 무시하는 외부 처리도 기한 안에 반환합니다. */ @@ -147,7 +150,21 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) { }), controller.signal, ); - if (!allowed) throw new RelayError(429); + if (!allowed) { + let quota: RelayQuota | undefined; + try { + const snapshot = options.takeQuota.status?.(); + const reason = snapshot?.blockedBy; + const retry = snapshot?.retryAfter; + if ((reason === 'minute' || reason === 'daily') && typeof retry === 'number' && + Number.isInteger(retry) && retry >= 1 && retry <= 86400) { + quota = { quotaReason: reason, retryAfter: retry }; + } + } catch { + // 상태 조회 실패는 기존 제한 응답을 바꾸지 않습니다. + } + throw new RelayError(429, undefined, quota); + } controller.signal.throwIfAborted(); work.stage = 'upstream'; const data = await abortable( @@ -245,7 +262,9 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) { ...(error instanceof RelayError && error.upstreamError ? { success: false } : {}), error: 'Relay request failed', upstreamError: error instanceof RelayError ? error.upstreamError : undefined, - }); + }, status === 429 && work?.stage === 'quota' && error instanceof RelayError && error.quota + ? { 'x-relay-quota-reason': error.quota.quotaReason, 'Retry-After': String(error.quota.retryAfter) } + : {}); } finally { try { void Promise.resolve( diff --git a/tests/relay/convenience-quota.test.ts b/tests/relay/convenience-quota.test.ts index 7f239347..554b80cf 100644 --- a/tests/relay/convenience-quota.test.ts +++ b/tests/relay/convenience-quota.test.ts @@ -22,7 +22,10 @@ it('분당 69회 원본 호출 후에도 캐시는 응답하며 최초 동시 expect(fetcher).toHaveBeenCalledTimes(1); for (let index = 1; index < 69; index++) expect((await relay(request(index))).status).toBe(200); expect((await relay(request(0))).status).toBe(200); - expect((await relay(request(69))).status).toBe(429); + const denied = await relay(request(69)); + expect(denied.status).toBe(429); + expect(denied.headers.get('x-relay-quota-reason')).toBe('minute'); + expect(denied.headers.get('Retry-After')).toBe('60'); expect(fetcher).toHaveBeenCalledTimes(69); expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(69); now += 60000; diff --git a/tests/relay/http-relay-quota-headers.test.ts b/tests/relay/http-relay-quota-headers.test.ts new file mode 100644 index 00000000..7c736020 --- /dev/null +++ b/tests/relay/http-relay-quota-headers.test.ts @@ -0,0 +1,44 @@ +/** 로컬 정책 한도만 검증된 재시도 헤더를 전달합니다. */ +import { expect, it, vi } from 'vitest'; +import { createHttpRelay, RelayError, type HttpRelayOptions } from '../../scripts/relay/http-relay.js'; +function request() { + return new Request('http://localhost/v1/test/read', { method: 'POST', headers: { Authorization: 'Bearer token' }, body: '{}' }); +} +function setup(takeQuota: HttpRelayOptions['takeQuota'], upstream = vi.fn(async () => ({}))) { + return { upstream, relay: createHttpRelay('token', { prefix: '/v1/test/', operations: ['read'], validate: value => value, takeQuota, upstream }) }; +} +it.each([['minute', 1], ['daily', 86400]])('정책 %s 제한에 재시도 헤더를 반환한다', async (blockedBy, retryAfter) => { + const takeQuota = Object.assign(async () => false, { status: () => ({ blockedBy, retryAfter }) }) as HttpRelayOptions['takeQuota']; + const { relay, upstream } = setup(takeQuota); + const response = await relay(request()); + expect(response.status).toBe(429); + expect(response.headers.get('x-relay-quota-reason')).toBe(blockedBy); + expect(response.headers.get('Retry-After')).toBe(String(retryAfter)); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(upstream).not.toHaveBeenCalled(); +}); +it.each([ + [null, 30], ['foo', 30], ['consumer', 30], ['minute', undefined], ['minute', '30'], + ['minute', NaN], ['minute', Infinity], ['minute', 0], ['minute', -1], ['minute', 1.5], ['daily', 86401], +])('유효하지 않은 정책 상태는 헤더 없이 429를 유지한다: %s/%s', async (blockedBy, retryAfter) => { + const takeQuota = Object.assign(async () => false, { status: () => ({ blockedBy, retryAfter }) }) as HttpRelayOptions['takeQuota']; + const response = await setup(takeQuota).relay(request()); + expect(response.status).toBe(429); + expect(response.headers.get('Retry-After')).toBeNull(); + expect(response.headers.get('x-relay-quota-reason')).toBeNull(); +}); +it('상태 함수가 없거나 실패해도 기존 429 계약을 유지한다', async () => { + for (const takeQuota of [async () => false, Object.assign(async () => false, { status: () => { throw new Error('private'); } })]) { + const response = await setup(takeQuota).relay(request()); + expect(response.status).toBe(429); + expect(response.headers.get('Retry-After')).toBeNull(); + expect(await response.text()).not.toContain('private'); + } +}); +it('원본 오류는 로컬 정책 재시도 헤더를 주입하지 못한다', async () => { + const upstream = vi.fn(async () => { throw Object.assign(new RelayError(429), { quota: { quotaReason: 'minute', retryAfter: 30 } }); }); + const response = await setup(async () => true, upstream).relay(request()); + expect(response.status).toBe(429); + expect(response.headers.get('Retry-After')).toBeNull(); + expect(response.headers.get('x-relay-quota-reason')).toBeNull(); +});