8000 fix: 중계 제한 진단과 재시도 시간 복구 by hmmhmmhm · Pull Request #249 · 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
2 changes: 2 additions & 0 deletions docs/cgv-relay-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 모듈·전송 계층·경로 선택 모듈이 누락될 수 있습니다. 상태 디렉터리와 호출 원장은 유지합니다.
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 @@ -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에는 자체 한도 사유를 붙이지 않습니다.
29 changes: 24 additions & 5 deletions scripts/relay/http-relay.ts
Original file line number Diff line number Diff line change
@@ -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 중계의 인증·용량·기한·동 8000 시성 제한입니다. */
Expand All @@ -13,7 +15,7 @@ export interface HttpRelayOptions {
signal: AbortSignal,
fetcher: typeof fetch,
) => Promise<unknown>;
takeQuota: () => Promise<boolean>;
takeQuota: (() => Promise<boolean>) & { status?: () => Pick<QuotaStatus, 'blockedBy' | 'retryAfter'> };
fetcher?: typeof fetch;
onEvent?: (event: {
stage: string;
Expand All @@ -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<string, string> = {}) =>
Response.json(body, {
status,
headers: { 'Cache-Control': 'no-store' },
headers: { 'Cache-Control': 'no-store', ...headers },
});

/** 취소 신호를 무시하는 외부 처리도 기한 안에 반환합니다. */
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion tests/relay/convenience-quota.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
44 changes: 44 additions & 0 deletions tests/relay/http-relay-quota-headers.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Loading
0