diff --git a/docs/convenience-relay.md b/docs/convenience-relay.md index 6ffb012e..bb16f322 100644 --- a/docs/convenience-relay.md +++ b/docs/convenience-relay.md @@ -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)을 참조하세요. 재시도도 같은 편의점 원장을 사용합니다. diff --git a/docs/oliveyoung-free-relay.md b/docs/oliveyoung-free-relay.md index 274ae89d..cf607fef 100644 --- a/docs/oliveyoung-free-relay.md +++ b/docs/oliveyoung-free-relay.md @@ -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)을 참조하세요. diff --git a/docs/plans/2026-10-05-status-resilience.md b/docs/plans/2026-10-05-status-resilience.md new file mode 100644 index 00000000..04e16b8e --- /dev/null +++ b/docs/plans/2026-10-05-status-resilience.md @@ -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가 영구 제거되었다고 주장하지 않으며, 재시도 후에도 실패하면 오류를 전달합니다. 별도의 비용 서비스나 기기를 추가하지 않습니다. diff --git a/docs/status-resilience.md b/docs/status-resilience.md new file mode 100644 index 00000000..fc383d86 --- /dev/null +++ b/docs/status-resilience.md @@ -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으로 바꾸거나 확인하지 못한 최신 재고를 이전 값으로 대체하지 않습니다. diff --git a/src/api/handlers.ts b/src/api/handlers.ts index 817f06e1..65f6b023 100644 --- a/src/api/handlers.ts +++ b/src/api/handlers.ts @@ -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> | null = storeCheck ? null : { totalCount: 0, stores: [] }; @@ -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( { diff --git a/src/api/routes/cuRoutes.ts b/src/api/routes/cuRoutes.ts index c83d735e..0903dd8f 100644 --- a/src/api/routes/cuRoutes.ts +++ b/src/api/routes/cuRoutes.ts @@ -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), ), @@ -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), ), diff --git a/src/services/cu/client.ts b/src/services/cu/client.ts index fc886b7f..7aadaf6d 100644 --- a/src/services/cu/client.ts +++ b/src/services/cu/client.ts @@ -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; } @@ -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, diff --git a/src/services/cu/tools/checkInventory.ts b/src/services/cu/tools/checkInventory.ts index ed5cfbee..47b4aa99 100644 --- a/src/services/cu/tools/checkInventory.ts +++ b/src/services/cu/tools/checkInventory.ts @@ -69,8 +69,8 @@ async function checkInventory(args: CheckInventoryArgs): Promise> | null = storeLimit <= 0 ? { totalCount: 0, stores: [] } : null; let storeUnavailableReason: string | null = null; @@ -97,6 +97,8 @@ async function checkInventory(args: CheckInventoryArgs): Promise[number]): Em }; } -function toProduct(item: NonNullable[number]): Emart24Product { +function toProduct( + item: NonNullable[number], +): Emart24Product { return { pluCd: item.pluCd || '', goodsName: item.goodsNm || '', @@ -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) { @@ -159,9 +169,8 @@ export async function fetchEmart24Stores( } const body = await fetchJson(endpoint.toString(), { - ...EMART24_DEFAULT_FETCH_OPTIONS, + ...emart24FetchOptions(deadline - Date.now()), method: 'GET', - timeout, headers: EMART24_JSON_HEADERS, }); @@ -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(); @@ -207,16 +210,18 @@ export async function searchEmart24Products( const body = await fetchJson( `${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), @@ -246,9 +251,8 @@ export async function searchEmart24StockByStores( endpoint.searchParams.set('bizNoArr', filteredBizNos.join(',')); return fetchJson(endpoint.toString(), { - ...EMART24_DEFAULT_FETCH_OPTIONS, + ...emart24FetchOptions(timeout), method: 'GET', - timeout, headers: EMART24_JSON_HEADERS, }); } @@ -262,9 +266,8 @@ export async function fetchEmart24StoreDetail( return fetchJson( `${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, }, ); diff --git a/src/services/oliveyoung/transport.ts b/src/services/oliveyoung/transport.ts index f8849eb2..4177140f 100644 --- a/src/services/oliveyoung/transport.ts +++ b/src/services/oliveyoung/transport.ts @@ -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 { + 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; @@ -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, @@ -99,6 +119,7 @@ export async function requestOliveyoung( headers, body: JSON.stringify(body), timeout: remaining, + signal: options.signal, retries: 0, expectedStatus: 200, }); @@ -106,11 +127,17 @@ export async function requestOliveyoung( 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); diff --git a/src/utils/convenienceTransport.ts b/src/utils/convenienceTransport.ts index da8b5468..8ae489eb 100644 --- a/src/utils/convenienceTransport.ts +++ b/src/utils/convenienceTransport.ts @@ -2,7 +2,7 @@ import { ServiceError } from '../core/errors.js'; import { diagnosticHeaders } from './diagnostics.js'; import { isValidDtryxRelayUrl } from '../services/dtryx/transport.js'; -import { createTimeoutController } from './http.js'; +import { createTimeoutController, parseRetryAfterDelayMs } from './http.js'; import { parseRelayQuota } from './relayQuota.js'; export interface ConvenienceTransportOptions { convenienceRelayUrl?: string; @@ -66,55 +66,103 @@ export async function requestConvenienceRelay( headers['CF-Access-Client-Id'] = id!; headers['CF-Access-Client-Secret'] = secret!; } - const { controller, timeoutId } = createTimeoutController(timeout); + let requestBody: string | undefined; try { - const response = await fetch(`${url!.replace(/\/$/, '')}/v1/convenience/${operation}`, { - method: 'POST', - headers, - body: JSON.stringify(body), - redirect: 'manual', - signal: controller.signal, - }); - if (response.status !== 200) { - void response.body?.cancel().catch(() => undefined); - const status = response.status; - throw new ServiceError( - 'CONVENIENCE_RELAY_FAILED', - '편의점 릴레이 요청에 실패했습니다.', - status === 429 || status === 503 || status === 504 ? status : 502, - status === 429 || status >= 500, - status, - parseRelayQuota(status, response.headers), - ); - } - const data: unknown = await response.json(); - if ( - !data || - typeof data !== 'object' || - Array.isArray(data) || - ('success' in data && data.success === false) || - 'error' in data || - ('resp_cd' in data && data.resp_cd !== '0000') - ) - throw Error('Invalid response'); - return data as T; - } catch (error) { - if (error instanceof ServiceError) throw error; - if (controller.signal.aborted) + requestBody = JSON.stringify(body); + } catch { + throw new ServiceError( + 'CONVENIENCE_RELAY_FAILED', + '편의점 릴레이 요청에 실패했습니다.', + 502, + false, + ); + } + const retryRead = operation === 'gs25-stock' || operation === 'gs25-products'; + const budget = retryRead ? Math.min(timeout, 15000) : timeout; + const deadline = Date.now() + budget; + const maxAttempts = retryRead ? 2 : 1; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const remaining = deadline - Date.now(); + if (retryRead && remaining <= 0) { throw new ServiceError( 'CONVENIENCE_RELAY_TIMEOUT', '편의점 릴레이 요청 시간이 초과되었습니다.', 504, true, ); - throw new ServiceError( - 'CONVENIENCE_RELAY_FAILED', - '편의점 릴레이 요청에 실패했습니다.', - 502, - true, - ); - } finally { - clearTimeout(timeoutId); - controller.abort(); + } + const attemptTimeout = + retryRead && attempt === 1 ? Math.max(1, Math.floor((budget - 250) / 2)) : remaining; + const { controller, timeoutId } = createTimeoutController(Math.min(attemptTimeout, remaining)); + let readingResponse = false; + try { + const response = await fetch(`${url!.replace(/\/$/, '')}/v1/convenience/${operation}`, { + method: 'POST', + headers, + body: requestBody, + redirect: 'manual', + signal: controller.signal, + }); + if (response.status !== 200) { + void response.body?.cancel().catch(() => undefined); + const status = response.status; + const delay = parseRetryAfterDelayMs(response) ?? 250; + if ( + retryRead && + attempt < maxAttempts && + [502, 503, 504].includes(status) && + delay < deadline - Date.now() + ) { + clearTimeout(timeoutId); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + throw new ServiceError( + 'CONVENIENCE_RELAY_FAILED', + '편의점 릴레이 요청에 실패했습니다.', + status === 429 || status === 503 || status === 504 ? status : 502, + status === 429 || status >= 500, + status, + parseRelayQuota(status, response.headers), + ); + } + readingResponse = true; + const data: unknown = await response.json(); + if ( + !data || + typeof data !== 'object' || + Array.isArray(data) || + ('success' in data && data.success === false) || + 'error' in data || + ('resp_cd' in data && data.resp_cd !== '0000') + ) + throw Error('Invalid response'); + return data as T; + } catch (error) { + if (error instanceof ServiceError) throw error; + if (!readingResponse && retryRead && attempt < maxAttempts && 250 < deadline - Date.now()) { + clearTimeout(timeoutId); + await new Promise((resolve) => setTimeout(resolve, 250)); + continue; + } + if (controller.signal.aborted) + throw new ServiceError( + 'CONVENIENCE_RELAY_TIMEOUT', + '편의점 릴레이 요청 시간이 초과되었습니다.', + 504, + true, + ); + throw new ServiceError( + 'CONVENIENCE_RELAY_FAILED', + '편의점 릴레이 요청에 실패했습니다.', + 502, + true, + ); + } finally { + clearTimeout(timeoutId); + controller.abort(); + } } + /* v8 ignore next */ + throw new Error('편의점 릴레이 요청을 완료하지 못했습니다.'); } diff --git a/src/utils/http.ts b/src/utils/http.ts index 9029d867..03537273 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -7,6 +7,8 @@ import { diagnosticEvent } from './diagnostics.js'; export interface FetchOptions extends RequestInit { fetchImpl?: typeof fetch; timeout?: number; + /** 모든 시도와 대기 시간을 합친 제한 시간입니다. */ + totalTimeout?: number; /** fetchJson에서 허용할 정확한 HTTP 상태입니다. 생략하면 모든 2xx를 허용합니다. */ expectedStatus?: number; retries?: number; @@ -73,13 +75,23 @@ function isRetryableStatus(status: number, retryStatusCodes: number[]): boolean return retryStatusCodes.includes(status); } -function wait(ms: number): Promise { +function wait(ms: number, signal?: AbortSignal | null): Promise { + signal?.throwIfAborted(); if (ms <= 0) { return Promise.resolve(); } - return new Promise((resolve) => { - setTimeout(resolve, ms); + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + reject(signal?.reason); + }; + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal?.addEventListener('abort', onAbort, { once: true }); }); } @@ -95,7 +107,7 @@ function canRetryMethod( return retryUnsafeMethods || retryMethods.map((item) => item.toUpperCase()).includes(method); } -function parseRetryAfterDelayMs(response: Response): number | null { +export function parseRetryAfterDelayMs(response: Response): number | null { const retryAfter = response.headers.get('retry-after'); if (!retryAfter) { return null; @@ -121,6 +133,8 @@ async function requestWithTimeout( ): Promise { const { timeout = 10000, + totalTimeout, + signal: callerSignal, fetchImpl = fetch, retries = 0, retryDelayMs = 250, @@ -135,8 +149,15 @@ async function requestWithTimeout( const method = normalizeMethod(restOptions.method); const retryAllowed = canRetryMethod(method, retryMethods, retryUnsafeMethods); + const deadline = totalTimeout === undefined ? Infinity : Date.now() + totalTimeout; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const { controller, timeoutId } = createTimeoutController(timeout); + callerSignal?.throwIfAborted(); + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new DOMException('Request deadline exceeded', 'TimeoutError'); + const { controller, timeoutId } = createTimeoutController(Math.min(timeout, remaining)); + const signal = callerSignal + ? AbortSignal.any([controller.signal, callerSignal]) + : controller.signal; let readingResponse = false; const started = Date.now(); let responseStatus: number | undefined; @@ -144,17 +165,26 @@ async function requestWithTimeout( try { const response = await fetchImpl(url, { ...restOptions, - signal: controller.signal, + signal, }); responseStatus = response.status; - diagnosticEvent({ stage: 'http', outcome: response.ok ? 'ok' : 'error', status: response.status, durationMs: Date.now() - started }); + diagnosticEvent({ + stage: 'http', + outcome: response.ok ? 'ok' : 'error', + status: response.status, + durationMs: Date.now() - started, + }); if ( retryAllowed && attempt < maxAttempts && isRetryableStatus(response.status, retryStatusCodes) ) { const delayMs = parseRetryAfterDelayMs(response) ?? retryDelayMs; + if (delayMs >= deadline - Date.now()) { + readingResponse = true; + return await readResponse(response); + } onRetry?.({ attempt, maxAttempts, @@ -165,15 +195,26 @@ async function requestWithTimeout( url, }); await response.body?.cancel(); - await wait(delayMs); + await wait(delayMs, callerSignal); continue; } readingResponse = true; return await readResponse(response); } catch (error) { - diagnosticEvent({ stage: 'http', outcome: 'error', status: responseStatus, durationMs: Date.now() - started }); - if (readingResponse || !retryAllowed || attempt >= maxAttempts) { + diagnosticEvent({ + stage: 'http', + outcome: 'error', + status: responseStatus, + durationMs: Date.now() - started, + }); + callerSignal?.throwIfAborted(); + if ( + readingResponse || + !retryAllowed || + attempt >= maxAttempts || + retryDelayMs >= deadline - Date.now() + ) { throw error; } @@ -186,7 +227,7 @@ async function requestWithTimeout( method, url, }); - await wait(retryDelayMs); + await wait(retryDelayMs, callerSignal); } finally { clearTimeout(timeoutId); } @@ -211,7 +252,10 @@ async function readTextResponse(url: string, options: FetchOptions) { export async function fetchJson(url: string, options: FetchOptions = {}): Promise { const { response, body } = await readTextResponse(url, options); - if (!response.ok || (options.expectedStatus !== undefined && response.status !== options.expectedStatus)) { + if ( + !response.ok || + (options.expectedStatus !== undefined && response.status !== options.expectedStatus) + ) { throw new HttpError(response.status, response.statusText, body, response.headers); } diff --git a/tests/api/cu-relay-keyword.test.ts b/tests/api/cu-relay-keyword.test.ts index 50452c53..29f0ff2b 100644 --- a/tests/api/cu-relay-keyword.test.ts +++ b/tests/api/cu-relay-keyword.test.ts @@ -8,7 +8,7 @@ const env = { CONVENIENCE_RELAY_TOKEN: 'token', KAKAO_REST_API_KEY: 'test-key', }; -const html = `
강남예시점
서울특별시 강남구 테헤란로63길 9 1층 105호
`; +const html = `
강남예시점
서울특별시 강남구 테헤란로4길 6, 강남역센트럴푸르지오시티 (역삼동) B1층 110호
`; afterEach(() => vi.unstubAllGlobals()); function prepare(geocode = true) { const fetcher = vi.fn(async (url: string, init?: RequestInit) => { @@ -16,7 +16,7 @@ function prepare(geocode = true) { if (url.startsWith('https://dapi.kakao.com/')) return Response.json({ documents: geocode - ? [{ x: '127.03', y: '37.5', address_name: '서울 강남구 테헤란로63길 9' }] + ? [{ x: '127.03', y: '37.5', address_name: '서울 강남구 테헤란로4길 6' }] : [], }); if (url.endsWith('/cu-prime')) return Response.json({}); @@ -24,7 +24,7 @@ function prepare(geocode = true) { return Response.json({ data: { stockResult: { - result: { rows: [{ fields: { item_cd: '123', on_item_no: '456', item_nm: '커피' } }] }, + result: { rows: [{ fields: { item_cd: '123', on_item_no: '456', item_nm: '레쓰비' } }] }, }, }, }); @@ -33,7 +33,7 @@ function prepare(geocode = true) { return Response.json({ storeList: body.latVal === '37.5' - ? [{ storeCd: 'sample', storeNm: '강남예시점', stock: 3 }] + ? [{ storeCd: 'sample', storeNm: '강남예시점', stock: 48 }] : [ { storeCd: 'wrong', @@ -61,8 +61,8 @@ it('중계 설정이 있어도 좌표 없는 CU 매장 키워드는 공식 웹 expect(result.stores).toEqual([ expect.objectContaining({ storeName: '강남예시점', - address: '서울특별시 강남구 테헤란로63길 9 1층 105호', - stock: -1, + address: '서울특별시 강남구 테헤란로4길 6, 강남역센트럴푸르지오시티 (역삼동) B1층 110호', + stock: null, }), ]); expect(fetcher).toHaveBeenCalledTimes(1); @@ -75,14 +75,15 @@ it.each([true, false])( async (geocode) => { const fetcher = prepare(geocode); const response = await app.request( - '/api/cu/inventory?keyword=커피&storeKeyword=강남', + '/api/cu/inventory?keyword=레쓰비&storeKeyword=강남역&size=1&storeLimit=1', undefined, env, ); expect(response.status).toBe(200); const rest = await response.json(); + expect(rest.data.location).toEqual(geocode ? { latitude: 37.5, longitude: 127.03 } : null); expect(rest.data.nearbyStores.stores).toEqual([ - expect.objectContaining({ storeName: '강남예시점', stock: geocode ? 3 : -1 }), + expect.objectContaining({ storeName: '강남예시점', stock: geocode ? 48 : null }), ]); const client = new Client({ name: 'test', version: '1' }); await client.connect( @@ -93,12 +94,13 @@ it.each([true, false])( try { const result = await client.callTool({ name: 'cu_check_inventory', - arguments: { keyword: '커피', storeKeyword: '강남' }, + arguments: { keyword: '레쓰비', storeKeyword: '강남역', size: 1, storeLimit: 1 }, }); expect(result.isError).not.toBe(true); const content = result.content as Array<{ text: string }>; + expect(JSON.parse(content[0].text).location).toEqual(geocode ? { latitude: 37.5, longitude: 127.03 } : null); expect(JSON.parse(content[0].text).nearbyStores.stores).toEqual([ - expect.objectContaining({ storeName: '강남예시점', stock: geocode ? 3 : -1 }), + expect.objectContaining({ storeName: '강남예시점', stock: geocode ? 48 : null }), ]); const stockCalls = fetcher.mock.calls.filter(([url]) => url.endsWith('/cu-store')); expect(stockCalls).toHaveLength(geocode ? 2 : 0); @@ -106,7 +108,7 @@ it.each([true, false])( expect(JSON.parse(init!.body as string)).toMatchObject({ latVal: '37.5', longVal: '127.03', - searchWord: '강남', + searchWord: '강남역', itemCd: '123', onItemNo: '456', isRecommend: 'Y', @@ -119,14 +121,14 @@ it.each([true, false])( ); it.each([ ['/api/cu/stores?keyword=강남', 'cu-stores', 86400, 300], - ['/api/cu/inventory?keyword=커피&storeKeyword=강남', 'cu-inventory', 600, 60], + ['/api/cu/inventory?keyword=레쓰비&storeKeyword=강남역', 'cu-inventory', 600, 60], ])( 'CU 수정 후 일반 사용자 URL의 기존 잘못된 캐시를 우회한다 (%s)', async (path, prefix, ttl, swr) => { const fetcher = prepare(); const url = new URL(path as string, 'https://local.test'); const oldKey = new URL(url); - oldKey.searchParams.append('__cache_prefix', `${prefix}-v2`); + oldKey.searchParams.append('__cache_prefix', `${prefix}-v4`); const entries = new Map([ [oldKey.href, Response.json({ success: true, data: { storeName: '시청예시점', stock: 0 } })], ]); @@ -141,7 +143,7 @@ it.each([ expect(text).toContain('강남예시점'); expect(text).not.toContain('시청예시점'); const newKey = new URL(url); - newKey.searchParams.append('__cache_prefix', `${prefix}-v4`); + newKey.searchParams.append('__cache_prefix', `${prefix}-v5`); expect(match.mock.calls[0][0].url).toBe(newKey.href); expect(put.mock.calls[0][0].url).toBe(newKey.href); expect(response.headers.get('Cache-Control')).toContain(`max-age=${ttl}`); diff --git a/tests/api/emart24-handlers.test.ts b/tests/api/emart24-handlers.test.ts index ecc8aabd..6bfa881b 100644 --- a/tests/api/emart24-handlers.test.ts +++ b/tests/api/emart24-handlers.test.ts @@ -18,6 +18,7 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); + vi.useRealTimers(); }); function createMockContext(query: Record = {}) { @@ -336,9 +337,18 @@ describe.each([ ['0', 15000], ['-1', 15000], ])('요청 제한 %s를 %i ms로 적용한다', async (timeoutMs, expected) => { - mockFetch.mockResolvedValue(new Response(JSON.stringify({ error: 0, data: [] }))); - const timer = vi.spyOn(globalThis, 'setTimeout'); - await handler(createMockContext({ keyword: '강남', timeoutMs })); - expect(timer).toHaveBeenCalledWith(expect.any(Function), expected); + vi.useFakeTimers(); + mockFetch.mockImplementation(async (_url, init) => { + await new Promise((resolve) => init.signal.addEventListener('abort', () => resolve())); + throw new DOMException('aborted', 'AbortError'); + }); + let settled = false; + const result = handler(createMockContext({ keyword: '강남', timeoutMs })).then(() => { settled = true; }); + await vi.advanceTimersByTimeAsync(expected - 2); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(2); + await result; + expect(settled).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(2); }); }); diff --git a/tests/app/app-rate-limit-stats.test.ts b/tests/app/app-rate-limit-stats.test.ts index dc155449..38d017df 100644 --- a/tests/app/app-rate-limit-stats.test.ts +++ b/tests/app/app-rate-limit-stats.test.ts @@ -163,7 +163,6 @@ describe('GET /api/rate-limit/stats', () => { expect(new URL(fixture.calls[0]!.url).search).toBe( `?from=${from}&to=${to}&asOf=${to}&service=cgv`, ); - expect(Date.now).toHaveBeenCalledTimes(2); }, ); diff --git a/tests/app/followup-compare-recovery.test.ts b/tests/app/followup-compare-recovery.test.ts index af0eaa9a..2580f27e 100644 --- a/tests/app/followup-compare-recovery.test.ts +++ b/tests/app/followup-compare-recovery.test.ts @@ -23,14 +23,19 @@ it('이마트24 403 이후 같은 검색의 원본을 재호출해 정상 가격 expect(await first.json()).toMatchObject({ success: true, data: { results: [{ service: 'daiso' }], errors: [{ service: 'emart24', message: expect.stringContaining('403') }], } }); + const emartCalls = () => fetcher.mock.calls.filter(([input]) => String(input).includes('everse.emart24.co.kr')).length; + expect(emartCalls()).toBe(2); + expect(put).not.toHaveBeenCalled(); recovering = true; const recovered = await app.request(url); expect(await recovered.json()).toMatchObject({ success: true, data: { results: [{ service: 'emart24', name: '복구 콜라' }, { service: 'daiso' }], errors: [], bestPrice: { price: 1300 }, } }); expect(first.headers.get('Cache-Control')).toBe('no-store'); - expect(fetcher).toHaveBeenCalledTimes(4); + expect(fetcher).toHaveBeenCalledTimes(5); + expect(emartCalls()).toBe(3); expect(put).toHaveBeenCalledTimes(1); await app.request(url); - expect(fetcher).toHaveBeenCalledTimes(4); + expect(fetcher).toHaveBeenCalledTimes(5); + expect(emartCalls()).toBe(3); }); diff --git a/tests/relay/retry-quota.test.ts b/tests/relay/retry-quota.test.ts new file mode 100644 index 00000000..c5a39a9d --- /dev/null +++ b/tests/relay/retry-quota.test.ts @@ -0,0 +1,33 @@ +/** 재시도도 실제 중계 입구의 파일 원장을 소비하는지 확인합니다. */ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it, vi } from 'vitest'; +import { createConvenienceRelay } from '../../scripts/relay/convenience.js'; +import { CONVENIENCE_QUOTA_LIMITS, createFileQuota } from '../../scripts/relay/quota.js'; +import { requestConvenienceRelay } from '../../src/utils/convenienceTransport.js'; + +it('GS 읽기 재시도는 원장을 두 번 소비하고 후속 캐시 조회는 소비하지 않는다', async () => { + const directory = await mkdtemp(join(tmpdir(), 'relay-retry-quota-')); + const path = join(directory, 'quota.json'); + const upstream = vi.fn() + .mockResolvedValueOnce(new Response('', { status: 503 })) + .mockResolvedValueOnce(Response.json({ SearchQueryResult: {} })); + const quota = await createFileQuota(path, Date.now, CONVENIENCE_QUOTA_LIMITS); + const relay = createConvenienceRelay('test-token', { takeQuota: quota, fetcher: upstream }); + vi.stubGlobal('fetch', async (url: string, options?: RequestInit) => relay(new Request(url, options))); + try { + const options = { convenienceRelayUrl: 'https://relay.example', convenienceRelayToken: 'test-token' }; + expect(await requestConvenienceRelay('gs25-products', { query: 'cola' }, options)) + .toEqual({ SearchQueryResult: {} }); + expect(upstream).toHaveBeenCalledTimes(2); + expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(2); + expect(await requestConvenienceRelay('gs25-products', { query: 'cola' }, options)) + .toEqual({ SearchQueryResult: {} }); + expect(upstream).toHaveBeenCalledTimes(2); + expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(2); + } finally { + vi.unstubAllGlobals(); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/services/cu/client.test.ts b/tests/services/cu/client.test.ts index 98b4346b..6653c9aa 100644 --- a/tests/services/cu/client.test.ts +++ b/tests/services/cu/client.test.ts @@ -571,6 +571,7 @@ it.each([400, 403, 429])('키가 없는 재고 원본 %i 차단은 조회 불가 }); it.each([ + ['서울특별시 강남구 테헤란로4길 6, 강남역센트럴푸르지오시티 (역삼동) B1층 110호', '서울특별시 강남구 테헤란로4길 6', '서울 강남구 테헤란로4길 6'], ['서울특별시 강남구 테헤란로63길 9 1층 105호', '서울특별시 강남구 테헤란로63길 9', '서울 강남구 테헤란로63길 9'], ['서울특별시 강남구 도산대로 529 KRA프라자 (청담동) KRA프라자', '서울특별시 강남구 도산대로 529', '서울 강남구 도산대로 529'], ['서울특별시 강남구 논현로 201 (도곡동)', '서울특별시 강남구 논현로 201', '서울 강남구 논현로 201'], @@ -609,3 +610,19 @@ it('CU 주소의 인식할 수 없는 숫자 상세는 원문에 남긴다', asy expect(await geocodeCuAddress(address, { kakaoRestApiKey: 'test-key' })).toBeNull(); expect(new URL(mockFetch.mock.calls[0][0]).searchParams.get('query')).toBe(address); }); + +it.each([ + '서울특별시 강남구 테헤란로4길 6, 강남대로 111', + '서울특별시 강남구 테헤란로4길 6, (경기도 성남시 분당구 정자동 12-3)', + '서울특별시 강남구 테헤란로4길 6, 110', +])('쉼표 뒤 다른 주소나 알 수 없는 숫자는 원문을 유지한다 (%s)', async (address) => { + mockFetch.mockResolvedValue(Response.json({ documents: [{ x: '127.03', y: '37.5', address_name: '서울 강남구 테헤란로4길 6' }] })); + expect(await geocodeCuAddress(address, { kakaoRestApiKey: 'test-key' })).toBeNull(); + expect(new URL(mockFetch.mock.calls[0][0]).searchParams.get('query')).toBe(address); +}); +it.each(['서울 강남구 테헤란로5길 6', '서울 강남구 테헤란로4길 7', '서울 서초구 테헤란로4길 6'])( + '쉼표 상세를 제거해도 다른 도로·번호·지역은 거절한다 (%s)', async (canonical) => { + mockFetch.mockResolvedValue(Response.json({ documents: [{ x: '127.03', y: '37.5', address_name: canonical }] })); + expect(await geocodeCuAddress('서울특별시 강남구 테헤란로4길 6, 예시건물 B1층 110호', { kakaoRestApiKey: 'test-key' })).toBeNull(); + }, +); diff --git a/tests/services/emart24/tools/searchProducts.test.ts b/tests/services/emart24/tools/searchProducts.test.ts index d3333d43..8a403a27 100644 --- a/tests/services/emart24/tools/searchProducts.test.ts +++ b/tests/services/emart24/tools/searchProducts.test.ts @@ -45,7 +45,7 @@ describe('createSearchProductsTool', () => { }); it('외부 API 차단 시 스키마에 맞는 degraded 결과를 반환한다', async () => { - mockFetch.mockResolvedValue(new Response('blocked', { status: 403, statusText: 'Forbidden' })); + mockFetch.mockImplementation(async () => new Response('blocked', { status: 403, statusText: 'Forbidden' })); const tool = createSearchProductsTool(); const result = await tool.handler({ keyword: '커피', pageSize: 1 }); diff --git a/tests/services/oliveyoung/burst.test.ts b/tests/services/oliveyoung/burst.test.ts index 41adaf58..96e6c227 100644 --- a/tests/services/oliveyoung/burst.test.ts +++ b/tests/services/oliveyoung/burst.test.ts @@ -14,9 +14,9 @@ async function waitForSetup(ready: () => boolean) { } throw new Error('요청 준비가 완료되지 않았습니다.'); } -function fakeTime() { vi.useFakeTimers(); vi.setSystemTime(0); } +function fakeTime() { vi.useFakeTimers(); vi.setSystemTime(0); vi.spyOn(Math, 'random').mockReturnValue(0); } -it('busy는 1초 기다린 뒤 한 번만 재시도하며 유료 경로를 사용하지 않는다', async () => { +it('busy는 최소 1초 기다린 뒤 재시도하며 유료 경로를 사용하지 않는다', async () => { fakeTime(); const fetch = vi.fn().mockResolvedValueOnce(busy()).mockResolvedValueOnce(success()); vi.stubGlobal('fetch', fetch); @@ -31,7 +31,7 @@ it('busy는 1초 기다린 뒤 한 번만 재시도하며 유료 경로를 사 expect(fetch.mock.calls.every(([url]) => url === 'https://busy-success.example/v1/oliveyoung/p')).toBe(true); }); -it('반복 busy도 최대 두 번 호출하고 같은 소비자만 휴지한다', async () => { +it('반복 busy도 재시도 상한에서 멈추고 같은 소비자만 휴지한다', async () => { fakeTime(); vi.spyOn(console, 'error').mockImplementation(() => undefined); const fetch = vi.fn().mockImplementation(() => Promise.resolve(busy())); @@ -39,26 +39,26 @@ it('반복 busy도 최대 두 번 호출하고 같은 소비자만 휴지한다' const config = options('busy-twice'); const first = withDiagnostics(() => requestOliveyoung('/p', {}, config), '192.0.2.1').catch(e => e); await waitForSetup(() => vi.getTimerCount() > 0); - await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(47000); expect(await first).toMatchObject({ status: 429, quotaReason: 'consumer-busy', retryAfter: 1 }); - expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledTimes(25); fetch.mockResolvedValueOnce(success()); await expect(withDiagnostics(() => requestOliveyoung('/p', {}, config), '192.0.2.2')).resolves.toMatchObject({ status: 'SUCCESS' }); - expect(fetch).toHaveBeenCalledTimes(3); + expect(fetch).toHaveBeenCalledTimes(26); }); -it('이전 busy 휴지도 요청의 단일 재시도 예산 안에서 복구한다', async () => { +it('이전 busy 휴지도 대기 예산 안에서 새 busy를 재시도한다', async () => { fakeTime(); const config = { ...options('busy-cached'), timeout: 500 }; - const fetch = vi.fn().mockResolvedValueOnce(busy()).mockResolvedValueOnce(busy()); + const fetch = vi.fn().mockResolvedValueOnce(busy()).mockResolvedValueOnce(busy()).mockResolvedValueOnce(success()); vi.stubGlobal('fetch', fetch); await expect(requestOliveyoung('/p', {}, config)).rejects.toMatchObject({ quotaReason: 'consumer-busy' }); const result = requestOliveyoung('/p', {}, { ...config, timeout: 5000 }).catch(e => e); await waitForSetup(() => vi.getTimerCount() > 0); expect(fetch).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(1000); - expect(await result).toMatchObject({ quotaReason: 'consumer-busy' }); - expect(fetch).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(3000); + expect(await result).toMatchObject({ status: 'SUCCESS' }); + expect(fetch).toHaveBeenCalledTimes(3); }); it('첫 호출 시간과 재시도 대기 시간을 명시한 전체 deadline에 합산한다', async () => { @@ -149,3 +149,119 @@ it.each(['2', '86400'])('지원하지 않는 busy 대기 %s는 시간이 지나 expect(fetch).toHaveBeenCalledTimes(2); expect(vi.getTimerCount()).toBe(0); }); + + +it('33초간 소비자가 사용 중이어도 원래 60초 예산에서 성공한다', async () => { + fakeTime(); + const fetch = vi.fn().mockImplementation(() => Promise.resolve(Date.now() < 33000 ? busy() : success())); + vi.stubGlobal('fetch', fetch); + const result = requestOliveyoung('/p', {}, options('long-overlap')).catch(e => e); + await waitForSetup(() => vi.getTimerCount() > 0); + await vi.advanceTimersByTimeAsync(33000); + expect(await result).toEqual({ status: 'SUCCESS' }); + expect(fetch).toHaveBeenCalledTimes(18); + expect(vi.getTimerCount()).toBe(0); +}); + +it('busy가 계속되면 남은 deadline보다 긴 대기를 시작하지 않는다', async () => { + fakeTime(); + const fetch = vi.fn().mockImplementation(() => Promise.resolve(busy())); + vi.stubGlobal('fetch', fetch); + const result = requestOliveyoung('/p', {}, { ...options('retry-deadline'), timeout: 4000 }).catch(e => e); + await waitForSetup(() => vi.getTimerCount() > 0); + await vi.advanceTimersByTimeAsync(3000); + expect(await result).toMatchObject({ status: 429, quotaReason: 'consumer-busy', retryAfter: 1 }); + expect(fetch).toHaveBeenCalledTimes(3); + expect(vi.getTimerCount()).toBe(0); +}); + +it.each(['minute', 'daily', 'consumer'])('실제 %s 한도는 재시도하지 않고 같은 범위의 휴지를 보존한다', async reason => { + fakeTime(); + const config = options(`quota-${reason}`); + const fetch = vi.fn().mockImplementation(() => Promise.resolve(new Response('', { status: 429, headers: { + 'x-relay-quota-reason': reason, 'retry-after': '60', + } }))); + vi.stubGlobal('fetch', fetch); + await expect(requestOliveyoung('/p', {}, config)).rejects.toMatchObject({ quotaReason: reason, retryAfter: 60 }); + await expect(requestOliveyoung('/p', {}, config)).rejects.toMatchObject({ quotaReason: reason, retryAfter: 60 }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); +}); + +it('busy 대기에 작은 무작위 여유를 더해 동시 재시도를 분산한다', async () => { + fakeTime(); + vi.mocked(Math.random).mockReturnValue(0.5); + const fetch = vi.fn().mockResolvedValueOnce(busy()).mockResolvedValueOnce(success()); + vi.stubGlobal('fetch', fetch); + const result = requestOliveyoung('/p', {}, options('jitter')).catch(e => e); + await waitForSetup(() => vi.getTimerCount() > 0); + await vi.advanceTimersByTimeAsync(1124); + expect(fetch).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(await result).toEqual({ status: 'SUCCESS' }); +}); + +it('취소된 busy 대기는 새 요청을 보내거나 타이머를 남기지 않는다', async () => { + fakeTime(); + const controller = new AbortController(); + const fetch = vi.fn().mockResolvedValueOnce(busy()); + vi.stubGlobal('fetch', fetch); + const result = requestOliveyoung('/p', {}, { ...options('cancel-wait'), signal: controller.signal }).catch(e => e); + await waitForSetup(() => vi.getTimerCount() > 0); + controller.abort(); + expect(await result).toMatchObject({ code: 'OLIVEYOUNG_RELAY_TIMEOUT' }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); +}); + +it('이미 취소된 요청은 릴레이 네트워크에 보내지 않는다', async () => { + const controller = new AbortController(); + controller.abort(); + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + await expect(requestOliveyoung('/p', {}, { ...options('cancelled-before'), signal: controller.signal })).rejects.toMatchObject({ code: 'OLIVEYOUNG_RELAY_TIMEOUT' }); + expect(fetch).not.toHaveBeenCalled(); +}); + +it('busy 뒤 응답 본문 읽기도 남은 전체 deadline에서 중단한다', async () => { + fakeTime(); + const fetch = vi.fn().mockResolvedValueOnce(busy()).mockImplementationOnce((_url, init) => Promise.resolve({ + status: 200, ok: true, + text: () => new Promise((_resolve, reject) => init.signal.addEventListener('abort', () => reject(new DOMException('timeout', 'AbortError')))), + })); + vi.stubGlobal('fetch', fetch); + const result = requestOliveyoung('/p', {}, { ...options('busy-body-deadline'), timeout: 1500 }).catch(e => e); + await waitForSetup(() => vi.getTimerCount() > 0); + await vi.advanceTimersByTimeAsync(1499); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls[1][1].signal.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(await result).toMatchObject({ code: 'OLIVEYOUNG_RELAY_TIMEOUT' }); + expect(vi.getTimerCount()).toBe(0); +}); + +it('실제 릴레이 요청 중 취소해도 busy 재시도를 시작하지 않는다', async () => { + fakeTime(); + const controller = new AbortController(); + const fetch = vi.fn().mockImplementation((_url, init) => new Promise((_resolve, reject) => init.signal.addEventListener('abort', () => reject(new DOMException('cancelled', 'AbortError'))))); + vi.stubGlobal('fetch', fetch); + const result = requestOliveyoung('/p', {}, { ...options('cancel-fetch'), signal: controller.signal }).catch(e => e); + await waitForSetup(() => vi.getTimerCount() > 0); + controller.abort(); + expect(await result).toMatchObject({ code: 'OLIVEYOUNG_RELAY_TIMEOUT' }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); +}); + +it('busy 응답이 도착하는 순간 취소된 요청도 즉시 대기를 정리한다', async () => { + fakeTime(); + const controller = new AbortController(); + const fetch = vi.fn().mockImplementation(() => { + controller.abort(); + return Promise.resolve(busy()); + }); + vi.stubGlobal('fetch', fetch); + await expect(requestOliveyoung('/p', {}, { ...options('cancel-busy-response'), signal: controller.signal })).rejects.toMatchObject({ code: 'OLIVEYOUNG_RELAY_TIMEOUT' }); + expect(fetch).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/tests/utils/readResilience.test.ts b/tests/utils/readResilience.test.ts new file mode 100644 index 00000000..b460d4d1 --- /dev/null +++ b/tests/utils/readResilience.test.ts @@ -0,0 +1,313 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { fetchJson } from '../../src/utils/http.js'; +import { requestConvenienceRelay } from '../../src/utils/convenienceTransport.js'; +import { fetchEmart24Stores } from '../../src/services/emart24/client.js'; +const relay = { convenienceRelayUrl: 'https://relay.example', convenienceRelayToken: 'token' }; +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +it.each([502, 503, 504])('GS 읽기는 HTTP %i 후 한 번만 새 릴레이 요청을 보낸다', async (status) => { + vi.useFakeTimers(); + const cancel = vi.fn(); + const fetcher = vi + .fn() + .mockResolvedValueOnce(new Response(new ReadableStream({ cancel }), { status })) + .mockResolvedValueOnce(Response.json({ stores: [] })); + vi.stubGlobal('fetch', fetcher); + const result = requestConvenienceRelay('gs25-stock', { keyword: '강남' }, relay); + await vi.advanceTimersByTimeAsync(250); + expect(await result).toEqual({ stores: [] }); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(cancel).toHaveBeenCalledOnce(); + expect(fetcher.mock.calls[0][1].body).toBe(fetcher.mock.calls[1][1].body); +}); + +it.each([401, 403, 429, 400, 500])('GS HTTP %i는 재시도하지 않는다', async (status) => { + const fetcher = vi.fn( + async () => + new Response('', { + status, + headers: { 'x-relay-quota-reason': 'daily', 'retry-after': '86400' }, + }), + ); + vi.stubGlobal('fetch', fetcher); + await expect(requestConvenienceRelay('gs25-stock', {}, relay)).rejects.toMatchObject({ + upstreamStatus: status, + }); + expect(fetcher).toHaveBeenCalledOnce(); +}); + +it.each(['cu-prime', 'seven-stock', 'gs25-write', 'unknown'])( + '%s 작업은 재시도 허용 목록 밖이다', + async (operation) => { + const fetcher = vi.fn(async () => new Response('', { status: 502 })); + vi.stubGlobal('fetch', fetcher); + await expect(requestConvenienceRelay(operation, {}, relay)).rejects.toThrow(); + expect(fetcher).toHaveBeenCalledOnce(); + }, +); + +it.each(['{broken', '{"success":false}'])('GS 잘못된 본문 %s는 재시도하지 않는다', async (body) => { + const fetcher = vi.fn(async () => new Response(body)); + vi.stubGlobal('fetch', fetcher); + await expect(requestConvenienceRelay('gs25-stock', {}, relay)).rejects.toThrow(); + expect(fetcher).toHaveBeenCalledOnce(); +}); + +it('GS 연결 실패는 한 번만 재시도한다', async () => { + vi.useFakeTimers(); + const fetcher = vi + .fn() + .mockRejectedValueOnce(new TypeError('network')) + .mockResolvedValueOnce(Response.json({ stores: [] })); + vi.stubGlobal('fetch', fetcher); + const result = requestConvenienceRelay('gs25-products', {}, relay); + await vi.advanceTimersByTimeAsync(250); + expect(await result).toEqual({ stores: [] }); + expect(fetcher).toHaveBeenCalledTimes(2); +}); + +it('GS Retry-After가 남은 예산보다 길면 즉시 실패한다', async () => { + const fetcher = vi.fn( + async () => new Response('', { status: 503, headers: { 'retry-after': '60' } }), + ); + vi.stubGlobal('fetch', fetcher); + await expect(requestConvenienceRelay('gs25-stock', {}, relay)).rejects.toMatchObject({ + upstreamStatus: 503, + }); + expect(fetcher).toHaveBeenCalledOnce(); +}); + +it('이마트24 공개 매장 읽기는 403을 한 번 재시도한다', async () => { + vi.useFakeTimers(); + const fetcher = vi + .fn() + .mockResolvedValueOnce(new Response('', { status: 403 })) + .mockResolvedValueOnce(Response.json({ error: 0, count: 0, data: [] })); + vi.stubGlobal('fetch', fetcher); + const result = fetchEmart24Stores({ keyword: '강남' }); + await vi.advanceTimersByTimeAsync(250); + expect((await result).stores).toEqual([]); + expect(fetcher).toHaveBeenCalledTimes(2); +}); + +it('이마트24 첫 제한 시간 이후 15초 안에 두 번째 읽기를 완료한다', async () => { + vi.useFakeTimers(); + const fetcher = vi + .fn() + .mockImplementationOnce(async (_url, init) => { + await new Promise((resolve) => init.signal.addEventListener('abort', () => resolve())); + throw new DOMException('aborted', 'AbortError'); + }) + .mockResolvedValueOnce(Response.json({ error: 0, count: 0, data: [] })); + vi.stubGlobal('fetch', fetcher); + const result = fetchEmart24Stores({ keyword: '강남' }); + let settled = false; + const observed = result.then((value) => { + settled = true; + return value; + }); + await vi.advanceTimersByTimeAsync(15000); + expect(settled).toBe(true); + await observed; + expect(fetcher).toHaveBeenCalledTimes(2); +}); + +it('공유 HTTP 전체 예산은 다음 시도의 제한 시간을 남은 시간으로 줄인다', async () => { + vi.useFakeTimers(); + const fetcher = vi.fn(async (_url, init) => { + await new Promise((resolve) => init.signal.addEventListener('abort', () => resolve())); + throw new DOMException('aborted', 'AbortError'); + }); + vi.stubGlobal('fetch', fetcher); + const result = fetchJson('https://example.com', { + timeout: 7000, + totalTimeout: 10000, + retries: 1, + }); + const observed = expect(result).rejects.toMatchObject({ name: 'AbortError' }); + await vi.advanceTimersByTimeAsync(10000); + await observed; + expect(fetcher).toHaveBeenCalledTimes(2); +}); + +it('호출자 취소는 재시도 없이 원래 이유로 실패한다', async () => { + const caller = new AbortController(); + caller.abort(new Error('caller cancelled')); + const fetcher = vi.fn(); + await expect( + fetchJson('https://example.com', { fetchImpl: fetcher, signal: caller.signal, retries: 1 }), + ).rejects.toThrow('caller cancelled'); + expect(fetcher).not.toHaveBeenCalled(); +}); + +it('GS 입력 직렬화 실패는 재시도하지 않는다', async () => { + vi.useFakeTimers(); + const body: { self?: unknown } = {}; + body.self = body; + const fetcher = vi.fn(); + vi.stubGlobal('fetch', fetcher); + const result = requestConvenienceRelay('gs25-stock', body, relay); + let settled = false; + const observed = result.catch((error) => { + settled = true; + expect(error.message).toContain('실패'); + }); + await vi.advanceTimersByTimeAsync(0); + expect(settled).toBe(true); + await observed; + expect(vi.getTimerCount()).toBe(0); + expect(fetcher).not.toHaveBeenCalled(); +}); + +it('GS 멈춘 연결은 두 번 시도해도 전체 15초를 넘기지 않는다', async () => { + vi.useFakeTimers(); + const fetcher = vi.fn(async (_url, init) => { + await new Promise((resolve) => init.signal.addEventListener('abort', () => resolve())); + throw new DOMException('aborted', 'AbortError'); + }); + vi.stubGlobal('fetch', fetcher); + const result = requestConvenienceRelay('gs25-stock', {}, relay, 20000); + const observed = expect(result).rejects.toMatchObject({ code: 'CONVENIENCE_RELAY_TIMEOUT' }); + await vi.advanceTimersByTimeAsync(15000); + await observed; + expect(fetcher).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); +}); + +it.each([401, 429])('이마트24 HTTP %i는 재시도하지 않는다', async (status) => { + const fetcher = vi.fn(async () => new Response('', { status })); + vi.stubGlobal('fetch', fetcher); + await expect(fetchEmart24Stores()).rejects.toMatchObject({ status }); + expect(fetcher).toHaveBeenCalledOnce(); +}); + +it('공유 HTTP 기본 정책은 403을 재시도하지 않는다', async () => { + const fetcher = vi.fn(async () => new Response('', { status: 403 })); + await expect( + fetchJson('https://example.com', { fetchImpl: fetcher, retries: 1 }), + ).rejects.toMatchObject({ status: 403 }); + expect(fetcher).toHaveBeenCalledOnce(); +}); + +it('공유 HTTP 전체 예산보다 긴 Retry-After는 대기하지 않는다', async () => { + const fetcher = vi.fn( + async () => new Response('busy', { status: 503, headers: { 'retry-after': '60' } }), + ); + await expect( + fetchJson('https://example.com', { fetchImpl: fetcher, totalTimeout: 15000, retries: 1 }), + ).rejects.toMatchObject({ status: 503 }); + expect(fetcher).toHaveBeenCalledOnce(); +}); + +it.each(['fetch', 'backoff'])('호출자 취소는 %s 중에도 원래 이유를 보존한다', async (stage) => { + vi.useFakeTimers(); + const caller = new AbortController(); + const fetcher = vi.fn(async (_url, init) => { + if (stage === 'backoff') return new Response('', { status: 503 }); + await new Promise((resolve) => init.signal.addEventListener('abort', () => resolve())); + throw new DOMException('aborted', 'AbortError'); + }); + const result = fetchJson('https://example.com', { + fetchImpl: fetcher, + signal: caller.signal, + retries: 1, + }); + const observed = expect(result).rejects.toThrow('caller cancelled'); + await vi.advanceTimersByTimeAsync(0); + caller.abort(new Error('caller cancelled')); + await observed; + expect(fetcher).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); +}); + +it('GS 성공 헤더 뒤 본문 제한 시간은 재시도하지 않는다', async () => { + vi.useFakeTimers(); + const fetcher = vi.fn( + async (_url, init) => + new Response( + new ReadableStream({ + start(controller) { + init.signal.addEventListener('abort', () => + controller.error(new DOMException('aborted', 'AbortError')), + ); + }, + }), + ), + ); + vi.stubGlobal('fetch', fetcher); + const result = requestConvenienceRelay('gs25-stock', {}, relay); + const observed = expect(result).rejects.toMatchObject({ code: 'CONVENIENCE_RELAY_TIMEOUT' }); + await vi.advanceTimersByTimeAsync(15000); + await observed; + expect(fetcher).toHaveBeenCalledOnce(); +}); + +it('GS 예산이 이미 소진되면 새 릴레이 요청을 보내지 않는다', async () => { + const fetcher = vi.fn(async () => Response.json({ stores: [] })); + vi.stubGlobal('fetch', fetcher); + await expect(requestConvenienceRelay('gs25-stock', {}, relay, 0)).rejects.toMatchObject({ + code: 'CONVENIENCE_RELAY_TIMEOUT', + }); + expect(fetcher).not.toHaveBeenCalled(); +}); + +it('공유 HTTP 소진된 전체 예산은 fetch 전에 실패한다', async () => { + const fetcher = vi.fn(); + await expect( + fetchJson('https://example.com', { fetchImpl: fetcher, totalTimeout: 0 }), + ).rejects.toMatchObject({ name: 'TimeoutError' }); + expect(fetcher).not.toHaveBeenCalled(); +}); + +it('호출자 신호가 있어도 정상 재시도 대기를 완료할 수 있다', async () => { + vi.useFakeTimers(); + const caller = new AbortController(); + const fetcher = vi + .fn() + .mockResolvedValueOnce(new Response('', { status: 503 })) + .mockResolvedValueOnce(Response.json({ ready: true })); + const result = fetchJson('https://example.com', { + fetchImpl: fetcher, + signal: caller.signal, + retries: 1, + }); + await vi.advanceTimersByTimeAsync(250); + expect(await result).toEqual({ ready: true }); + expect(vi.getTimerCount()).toBe(0); +}); + +it('공유 HTTP 정확한 성공 상태 요구는 다른 2xx를 거절한다', async () => { + await expect( + fetchJson('https://example.com', { + fetchImpl: async () => Response.json({ stores: [] }, { status: 201 }), + expectedStatus: 200, + }), + ).rejects.toMatchObject({ status: 201 }); +}); + + +it('이마트24 여러 키워드 변형도 하나의 15초 예산을 공유한다', async () => { + vi.useFakeTimers(); + const fetcher = vi.fn().mockImplementationOnce(async () => { + await new Promise(resolve => setTimeout(resolve, 6000)); + return Response.json({ error: 1 }); + }).mockImplementation(async (_url, init) => { + await new Promise(resolve => init.signal.addEventListener('abort', () => resolve())); + throw new DOMException('aborted', 'AbortError'); + }); + vi.stubGlobal('fetch', fetcher); + let settled = false; + const result = fetchEmart24Stores({ keyword: '강남 역' }).finally(() => { settled = true; }); + const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }); + await vi.advanceTimersByTimeAsync(14999); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await rejected; + expect(settled).toBe(true); + expect(fetcher).toHaveBeenCalledTimes(3); + expect(fetcher.mock.calls.map(([url]) => new URL(url).searchParams.get('search'))) + .toEqual(['강남 역', '강남역', '강남역']); +});