From 4045a693a8d8a39ee3b758160204d76ee3f403fb Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Wed, 7 Oct 2026 16:02:21 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9A=B4=EC=98=81=20=EC=A4=91=EA=B3=84?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=84=B8=EB=B8=90=EC=9D=BC=EB=A0=88?= =?UTF-8?q?=EB=B8=90=20=EC=8B=A4=ED=8C=A8=20=EC=A7=84=EB=8B=A8=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/convenience-relay.md | 2 + scripts/relay/http-relay.ts | 3 +- src/utils/convenienceTransport.ts | 17 ++++++++ tests/api/seven-stock-error.test.ts | 60 +++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/docs/convenience-relay.md b/docs/convenience-relay.md index 8686f29a..a4a57651 100644 --- a/docs/convenience-relay.md +++ b/docs/convenience-relay.md @@ -73,3 +73,5 @@ CU 매장 키워드만 입력한 조회는 공식 웹에서 해당 지역 점포 원본 `/real-stock/multi/01/stocks`에서 매장 1곳은 성공했으나 2곳은 HTTP 400, 코드 501, `정상적인 점포 조회 요청이 아닙니다.`로 거부되는 현상을 확인했습니다. 영구 정책인지 일시 제한인지는 확인되지 않았습니다. 이 응답은 공개 API에서 HTTP 502를 유지하고, 오류 문구를 `정상적인 점포 조회 요청이 아닙니다. 일시적인 외부 서비스 오류입니다. 잠시 후 재시도해주세요.`로 표시합니다. API와 MCP의 공통 `diagnostics`에는 원본 `upstreamStatus: 400`, `upstreamCode: 501`, `upstreamMessage`가 남습니다. 확인된 문구만 전달하며 응답 본문 전체나 알 수 없는 오류 문구는 공개하지 않습니다. 이 요청 거부는 `retryable: false`로 표시해 자동 재시도를 하지 않습니다. 안내 문구는 정책이 일시적이라는 확정 진단이 아닙니다. + +중계 내부에서는 이 확인된 거부를 HTTP 200의 `success: false` 실패 envelope로 전달해 중간 연결 구간의 5xx 오류 페이지 처리와 분리합니다. Worker는 이를 성공으로 반환하지 않고 HTTP 502로 변환합니다. 실패는 캐시하지 않으며 원본 호출 로그에도 오류로 남습니다. 구버전 중계의 HTTP 502 진단도 계속 읽을 수 있습니다. diff --git a/scripts/relay/http-relay.ts b/scripts/relay/http-relay.ts index e5027a5c..0cd19ab0 100644 --- a/scripts/relay/http-relay.ts +++ b/scripts/relay/http-relay.ts @@ -241,7 +241,8 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) { ? error.status : 502; resultStatus = status; - return reply(status, { + return reply(error instanceof RelayError && error.upstreamError ? 200 : status, { + ...(error instanceof RelayError && error.upstreamError ? { success: false } : {}), error: 'Relay request failed', upstreamError: error instanceof RelayError ? error.upstreamError : undefined, }); diff --git a/src/utils/convenienceTransport.ts b/src/utils/convenienceTransport.ts index 1a933b08..1742358f 100644 --- a/src/utils/convenienceTransport.ts +++ b/src/utils/convenienceTransport.ts @@ -173,6 +173,23 @@ async function requestConvenienceRelayRaw( } readingResponse = true; const data: unknown = await response.json(); + if (operation === 'seven-stock') { + 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, + ); + } if ( !data || typeof data !== 'object' || diff --git a/tests/api/seven-stock-error.test.ts b/tests/api/seven-stock-error.test.ts index aa3ba884..bcb14884 100644 --- a/tests/api/seven-stock-error.test.ts +++ b/tests/api/seven-stock-error.test.ts @@ -107,3 +107,63 @@ it('원본 400의 오류 형식이 다르면 본문을 전달하지 않는다', ); expect(await response.json()).toEqual({ error: 'Relay request failed' }); }); +it('원본 조회 거부는 HTTP 중계 오류 페이지 대신 실패 envelope로 전달한다', async () => { + 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(200); + expect(await response.json()).toMatchObject({ + success: false, + upstreamError: { status: 400, code: 501, message }, + }); +}); +it.each([null, 1, {}, { upstreamError: { status: 400, code: 501, message: 'private' } }])( + '정상 HTTP에서도 검증되지 않은 진단을 전달하지 않는다 %j', + async (value) => { + vi.stubGlobal('fetch', async () => Response.json(value)); + try { + if (!value || typeof value !== 'object') + await expect( + requestConvenienceRelay('seven-stock', body, { + convenienceRelayUrl: 'https://relay.example', + convenienceRelayToken: 'token', + }), + ).rejects.toThrow('실패'); + else + expect( + await requestConvenienceRelay('seven-stock', body, { + convenienceRelayUrl: 'https://relay.example', + convenienceRelayToken: 'token', + }), + ).toEqual(value); + } finally { + vi.unstubAllGlobals(); + } + }, +); +it('구버전 중계의 HTTP 502 원본 진단도 보존한다', async () => { + vi.stubGlobal('fetch', async () => + Response.json( + { error: 'Relay request failed', upstreamError: { status: 400, code: 501, message } }, + { status: 502 }, + ), + ); + try { + await expect( + requestConvenienceRelay('seven-stock', body, { + convenienceRelayUrl: 'https://relay.example', + convenienceRelayToken: 'token', + }), + ).rejects.toMatchObject({ upstreamStatus: 400, retryable: false }); + } finally { + vi.unstubAllGlobals(); + } +});