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
20 changes: 20 additions & 0 deletions docs/upstream-reliability-2026-09-10.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# 2026-09-10 외부 서비스 응답 개선

## 확인한 원인과 변경

- GS25 기존 매장 웹 주소는 새 GS리테일 사이트로 이동합니다. 기존 CSRF 토큰과 이중 JSON 응답 처리 대신 새 사이트가 사용하는 공개 매장 API를 연결합니다. `shopName`으로 검색하고 `posY`를 위도, `posX`를 경도로 정규화합니다. 실제 강남 검색에서 46개 매장을 확인했습니다. 공개 매장 정보는 재고 수량의 근거가 아닙니다.
- REST와 MCP 매장 탐색 모두 공개 매장 경로를 사용해 빈 기본 응답을 보완합니다. 공개 경로까지 실패하면 빈 매장 목록으로 성공 처리하지 않습니다.
- CU 재고 검색은 운영에서 JSON 대신 HTML을 반환했습니다. 정상 HTTP 코드의 HTML도 식별 가능한 오류로 구분하고 CU 응답에 `available:false`와 원인을 전달합니다. 헬스체크도 이를 정상 재고로 처리하지 않습니다. 일반 JSON 문법 오류는 그대로 실패하고, content-type이 잘못 지정된 유효 JSON은 계속 허용합니다. 확인된 오류 코드 `3000`도 정상 빈 재고로 처리하지 않습니다.
- 올리브영 전체 헬스체크의 고정 5초 제한을 제거해 요청한 제한 시간을 적용합니다. 간이 검사의 요청 제한과 지연 경고는 유지합니다. 이마트24 매장·상품 핸들러도 `timeoutMs`를 반영합니다.

## 남은 운영 제약

GS25 재고 원본은 HTTP 401과 `인증키가 제공되지 않음`을 반환합니다. 운영에 유효한 `GS25_API_KEY`를 설정해야 재고 복구를 검증할 수 있습니다. 공개 매장 API로 인증이 필요한 재고를 대체하지 않습니다.

CU는 로컬 직접 요청이 성공했지만 운영 경로의 HTML 응답 원인은 확정되지 않았습니다. 이번 변경은 실패를 명확하게 전달하며 조회 자체를 복구했다고 주장하지 않습니다. 확인되지 않은 HTML에 유료 대체 요청을 추가하지 않습니다.

이마트24 매장은 직접 검사에서 약 1.5초에 성공했다가 약 31초 후 시간 초과로 실패하고 다시 성공했습니다. 제한 시간 전달을 바로잡아도 외부 서버의 간헐 지연·403이 모두 해소되는 것은 아닙니다.

## 검증

회귀 테스트는 GS25 공개 API와 좌표 변환, CU 응답 형식·조회 불가 상태, 간이/전체 검사 제한 시간 적용을 검증합니다. 운영 반영 뒤 매장 검색 및 전체 fresh 헬스체크로 실제 복구 범위와 잔여 장애를 확인합니다.
8 changes: 6 additions & 2 deletions src/api/emart24Handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export async function handleEmart24FindStores(c: ApiContext) {
const service24h = c.req.query('service24h') === 'true';
const page = parseInt(c.req.query('page') || '1', 10);
const limit = parseInt(c.req.query('limit') || '20', 10);
const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10);
const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000;
const rawLat = c.req.query('lat');
const rawLng = c.req.query('lng');
const parsedLat = rawLat ? parseFloat(rawLat) : undefined;
Expand All @@ -34,7 +36,7 @@ export async function handleEmart24FindStores(c: ApiContext) {
service24h,
},
{
timeout: 15000,
timeout: timeoutMs,
},
);

Expand Down Expand Up @@ -86,6 +88,8 @@ export async function handleEmart24SearchProducts(c: ApiContext) {
const sortType =
(c.req.query('sortType') as 'SALE' | 'LATEST' | 'PRICE_ASC' | 'PRICE_DESC' | null) || 'SALE';
const saleProductYn = (c.req.query('saleProductYn') as 'Y' | 'N' | null) || 'N';
const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10);
const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000;

if (keyword.trim().length === 0) {
return errorResponse(c, 'MISSING_QUERY', '검색어(keyword)를 입력해주세요.');
Expand All @@ -101,7 +105,7 @@ export async function handleEmart24SearchProducts(c: ApiContext) {
saleProductYn,
},
{
timeout: 15000,
timeout: timeoutMs,
},
);

Expand Down
32 changes: 12 additions & 20 deletions src/api/gs25Handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export async function handleGs25FindStores(c: ApiContext) {
}
}

let fallbackUsed = false;
let storeResult = await fetchGs25Stores(
{
serviceCode,
Expand All @@ -65,11 +66,15 @@ export async function handleGs25FindStores(c: ApiContext) {
zyteApiKey: c.env?.ZYTE_API_KEY,
apiKey: c.env?.GS25_API_KEY,
},
);
let fallbackUsed = false;
).catch(async (error: unknown) => {
if (!isGs25UpstreamUnavailableError(error) || keyword.trim().length === 0) throw error;
const webStoreResult = await fetchGs25WebStores(keyword, { timeout: 20000 });
fallbackUsed = true;
return { ...webStoreResult, cacheHit: false };
});

if (
storeResult.stores.length === 0 &&
!fallbackUsed && storeResult.stores.length === 0 &&
typeof latitude === 'number' &&
typeof longitude === 'number'
) {
Expand Down Expand Up @@ -99,23 +104,10 @@ export async function handleGs25FindStores(c: ApiContext) {
}
}

if (storeResult.stores.length === 0 && keyword.trim().length > 0) {
try {
const webStoreResult = await fetchGs25WebStores(keyword, {
timeout: 20000,
});

if (webStoreResult.stores.length > 0) {
storeResult = {
totalCount: webStoreResult.totalCount,
stores: webStoreResult.stores,
cacheHit: false,
};
fallbackUsed = true;
}
} catch {
fallbackUsed = false;
}
if (!fallbackUsed && storeResult.stores.length === 0 && keyword.trim().length > 0) {
const webStoreResult = await fetchGs25WebStores(keyword, { timeout: 20000 });
storeResult = { ...webStoreResult, cacheHit: false };
fallbackUsed = true;
}

const selected = selectGs25StoresForKeyword(storeResult.stores, keyword, {
Expand Down
2 changes: 0 additions & 2 deletions src/api/healthCheckDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,6 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [
path: '/api/oliveyoung/products?keyword=%EB%A6%BD%EB%B0%A4&size=1',
collectionKey: 'products',
requiredFields: ['goodsNumber', 'goodsName', 'productNo', 'productName', 'name'],
timeoutMs: 5000,
},
{
id: 'megabox.theaters',
Expand Down Expand Up @@ -213,6 +212,5 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [
path: '/api/oliveyoung/inventory?keyword=%EC%84%A0%ED%81%AC%EB%A6%BC&storeKeyword=%EB%AA%85%EB%8F%99&size=1&storeLimit=1&stockCheckLimit=0',
collectionKey: 'inventoryProducts',
requiredFields: ['goodsNumber', 'goodsName', 'productNo', 'productName', 'name'],
timeoutMs: 5000,
},
];
1 change: 0 additions & 1 deletion src/api/healthCheckTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ export interface HealthCheckDefinition {
| 'inventoryItems'
| 'inventoryStores';
requiredFields?: string[];
timeoutMs?: number;
degradedFailurePatterns?: string[];
allowEmpty?: boolean;
}
Expand Down
35 changes: 16 additions & 19 deletions src/api/healthChecks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,23 +151,6 @@ function shouldDegradeCliContractPath(path: string, message: string): boolean {
return false;
}

function resolveCheckTimeoutMs(
check: Pick<HealthCheckDefinition, 'timeoutMs'>,
timeoutMs: number,
): number {
if (check.timeoutMs === undefined) {
return timeoutMs;
}
return Math.min(timeoutMs, Math.trunc(check.timeoutMs));
}

function resolveCliContractTimeoutMs(path: string, timeoutMs: number): number {
if (path.startsWith('/api/oliveyoung/')) {
return Math.min(timeoutMs, 5000);
}
return timeoutMs;
}

function buildCheckUrl(
baseUrl: string,
check: HealthCheckDefinition,
Expand Down Expand Up @@ -220,7 +203,7 @@ async function runCliContractCheck(
const degradedMessages: string[] = [];

for (const path of CLI_CONTRACT_PATHS) {
const checkTimeoutMs = resolveCliContractTimeoutMs(path, params.timeoutMs);
const checkTimeoutMs = params.timeoutMs;
const syntheticCheck = { ...check, path };
try {
const response = await params.fetchImpl(
Expand Down Expand Up @@ -286,7 +269,7 @@ async function runSingleCheck(
return runCliContractCheck(check, params);
}

const timeoutMs = resolveCheckTimeoutMs(check, params.timeoutMs);
const timeoutMs = params.timeoutMs;
const startedAt = params.now();
const cacheBustValue = params.cacheBust ? startedAt : undefined;

Expand Down Expand Up @@ -318,6 +301,20 @@ async function runSingleCheck(
};
}

const inventory = (body.data as { inventory?: { available?: boolean; unavailableReason?: unknown } } | undefined)?.inventory;
if (check.id === 'cu.inventory' && inventory?.available === false) {
const reason = inventory.unavailableReason;
return {
id: check.id,
service: check.service,
target: check.target,
status: 'degraded',
durationMs,
httpStatus: response.status,
message: typeof reason === 'string' && reason.trim() ? reason.trim() : 'inventory unavailable',
};
}

const count = typeof body.meta?.total === 'number' ? body.meta.total : toCount(body.data);
const shapeOk = hasRequiredRepresentativeFields(
body.data,
Expand Down
18 changes: 8 additions & 10 deletions src/services/cu/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
* CU API 클라이언트
*/

import { fetchJson, HttpError } from '../../utils/http.js';
import { fetchJson } from '../../utils/http.js';
import { decodeBase64, requestByZyte } from '../../utils/zyte.js';
import { fetchJsonWithZyteFallback } from '../../utils/zyteJsonFallback.js';
import { CU_API } from './api.js';
import { cuStockUnavailableReason } from './upstreamError.js';
import type { CuStockItem, CuStockMainResponse, CuStore, CuStoreResponse } from './types.js';

interface RequestOptions {
Expand Down Expand Up @@ -402,17 +403,11 @@ export async function fetchCuStock(
try {
body = await requestCuJson<CuStockMainResponse>(CU_API.STOCK_MAIN_PATH, payload, options);
} catch (error) {
const message = error instanceof Error ? error.message : '';
const originBlocked = error instanceof HttpError && [400, 403, 429].includes(error.status);
const zyteBlocked =
message.includes('Zyte API 호출 실패: 520') ||
message.includes('Zyte 대상 응답 실패: 520');
if (originBlocked || zyteBlocked) {
const unavailableReason = cuStockUnavailableReason(error);
if (unavailableReason) {
return {
available: false,
unavailableReason: originBlocked
? `CU 재고 API가 차단되었습니다 (${error.status} Request Blocked).`
: 'CU 재고 API가 차단되었습니다 (Zyte Website Ban 520).',
unavailableReason,
totalCount: 0,
spellModifyYn: 'N',
items: [],
Expand All @@ -421,6 +416,9 @@ export async function fetchCuStock(
throw error;
}

if (body.resp_cd === '3000') {
throw new Error('CU 재고 API가 조회 실패 응답을 반환했습니다 (3000).');
}
const result = body.data?.stockResult?.result;
const rows = result?.rows || [];

Expand Down
1 change: 1 addition & 0 deletions src/services/cu/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ interface CuStockRow {
}

export interface CuStockMainResponse {
resp_cd?: string;
spellModifyYn?: string;
data?: {
stockResult?: {
Expand Down
16 changes: 16 additions & 0 deletions src/services/cu/upstreamError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/** CU 재고 조회 불가 사유를 응답 형식과 전송 실패에 따라 구분합니다. */
import { HttpError, UnexpectedHtmlResponseError } from '../../utils/http.js';

export function cuStockUnavailableReason(error: unknown): string | null {
if (error instanceof UnexpectedHtmlResponseError) {
return 'CU 재고 API가 JSON 대신 HTML을 반환하여 재고를 확인할 수 없습니다.';
}
if (error instanceof HttpError && [400, 403, 429].includes(error.status)) {
return `CU 재고 API가 차단되었습니다 (${error.status} Request Blocked).`;
}
const message = error instanceof Error ? error.message : '';
if (message.includes('Zyte API 호출 실패: 520') || message.includes('Zyte 대상 응답 실패: 520')) {
return 'CU 재고 API가 차단되었습니다 (Zyte Website Ban 520).';
}
return null;
}
4 changes: 2 additions & 2 deletions src/services/gs25/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const GS25_API = {
STORE_STOCK_PATH: '/api/bff/v2/store/stock',
APIGW_BASE_URL: 'https://b2c-apigw.woodongs.com',
TOTAL_SEARCH_PATH: '/search/v3/totalSearch',
WEB_BASE_URL: 'https://gs25.gsretail.com',
WEB_BASE_URL: 'https://www.gsretail.com',
WEB_LOCATIONS_PATH: '/gscvs/ko/store-services/locations',
WEB_LOCATION_LIST_PATH: '/gscvs/ko/store-services/locationList',
WEB_LOCATION_LIST_PATH: '/api/homepage/brand/storeSearch/selectGs25Stores',
} as const;
Loading