8000 fix: 세븐일레븐 원본 오류와 재시도 안내 보존 by hmmhmmhm · Pull Request #244 · 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
6 changes: 6 additions & 0 deletions docs/convenience-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,9 @@ CU 매장 키워드만 입력한 조회는 공식 웹에서 해당 지역 점포
분당 상한은 UTC 기준 각 1분 구간에 적용됩니다. 69 × 1,440분 = 99,360회이므로 정상적인 하루 합계는 10만 회 이하이며, 일일 100,000회 상한도 함께 검사합니다. 일일 원장은 UTC 자정(한국 시각 오전 9시)에 새 날짜로 전환됩니다. 한도 변경이나 재시작 시 기존 원장을 초기화하지 않습니다.

조회 재시도와 CU 주소·미확인 재고 처리는 [조회 오류 복구와 제한](status-resilience.md)을 참조하세요. 재시도도 같은 편의점 원장을 사용합니다.

### 세븐일레븐 점포 조회 거부 진단 (2026-10-07)

원본 `/real-stock/multi/01/stocks`에서 매장 1곳은 성공했으나 2곳은 HTTP 400, 코드 501, `정상적인 점포 조회 요청이 아닙니다.`로 거부되는 현상을 확인했습니다. 영구 정책인지 일시 제한인지는 확인되지 않았습니다.

이 응답은 공개 API에서 HTTP 502를 유지하고, 오류 문구를 `정상적인 점포 조회 요청이 아닙니다. 일시적인 외부 서비스 오류입니다. 잠시 후 재시도해주세요.`로 표시합니다. API와 MCP의 공통 `diagnostics`에는 원본 `upstreamStatus: 400`, `upstreamCode: 501`, `upstreamMessage`가 남습니다. 확인된 문구만 전달하며 응답 본문 전체나 알 수 없는 오류 문구는 공개하지 않습니다. 이 요청 거부는 `retryable: false`로 표시해 자동 재시도를 하지 않습니다. 안내 문구는 정책이 일시적이라는 확정 진단이 아닙니다.
21 changes: 20 additions & 1 deletion scripts/relay/convenience.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { parseSevenStockFailure } from '../../src/utils/sevenStockFailure.js';
import { CU_DEFAULT_HEADERS } from '../../src/services/cu/client.js';
import { GS25_DEFAULT_HEADERS } from '../../src/services/gs25/client.js';
import { GS25_TOTAL_SEARCH_HEADERS } from '../../src/services/gs25/productSearch.js';
Expand Down Expand Up @@ -81,7 +82,15 @@ const schemas = {
[v.longVal, v.baseLongVal].every((n) => Math.abs(Number(n)) <= 180),
),
'seven-goods': z.union([
z.object({ collection: z.literal('goods'), query: text.min(1), sort: z.literal('quantity/desc,itemOnm/asc'), startCount: count, listCount: count.min(1) }).strict(),
z
.object({
collection: z.literal('goods'),
query: text.min(1),
sort: z.literal('quantity/desc,itemOnm/asc'),
startCount: count,
listCount: count.min(1),
})
.strict(),
z.object({ query: text.min(1), pageNo: count, pageSize: count.min(1) }).strict(),
]),
'seven-store': z
Expand Down Expand Up @@ -302,6 +311,16 @@ export function createConvenienceRelay(
}),
signal,
);
if (operation === 'seven-stock' && response.status === 400) {
const data = await readJson(response.body, 16384, signal, 502);
const rejected = z
.object({ success: z.literal(false), code: z.literal(501), message: z.string() })
.safeParse(data);
throw new RelayError(
502,
rejected.success ? parseSevenStockFailure({ ...rejected.data, status: 400 }) : undefined,
);
}
if (response.status !== 200) {
void response.body?.cancel().catch(() => undefined);
throw new RelayError(
Expand Down
11 changes: 9 additions & 2 deletions scripts/relay/http-relay.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { SevenStockFailure } from '../../src/utils/sevenStockFailure.js';
import { canonicalKey, createResponseCache } from './cache.js';
/** 공개 API 중계의 인증·용량·기한·동시성 제한입니다. */
import { randomUUID, timingSafeEqual } from 'node:crypto';
Expand All @@ -24,7 +25,10 @@ export interface HttpRelayOptions {
}) => void | Promise<void>;
}
export class RelayError extends Error {
constructor(readonly status: number) {
constructor(
readonly status: number,
readonly upstreamError?: SevenStockFailure,
) {
super('Relay failure');
}
}
Expand Down Expand Up @@ -237,7 +241,10 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) {
? error.status
: 502;
resultStatus = status;
return reply(status, { error: 'Relay request failed' });
return reply(status, {
error: 'Relay request failed',
upstreamError: error instanceof RelayError ? error.upstreamError : undefined,
});
} finally {
try {
void Promise.resolve(
Expand Down
35 changes: 21 additions & 14 deletions src/core/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { EXTERNAL_SERVICE_RETRY_HINT, type SevenStockFailure } from '../utils/sevenStockFailure.js';
/**
* MCP/API 공통 에러 진단 구조
*/
Expand All @@ -14,12 +15,14 @@ export interface StandardErrorDiagnostics {
service?: string;
operation?: string;
upstreamStatus?: number;
upstreamCode?: number;
upstreamMessage?: string;
quotaReason?: RelayQuota['quotaReason'];
retryAfter?: number;
hint: string;
}

/** 외부 응답 원문 없이 API와 MCP에 전달할 오류 진단입니다. */
/** 허용된 공개 오류 문구만 API와 MCP에 전달합니다. */
export class ServiceError extends Error {
readonly quotaReason?: RelayQuota['quotaReason'];
readonly retryAfter?: number;
Expand All @@ -30,6 +33,7 @@ export class ServiceError extends Error {
readonly retryable: boolean,
readonly upstreamStatus?: number,
quota?: RelayQuota,
readonly upstreamError?: SevenStockFailure,
) {
super(message);
this.name = 'ServiceError';
Expand Down Expand Up @@ -91,9 +95,7 @@ function isRetryable(code: string, status?: number): boolean {
}

function buildHint(retryable: boolean): string {
return retryable
? '일시적인 외부 서비스 오류일 수 있습니다. 잠시 후 다시 시도하세요.'
: '입력값 또는 요청 조건을 확인하세요.';
return retryable ? EXTERNAL_SERVICE_RETRY_HINT : '입력값 또는 요청 조건을 확인하세요.';
}

export function toStandardErrorDiagnostics(
Expand All @@ -114,8 +116,7 @@ export function toStandardErrorDiagnostics(
'Zyte API 호출 실패: 403 account suspended',
'ZYTE_API_KEY가 설정되지 않았습니다.',
].some((diagnostic) => message.includes(diagnostic));
const gs25AuthenticationError =
message.includes('GS25 재고 서비스 인증을 사용할 수 없습니다.');
const gs25AuthenticationError = message.includes('GS25 재고 서비스 인증을 사용할 수 없습니다.');
const oliveyoungRelayConfigurationError = [
'올리브영 릴레이 URL은 HTTPS 또는 로컬 HTTP 주소여야 합니다.',
'OY_RELAY_TOKEN이 필요합니다.',
Expand All @@ -140,12 +141,12 @@ export function toStandardErrorDiagnostics(
hint: oliveyoungRelayConfigurationError
? '운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN 및 브라우저 릴레이 실행 상태를 확인하세요.'
: costPolicyError
? ZYTE_COST_POLICY_MESSAGE
: gs25AuthenticationError
? '운영자는 GS25_API_KEY 설정을 확인하세요.'
: configurationError
? '운영자는 ZYTE_API_KEY 설정과 Zyte 계정 상태를 확인하세요.'
: buildHint(retryable),
? ZYTE_COST_POLICY_MESSAGE
: gs25AuthenticationError
? '운영자는 GS25_API_KEY 설정을 확인하세요.'
: configurationError
? '운영자는 ZYTE_API_KEY 설정과 Zyte 계정 상태를 확인하세요.'
: buildHint(retryable),
};
}

Expand All @@ -154,7 +155,10 @@ export function getErrorMessage(error: unknown): string {
}

/** 전송 계층에서 확인한 재시도 가능 여부를 공통 진단에도 유지합니다. */
export function toServiceErrorDiagnostics(error: ServiceError, operation: string): StandardErrorDiagnostics {
export function toServiceErrorDiagnostics(
error: ServiceError,
operation: string,
): StandardErrorDiagnostics {
return {
...toStandardErrorDiagnostics(error.code, error.message, {
status: error.status,
Expand All @@ -164,6 +168,9 @@ export function toServiceErrorDiagnostics(error: ServiceError, operation: string
retryable: error.retryable,
quotaReason: error.quotaReason,
retryAfter: error.retryAfter,
hint: buildHint(error.retryable),
...(error.upstreamError
? { upstreamCode: error.upstreamError.code, upstreamMessage: error.upstreamError.message }
: {}),
hint: error.upstreamError ? EXTERNAL_SERVICE_RETRY_HINT : buildHint(error.retryable),
};
}
20 changes: 19 additions & 1 deletion src/utils/convenienceTransport.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { parseSevenStockFailure, EXTERNAL_SERVICE_RETRY_HINT } from './sevenStockFailure.js';
/** 편의점 공통 릴레이 설정과 인증 전송입니다. */
import { requestDirectRoute } from './directRoutes.js';
import { isRouteKey, routeOperation } from './routeHealth.js';
Expand Down Expand Up @@ -131,7 +132,24 @@ async function requestConvenienceRelayRaw<T>(
signal: controller.signal,
});
if (response.status !== 200) {
void response.body?.cancel().catch(() => undefined);
if (operation === 'seven-stock' && response.status === 502) {
const data: unknown = await response.json().catch(() => undefined);
const failure = parseSevenStockFailure(
data && typeof data === 'object'
? (data as Record<string, unknown>).upstreamError
: undefined,
);
if (failure)
throw new ServiceError(
'CONVENIENCE_RELAY_FAILED',
`${failure.message} ${EXTERNAL_SERVICE_RETRY_HINT}`,
502,
false,
failure.status,
undefined,
failure,
);
} else void response.body?.cancel().catch(() => undefined);
const status = response.status;
const delay = parseRetryAfterDelayMs(response) ?? 250;
if (
Expand Down
16 changes: 16 additions & 0 deletions src/utils/sevenStockFailure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/** 확인된 공개 오류만 전달해 임의 응답 본문과 인증 정보 노출을 막습니다. */
export const EXTERNAL_SERVICE_RETRY_HINT =
'일시적인 외부 서비스 오류입니다. 잠시 후 재시도해주세요.';
export const SEVEN_STORE_REJECTION = '정상적인 점포 조회 요청이 아닙니다.';
export interface SevenStockFailure {
status: 400;
code: 501;
message: typeof SEVEN_STORE_REJECTION;
}
export function parseSevenStockFailure(value: unknown): SevenStockFailure | undefined {
if (!value || typeof value !== 'object') return undefined;
const data = value as Record<string, unknown>;
if (data.status !== 400 || data.code !== 501 || data.message !== SEVEN_STORE_REJECTION)
return undefined;
return { status: 400, code: 501, message: SEVEN_STORE_REJECTION };
}
4 changes: 2 additions & 2 deletions tests/api/response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe('errorResponse', () => {
service: 'gs25',
operation: 'product_search',
upstreamStatus: undefined,
hint: '일시적인 외부 서비스 오류일 수 있습니다. 잠시 후 다시 시도하세요.',
hint: '일시적인 외부 서비스 오류입니다. 잠시 후 재시도해주세요.',
},
},
500,
Expand Down Expand Up @@ -89,7 +89,7 @@ describe('설정 오류 진단', () => {
])('일반 외부 오류는 재시도 판단을 유지한다: %s %s', (code, message, status, upstreamStatus) => {
expect(toStandardErrorDiagnostics(code, message, { status, upstreamStatus })).toMatchObject({
retryable: true,
hint: '일시적인 외부 서비스 오류일 수 있습니다. 잠시 후 다시 시도하세요.',
hint: '일시적인 외부 서비스 오류입니다. 잠시 후 재시도해주세요.',
});
});
});
Expand Down
109 changes: 109 additions & 0 deletions tests/api/seven-stock-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { expect, it, vi } from 'vitest';
import { createConvenienceRelay } from '../../scripts/relay/convenience.js';
import { requestConvenienceRelay } from '../../src/utils/convenienceTransport.js';
import { ServiceError, toServiceErrorDiagnostics } from '../../src/core/errors.js';

const message = '정상적인 점포 조회 요청이 아닙니다.';
const hint = '일시적인 외부 서비스 오류입니다. 잠시 후 재시도해주세요.';
const body = {
smCd: '1',
stokMngCd: '1',
stokMngQty: 1,
stockApplicationRate: '100',
storeList: ['a', 'b'],
};
it('원본의 점포 조회 거부 문구와 상태를 중계·Worker·MCP 공통 진단까지 전달한다', async () => {
const relay = createConvenienceRelay('token', {
takeQuota: async () => true,
fetcher: async () =>
Response.json({ success: false, message, code: 501, secret: 'hidden' }, { status: 400 }),
});
vi.stubGlobal('fetch', (url: string, init: RequestInit) => relay(new Request(url, init)));
try {
await requestConvenienceRelay('seven-stock', body, {
convenienceRelayUrl: 'https://relay.example',
convenienceRelayToken: 'token',
});
expect.fail('조회 거부가 성공으로 반환되면 안 됩니다.');
} catch (error) {
expect(error).toBeInstanceOf(ServiceError);
expect(toServiceErrorDiagnostics(error as ServiceError, 'seveneleven_request')).toMatchObject({
message: `${message} ${hint}`,
status: 502,
upstreamStatus: 400,
upstreamCode: 501,
upstreamMessage: message,
retryable: false,
hint,
});
expect(JSON.stringify(error)).not.toContain('hidden');
} finally {
vi.unstubAllGlobals();
}
});
it.each(['token=private', '<html>secret</html>'])(
'알 수 없는 원문은 공개하지 않는다: %s',
async (message) => {
const relay = createConvenienceRelay('token', {
takeQuota: async () => true,
fetcher: async () => Response.json({ success: false, message, code: 501 }, { status: 400 }),
});
const response = await relay(
new Request('http://localhost/v1/convenience/seven-stock', {
method: 'POST',
headers: { Authorization: 'Bearer token' },
body: JSON.stringify(body),
}),
);
expect(response.status).toBe(502);
expect(await response.text()).not.toContain(message);
},
);
import { parseSevenStockFailure } from '../../src/utils/sevenStockFailure.js';
it.each([
null,
1,
{},
{ status: 500 },
{ status: 400, code: 500 },
{ status: 400, code: 501, message: 'private' },
])('검증되지 않은 원본 진단은 제외한다 %j', (value) => {
expect(parseSevenStockFailure(value)).toBeUndefined();
});
it.each([
null,
1,
{},
{ upstreamError: { status: 400, code: 501, message: 'private' } },
'invalid JSON',
])('중계의 잘못된 오류 본문은 일반 오류로 처리한다 %j', async (value) => {
vi.stubGlobal('fetch', async () =>
typeof value === 'string'
? new Response(value, { status: 502 })
: Response.json(value, { status: 502 }),
);
try {
await expect(
requestConvenienceRelay('seven-stock', body, {
convenienceRelayUrl: 'https://relay.example',
convenienceRelayToken: 'token',
}),
).rejects.toMatchObject({ message: '편의점 릴레이 요청에 실패했습니다.', status: 502 });
} finally {
vi.unstubAllGlobals();
}
});
it('원본 400의 오류 형식이 다르면 본문을 전달하지 않는다', async () => {
const relay = createConvenienceRelay('token', {
takeQuota: async () => true,
fetcher: async () => Response.json({ message: 'private' }, { status: 400 }),
});
const response = await relay(
new Request('http://localhost/v1/convenience/seven-stock', {
method: 'POST',
headers: { Authorization: 'Bearer token' },
body: JSON.stringify(body),
}),
);
expect(await response.json()).toEqual({ error: 'Relay request failed' });
});
Loading
0