8000 fix: 주소 기반 재고 조회와 일시 실패 복구 개선 by hmmhmmhm · Pull Request #231 · 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/convenience-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,5 @@ CU 매장 키워드만 입력한 조회는 공식 웹에서 해당 지역 점포
리다이렉트·비 JSON·실패 envelope·잘못된 수량은 오류로 반환합니다. 재고 오류를 수량 0으로 바꾸지 않습니다. 세븐일레븐 수량 `-1`은 미확인이며 품절로 표시하지 않습니다. 반환된 매장의 수량이 모두 미확인이면 `stockAvailable`은 false입니다. 캐시 수량은 조회 시점 이후 바뀔 수 있으므로 실제 방문 전 점포 확인이 필요합니다.

분당 상한은 UTC 기준 각 1분 구간에 적용됩니다. 69 × 1,440분 = 99,360회이므로 정상적인 하루 합계는 10만 회 이하이며, 일일 100,000회 상한도 함께 검사합니다. 일일 원장은 UTC 자정(한국 시각 오전 9시)에 새 날짜로 전환됩니다. 한도 변경이나 재시작 시 기존 원장을 초기화하지 않습니다.

조회 재시도와 CU 주소·미확인 재고 처리는 [조회 오류 복구와 제한](status-resilience.md)을 참조하세요. 재시도도 같은 편의점 원장을 사용합니다.
2 changes: 2 additions & 0 deletions docs/oliveyoung-free-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,5 @@ launchctl bootout "gui/$(id -u)/page.aka.daiso-oliveyoung"
### 2026-09-15 운영 보강 검증

Playwright Chromium 153.0.8010.12에서 sandbox를 켜고 검증했습니다. 실제 조회 두 건과 부모 SIGKILL 후 잔여 소유 프로세스 0개를 확인했습니다. 모의 브라우저 API 조회 400회로 두 번 교체하고 각 교체 후에도 잔여 프로세스 0개를 확인했습니다. 팝업, 실제 renderer crash, 18초 Node watchdog도 검증했습니다. 메모리는 시점별 약 519~632MiB로 관측됐으며 장기간 무누수나 최고 사용량 보장을 의미하지 않습니다.

`consumer-busy` 대기 복구와 실제 quota 구분, 전체 시간 제한은 [조회 오류 복구와 제한](status-resilience.md)을 참조하세요.
18 changes: 18 additions & 0 deletions docs/plans/2026-10-05-status-resilience.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# 전체 운영 오류 개선 계획

**목표:** 감사에서 재현된 CU 무좌표 재고 미조회와 위치 메타데이터 오류를 수정하고, 일시 원본 장애·올리브영 처리 대기의 사용자 실패를 줄입니다.

**설계:** CU 주소는 쉼표 뒤 건물 상세를 안전하게 제거하되 다른 숫자 주소가 섞인 경우 원문을 유지합니다. 실제 조회에 사용한 좌표를 REST/MCP에 반환하고 미확인 재고는 null로 보존합니다. CU 캐시 키를 올려 과거 잘못된 응답을 재사용하지 않습니다. 읽기 요청 재시도는 정상 인증·조회만 대상으로 원래 시간 예산 안에서 제한하며, 일일/분당 quota와 인증 오류는 재시도하지 않습니다. 매번 실제 재고 호출은 기존 중계 입구를 통과해 원장을 소비합니다. OY 동시 처리 제한은 유지하며 consumer-busy에만 짧게 기다려 같은 읽기를 다시 요청합니다. 외부 사이트가 계속 실패하면 정확한 오류·부분 결과를 유지합니다.

## 작업

- [ ] CU: 실제 쉼표 주소와 본주소의 정상 카카오 응답, REST/MCP 위치 메타데이터, 변환 실패 null 재고, 중복/모호 주소 거부를 RED로 확인합니다. client.ts·checkInventory.ts·handlers.ts CU 블록과 cuRoutes 캐시를 최소 수정하고 GREEN을 확인합니다.
- [ ] OY: transport.ts에서 consumer-busy → 정상, 지속 busy의 총 시간/횟수 제한, daily/minute/consumer/auth/malformed 미재시도 회귀를 RED로 확인하고 한정된 재시도를 적용합니다. relay의 동시성·원장·인증은 그대로 유지합니다.
- [ ] EM/GS: 기존 재시도 범위와 시간이 누적되는 원인을 확인하고, EM 한정 public403/timeout 및 편의점 읽기502/503/504의 재시도를 같은 총 시간 안에 적용합니다. 안전한 작업 allowlist·응답 body 정리·실제 quota 소비·변경 없는 auth/quota 오류를 검증합니다.
- [ ] 날짜 경계 통계 테스트의 Date.now 정확한 호출 횟수 단정을 제거하고 실제 KST 조회 범위 단정을 유지합니다. 새 HTTP 계측이 시간을 읽어도 정상 기능을 실패로 분류하지 않게 합니다.
- [ ] 독립 변경을 통합하고 정상/오류/시간 경계·취소 테스트, fresh check/커버리지100/build/audit0와 코드 리뷰를 완료합니다. 문서에 상한·정확한 제한을 기록합니다.
- [ ] PR 작성·CI/Coverage/CodeQL 성공·병합·Worker 배포 확인 후 공개 CLI 새 버전을 게시하고 registry 커밋·무결성을 확인합니다.
- [ ] 운영 CU 무좌표 REST/MCP/CLI의 상품8801056018979·강남푸르지오24008 실제 재고/위치, 변환 실패 미확인 표시, GS/EM/OY 기본/동시 요청, Seven 페이지·영화 좌석을 검증합니다. 추가 real 원본 소진을 제한하고 인증·원장을 초기화하지 않습니다.
- [ ] PROJECT와 복구 기록·보고서 갱신, 작업 checkout 정리·완료 알림을 보냅니다.

기준 main 0eb267d, 2,753 tests 통과. 작업 checkout `.worktrees/status-fixes`에서 파일 소유를 CU/OY/EM·GS로 나눠 병렬 수정합니다. 운영 EM403이나 GS502가 영구 제거되었다고 주장하지 않으며, 재시도 후에도 실패하면 오류를 전달합니다. 별도의 비용 서비스나 기기를 추가하지 않습니다.
11 changes: 11 additions & 0 deletions docs/status-resilience.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# 조회 오류 복구와 제한

CU의 좌표 없는 매장 키워드 조회는 공식 매장 주소의 본주소를 확인한 뒤 좌표를 얻고, 선택 상품의 실제 재고를 다시 조회합니다. 건물 번호 뒤 쉼표와 층·호수 상세를 처리하되 다른 도로명·지번이나 알 수 없는 숫자가 섞인 주소를 임의로 자르지 않습니다. REST/MCP `location`은 실제 사용한 좌표를 반환하며, 확인하지 못한 매장의 `stock`은 `null`입니다. CU 매장·재고 캐시 키 v5는 이전 미확인 수량·좌표 응답을 다시 사용하지 않습니다.

GS25의 `gs25-products`, `gs25-stock`만 HTTP 502·503·504 또는 응답 수신 전 연결 실패 때 한 번 재시도합니다. 원래 요청 시간 안에서 최대 15초를 사용하며 첫 시도에 예산의 약 절반을 배정하고 250ms 또는 원본 `Retry-After`만큼 기다립니다. 원본의 대기 안내가 남은 예산보다 길면 즉시 오류를 전달합니다. 다른 작업, 인증 401·403, quota 429, 잘못된 정상 응답은 재시도하지 않습니다. 두 시도는 각각 중계 입구를 통과하므로 두 원본 호출이면 기존 원장을 두 번 소비합니다. 정상 캐시는 원본 예산을 추가 소비하지 않습니다.

이마트24의 각 공개 HTTP 조회는 최대 두 번 시도하며, 매장 키워드 변형 전체는 같은 시간 예산을 공유합니다. 기존 15초 제한을 시도마다 새로 주던 방식 대신 시도·대기 합계에 적용합니다. 매장 검색의 여러 키워드 변형도 같은 제한 안에서 진행합니다. 알려진 공개 API의 403·408·500·502·503·504·522·524에 한정해 재시도하고, 401·429는 재시도하지 않습니다. 상품 검색 POST는 조회 작업이며 주문·예약·전송 API에는 적용하지 않습니다. 계속 실패하면 오류·부분 결과를 유지합니다.

올리브영의 `x-relay-quota-reason: consumer-busy`, `Retry-After: 1`인 429는 다른 조회가 처리 중이라는 의미입니다. 같은 소비자의 처리 슬롯·원본 호출 한도는 그대로 유지합니다. Worker는 첫 대기 1초, 이후 2초에 작은 임의 지연을 더해 최대 24번 다시 기다리며, 요청의 기존 전체 시간 제한을 넘기지 않습니다. 취소되면 대기 타이머와 요청을 중단합니다. 일일·분당·소비자 원본 한도, 인증 오류, 잘못된 응답과 이유가 구분되지 않는 503을 busy로 간주하지 않습니다. 한도를 삭제하거나 다른 소비자 이름으로 바꾸지 않습니다.

검증은 실패 후 정상 응답·지속 실패·인증/실제 quota 오류·전체 시간 경계·응답 본문 읽기·취소를 포함합니다. 외부 사이트의 오류 자체를 제거하는 방식은 아니므로, 재시도 후에도 실패하면 그 상태를 사용자에게 알립니다. 재고를 품절 0으로 바꾸거나 확인하지 못한 최신 재고를 이전 값으로 대체하지 않습니다.
6 changes: 4 additions & 2 deletions src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,8 +328,8 @@ export async function handleCuCheckInventory(c: ApiContext) {

const firstStockItem = selectCuStockItem(stockResult.items, keyword);
const hasInputLocation = typeof lat === 'number' && typeof lng === 'number';
const resolvedLat = hasInputLocation ? lat : undefined;
const resolvedLng = hasInputLocation ? lng : undefined;
let resolvedLat = hasInputLocation ? lat : undefined;
let resolvedLng = hasInputLocation ? lng : undefined;
let storeResult: Awaited<ReturnType<typeof fetchCuStores>> | null = storeCheck
? null
: { totalCount: 0, stores: [] };
Expand All @@ -353,6 +353,8 @@ export async function handleCuCheckInventory(c: ApiContext) {
...geocodeBindings(c.env),
});
if (geocoded) {
resolvedLat = geocoded.latitude;
resolvedLng = geocoded.longitude;
const hasStockSeed = !!firstStockItem?.itemCode;
storeResult = await fetchCuStores(
{
Expand Down
4 changes: 2 additions & 2 deletions src/api/routes/cuRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function registerCuRoutes(app: Hono<{ Bindings: AppBindings }>): void {
{
ttlSeconds: 60 * 60 * 24,
staleWhileRevalidateSeconds: 60 * 5,
keyPrefix: 'cu-stores-v4',
keyPrefix: 'cu-stores-v5',
},
() => handleCuFindStores(c),
),
Expand All @@ -26,7 +26,7 @@ export function registerCuRoutes(app: Hono<{ Bindings: AppBindings }>): void {
{
ttlSeconds: 60 * 10,
staleWhileRevalidateSeconds: 60,
keyPrefix: 'cu-inventory-v4',
keyPrefix: 'cu-inventory-v5',
},
() => handleCuCheckInventory(c),
),
Expand Down
6 changes: 2 additions & 4 deletions src/services/cu/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,13 @@ async function requestCuWebHtml(
function cuGeocodeAddress(address: string): string {
const trimmed = address.trim();
const base = trimmed.match(
/^((?:[가-힣]+(?:시|도)|서울|부산|대구|인천|광주|대전|울산|세종|경기|강원|충북|충남|전북|전남|경북|경남|제주)\s+.+?(?:(?:대로|로|길)\s+\d+(?:-\d+)?|(?:읍|면|동|리)\s+(?:산\s*)?\d+(?:-\d+)?))(?=\s|$)/u,
/^((?:[가-힣]+(?:시|도)|서울|부산|대구|인천|광주|대전|울산|세종|경기|강원|충북|충남|전북|전남|경북|경남|제주)\s+.+?(?:(?:대로|로|길)\s+\d+(?:-\d+)?|(?:읍|면|동|리)\s+(?:산\s*)?\d+(?:-\d+)?))(?=\s|,|$)/u,
);
if (!base) return address;
const detail = trimmed.slice(base[1].length);
if (/(?:대로|로|길)\s+\d|(?:읍|면|동|리)\s+(?:산\s*)?\d/u.test(detail)) return address;
// 층·호수·건물명·괄호 상세만 제거하고 다른 숫자 주소가 섞이면 원문을 유지합니다.
return /^(?:\s+(?:\d+(?:층|호)|[A-Za-z가-힣][A-Za-z가-힣0-9]*|\([^)]*\)))*\s*$/u.test(detail)
return /^(?:\s+(?:\d+(?:층|호)|[A-Za-z가-힣][A-Za-z가-힣0-9]*|\([^)]*\)))*\s*$/u.test(detail.replace(/,/gu, ' '))
? base[1]
: address;
}
Expand Down Expand Up @@ -269,8 +269,6 @@ export async function fetchCuStores(
timeout,
);
const stores = parseCuWebStores(html);
// 공식 웹은 점포 정보만 제공하므로 중계 재고 조회 전 수량은 미확인입니다.
if (hasConvenienceRelay(options)) for (const store of stores) store.stock = -1;
return {
totalCount: stores.length,
stores,
Expand Down
6 changes: 4 additions & 2 deletions src/services/cu/tools/checkInventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ async function checkInventory(args: CheckInventoryArgs): Promise<McpToolResponse

const firstStockItem = selectCuStockItem(stockResult.items, keyword);
const hasInputLocation = typeof latitude === 'number' && typeof longitude === 'number';
const resolvedLatitude = hasInputLocation ? latitude : undefined;
const resolvedLongitude = hasInputLocation ? longitude : undefined;
let resolvedLatitude = hasInputLocation ? latitude : undefined;
let resolvedLongitude = hasInputLocation ? longitude : undefined;
let storeResult: Awaited<ReturnType<typeof fetchCuStores>> | null =
storeLimit <= 0 ? { totalCount: 0, stores: [] } : null;
let storeUnavailableReason: string | null = null;
Expand All @@ -97,6 +97,8 @@ async function checkInventory(args: CheckInventoryArgs): Promise<McpToolResponse
googleMapsApiKey,
});
if (geocoded) {
resolvedLatitude = geocoded.latitude;
resolvedLongitude = geocoded.longitude;
const hasStockSeed = !!firstStockItem?.itemCode;
storeResult = await fetchCuStores(
{
Expand Down
47 changes: 25 additions & 22 deletions src/services/emart24/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,15 @@ const EMART24_FORM_HEADERS = {
Accept: 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
} as const;
const EMART24_DEFAULT_FETCH_OPTIONS = {
retries: 1,
retryDelayMs: 250,
} as const;
function emart24FetchOptions(totalTimeout: number) {
return {
timeout: Math.max(1, Math.floor((totalTimeout - 250) / 2)),
totalTimeout,
retryStatusCodes: [403, 408, 500, 502, 503, 504, 522, 524],
retries: 1,
retryDelayMs: 250,
};
}

function buildKeywordVariants(keyword: string): string[] {
const trimmed = keyword.trim();
Expand Down Expand Up @@ -88,7 +93,9 @@ function toBooleanFlag(value: unknown): boolean {
return value;
}

const normalized = String(value || '').trim().toUpperCase();
const normalized = String(value || '')
.trim()
.toUpperCase();
return normalized === '1' || normalized === 'Y' || normalized === 'TRUE';
}

Expand All @@ -110,7 +117,9 @@ function toStore(item: NonNullable<Emart24WebStoreResponse['data']>[number]): Em
};
}

function toProduct(item: NonNullable<Emart24ProductSearchResponse['productList']>[number]): Emart24Product {
function toProduct(
item: NonNullable<Emart24ProductSearchResponse['productList']>[number],
): Emart24Product {
return {
pluCd: item.pluCd || '',
goodsName: item.goodsNm || '',
Expand Down Expand Up @@ -139,6 +148,7 @@ export async function fetchEmart24Stores(
const { timeout = 15000 } = options;
const { keyword = '', area1 = '', area2 = '', page = 1, service24h = false } = params;
const keywordVariants = buildKeywordVariants(keyword);
const deadline = Date.now() + timeout;
let lastError: number | undefined;

for (let index = 0; index < keywordVariants.length; index += 1) {
Expand All @@ -159,9 +169,8 @@ export async function fetchEmart24Stores(
}

const body = await fetchJson<Emart24WebStoreResponse>(endpoint.toString(), {
...EMART24_DEFAULT_FETCH_OPTIONS,
...emart24FetchOptions(deadline - Date.now()),
method: 'GET',
timeout,
headers: EMART24_JSON_HEADERS,
});

Expand All @@ -188,13 +197,7 @@ export async function searchEmart24Products(
params: SearchEmart24ProductsParams,
options: RequestOptions = {},
): Promise<{ totalCount: number; products: Emart24Product[] }> {
const {
keyword,
page = 1,
pageSize = 10,
sortType = 'SALE',
saleProductYn = 'N',
} = params;
const { keyword, page = 1, pageSize = 10, sortType = 'SALE', saleProductYn = 'N' } = params;
const { timeout = 15000 } = options;

const form = new URLSearchParams();
Expand All @@ -207,16 +210,18 @@ export async function searchEmart24Products(
const body = await fetchJson<Emart24ProductSearchResponse>(
`${EMART24_API.EVERSE_BASE_URL}${EMART24_API.PRODUCT_SEARCH_PATH}`,
{
...EMART24_DEFAULT_FETCH_OPTIONS,
...emart24FetchOptions(timeout),
method: 'POST',
retryUnsafeMethods: true,
timeout,
headers: EMART24_FORM_HEADERS,
body: form.toString(),
},
);

const products = (body.productList || []).map(toProduct).filter((item) => item.pluCd.length > 0).slice(0, pageSize);
const products = (body.productList || [])
.map(toProduct)
.filter((item) => item.pluCd.length > 0)
.slice(0, pageSize);

return {
totalCount: toNumber(body.totalCnt),
Expand Down Expand Up @@ -246,9 +251,8 @@ export async function searchEmart24StockByStores(
endpoint.searchParams.set('bizNoArr', filteredBizNos.join(','));

return fetchJson<Emart24StockByStoreResponse>(endpoint.toString(), {
...EMART24_DEFAULT_FETCH_OPTIONS,
...emart24FetchOptions(timeout),
method: 'GET',
timeout,
headers: EMART24_JSON_HEADERS,
});
}
Expand All @@ -262,9 +266,8 @@ export async function fetchEmart24StoreDetail(
return fetchJson<Emart24StoreDetailResponse>(
`${EMART24_API.EVERSE_BASE_URL}${EMART24_API.STORE_DETAIL_PATH_PREFIX}${encodeURIComponent(bizNo)}`,
{
...EMART24_DEFAULT_FETCH_OPTIONS,
...emart24FetchOptions(timeout),
method: 'GET',
timeout,
headers: EMART24_JSON_HEADERS,
},
);
Expand Down
39 changes: 33 additions & 6 deletions src/services/oliveyoung/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,30 @@ import { OLIVEYOUNG_API } from './api.js';
import type { OliveyoungApiResponse } from './types.js';

const cooldown = createRelayCooldown();
const MAX_BUSY_RETRIES = 24;

/** 소비자의 일시 점유는 유한한 대기로 복구하고 취소 시 타이머를 정리합니다. */
function waitForBusy(delay: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const abort = () => {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
reject(new DOMException('Request cancelled', 'AbortError'));
};
const timer = setTimeout(() => {
signal?.removeEventListener('abort', abort);
resolve();
}, delay);
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) abort();
});
}

export interface OliveyoungRequestOptions {
/** 기존 호출부와의 호환용이며 유료 요청에는 사용하지 않습니다. */
apiKey?: string;
timeout?: number;
signal?: AbortSignal;
relayUrl?: string;
relayToken?: string;
accessClientId?: string;
Expand Down Expand Up @@ -81,12 +100,13 @@ export async function requestOliveyoung(
if (relayUrl) {
scope = await relayCredentialScope([relayUrl.replace(/\/$/, ''), relayToken, options.accessClientId, options.accessClientSecret]);
}
let retried = false;
let busyRetries = 0;
while (true) {
let result: OliveyoungApiResponse;
const cachedQuota = relayUrl ? cooldown.get(scope, consumer) : undefined;
try {
const remaining = deadline - Date.now();
if (options.signal?.aborted) throw new DOMException('Request cancelled', 'AbortError');
if (remaining <= 0) throw new DOMException('Request deadline exceeded', 'AbortError');
if (cachedQuota) throw new HttpError(429, '', '', new Headers({
'x-relay-quota-reason': cachedQuota.quotaReason,
Expand All @@ -99,18 +119,25 @@ export async function requestOliveyoung(
headers,
body: JSON.stringify(body),
timeout: remaining,
signal: options.signal,
retries: 0,
expectedStatus: 200,
});
} catch (error) {
if (relayUrl) {
if (error instanceof HttpError && error.quota) {
if (!cachedQuota) cooldown.set(scope, consumer, error.quota);
if (!retried && error.quota.quotaReason === 'consumer-busy' &&
deadline - Date.now() > 1000) {
retried = true;
await new Promise(resolve => setTimeout(resolve, 1000));
continue;
if (error.quota.quotaReason === 'consumer-busy' && busyRetries < MAX_BUSY_RETRIES) {
const delay = (busyRetries === 0 ? 1000 : 2000) + Math.floor(Math.random() * 250);
if (deadline - Date.now() > delay) {
busyRetries += 1;
try {
await waitForBusy(delay, options.signal);
} catch (cancelled) {
throw toOliveyoungRelayError(cancelled);
}
continue;
}
}
}
throw toOliveyoungRelayError(error);
Expand Down
Loading
Loading
0