diff --git a/docs/convenience-relay.md b/docs/convenience-relay.md index 43a0fc2f..8686f29a 100644 --- a/docs/convenience-relay.md +++ b/docs/convenience-relay.md @@ -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`로 표시해 자동 재시도를 하지 않습니다. 안내 문구는 정책이 일시적이라는 확정 진단이 아닙니다. diff --git a/scripts/relay/convenience.ts b/scripts/relay/convenience.ts index bf506807..53b35b1e 100644 --- a/scripts/relay/convenience.ts +++ b/scripts/relay/convenience.ts @@ -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'; @@ -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 @@ -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( diff --git a/scripts/relay/http-relay.ts b/scripts/relay/http-relay.ts index d6830e6b..e5027a5c 100644 --- a/scripts/relay/http-relay.ts +++ b/scripts/relay/http-relay.ts @@ -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'; @@ -24,7 +25,10 @@ export interface HttpRelayOptions { }) => void | Promise; } export class RelayError extends Error { - constructor(readonly status: number) { + constructor( + readonly status: number, + readonly upstreamError?: SevenStockFailure, + ) { super('Relay failure'); } } @@ -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( diff --git a/src/core/errors.ts b/src/core/errors.ts index 0d4829e9..4152559a 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -1,3 +1,4 @@ +import { EXTERNAL_SERVICE_RETRY_HINT, type SevenStockFailure } from '../utils/sevenStockFailure.js'; /** * MCP/API 공통 에러 진단 구조 */ @@ -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; @@ -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'; @@ -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( @@ -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이 필요합니다.', @@ -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), }; } @@ -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, @@ -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), }; } diff --git a/src/utils/convenienceTransport.ts b/src/utils/convenienceTransport.ts index 6ba83239..1a933b08 100644 --- a/src/utils/convenienceTransport.ts +++ b/src/utils/convenienceTransport.ts @@ -1,3 +1,4 @@ +import { parseSevenStockFailure, EXTERNAL_SERVICE_RETRY_HINT } from './sevenStockFailure.js'; /** 편의점 공통 릴레이 설정과 인증 전송입니다. */ import { requestDirectRoute } from './directRoutes.js'; import { isRouteKey, routeOperation } from './routeHealth.js'; @@ -131,7 +132,24 @@ async function requestConvenienceRelayRaw( 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).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 ( diff --git a/src/utils/sevenStockFailure.ts b/src/utils/sevenStockFailure.ts new file mode 100644 index 00000000..99a9c5b5 --- /dev/null +++ b/src/utils/sevenStockFailure.ts @@ -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; + if (data.status !== 400 || data.code !== 501 || data.message !== SEVEN_STORE_REJECTION) + return undefined; + return { status: 400, code: 501, message: SEVEN_STORE_REJECTION }; +} diff --git a/tests/api/response.test.ts b/tests/api/response.test.ts index f9c1de9d..3954790d 100644 --- a/tests/api/response.test.ts +++ b/tests/api/response.test.ts @@ -34,7 +34,7 @@ describe('errorResponse', () => { service: 'gs25', operation: 'product_search', upstreamStatus: undefined, - hint: '일시적인 외부 서비스 오류일 수 있습니다. 잠시 후 다시 시도하세요.', + hint: '일시적인 외부 서비스 오류입니다. 잠시 후 재시도해주세요.', }, }, 500, @@ -89,7 +89,7 @@ describe('설정 오류 진단', () => { ])('일반 외부 오류는 재시도 판단을 유지한다: %s %s', (code, message, status, upstreamStatus) => { expect(toStandardErrorDiagnostics(code, message, { status, upstreamStatus })).toMatchObject({ retryable: true, - hint: '일시적인 외부 서비스 오류일 수 있습니다. 잠시 후 다시 시도하세요.', + hint: '일시적인 외부 서비스 오류입니다. 잠시 후 재시도해주세요.', }); }); }); diff --git a/tests/api/seven-stock-error.test.ts b/tests/api/seven-stock-error.test.ts new file mode 100644 index 00000000..aa3ba884 --- /dev/null +++ b/tests/api/seven-stock-error.test.ts @@ -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', 'secret'])( + '알 수 없는 원문은 공개하지 않는다: %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' }); +});