diff --git a/docs/oliveyoung-free-relay.md b/docs/oliveyoung-free-relay.md index 7256d77c..a01cff66 100644 --- a/docs/oliveyoung-free-relay.md +++ b/docs/oliveyoung-free-relay.md @@ -1,5 +1,13 @@ # 올리브영 무료 브라우저 릴레이 +## 2026-09-28 운영 연결과 용량 보호 + +Mac GUI 중계 → Tunnel → Access Service Auth → 운영 Worker 연결에서 상품·매장·재고와 MCP 상품 조회를 확인했습니다. 무인증은 Access403, Access 인증만 있는 요청은 릴레이401로 거절합니다. 설치 호스트가 재부팅되면 GUI 로그인이 필요합니다. 설치용 Cloudflare API 토큰의 만료와 상시 실행용 Tunnel/Access 자격증명의 수명은 별개입니다. + +재고 보강은 요청당 최대 5개 상품에 적용하고 상품 2개씩 처리합니다. 더 큰 `stockCheckLimit`도 5로 제한합니다. 확인하지 못한 상품의 온라인 재고를 매장 재고로 바꾸지 않으며 `stockCheckedCount`/`stockUncheckedCount`로 구분합니다. 여러 사용자 요청을 합친 전체 중계 용량과 쿼터는 아래 제한을 따릅니다. + +릴레이 HTTP429·502·503은 API 상태 및 `diagnostics.upstreamStatus`에 보존합니다. timeout은504, 네트워크/잘못된 응답은502로 분류하며 `retryable`을 함께 제공합니다. 인증/리다이렉트/응답 형식 오류는 자동 재시도 대상으로 표시하지 않습니다. MCP는 `isError`와 text의 JSON 오류 진단을 반환하고 성공 스키마용 `structuredContent`는 넣지 않습니다. 응답 원문과 인증 토큰은 오류에 포함하지 않습니다. 자동 재시도나 유료 대체 경로는 추가하지 않았습니다. + ## 2026-09-21 Windows 로컬 검증 Windows 11의 설치된 Chrome을 별도 임시 컨텍스트로 실행하여 기존 `createBrowserRunner`와 `createOliveyoungRelay`를 검증했습니다. 개인 프로필·로그인 정보는 사용하지 않았습니다. Node 클라이언트가 `127.0.0.1`의 임시 포트에 생성한 인증 릴레이를 거쳐 브라우저에서 조회하는 전체 경로를 확인했습니다. @@ -27,6 +35,7 @@ Workers가 지원하지 않는 `redirect: 'error'` 대신 `manual`을 사용하 export OY_RELAY_TOKEN='<전용 임의 토큰>' # 선택 사항: 기본 포트 4319, Playwright 기본 Chromium 사용 export OY_RELAY_PORT=4319 +export OY_RELAY_STATE_DIR="$HOME/Library/Application Support/DaisoRelay/state" # export OY_BROWSER_EXECUTABLE='/absolute/path/to/chromium' npx tsx scripts/relay/start.ts ``` @@ -77,7 +86,7 @@ GitHub Actions의 `Sync Worker Secrets`는 위 네 OY secrets를 동기화하며 ## 상시 운영 보강 -중계는 한 브라우저·한 컨텍스트·한 페이지를 재사용합니다. 요청마다 탭을 만들지 않습니다. 추가 페이지는 즉시 회수하며 회수 실패·페이지 crash·Node 측 watchdog timeout에는 소유한 브라우저 세션을 폐기합니다. 200회 조회 또는 30분 사용 후 유휴 경계에서 정리하고 다음 요청에 새 세션을 만듭니다. +중계는 한 브라우저·한 컨텍스트·한 페이지를 재사용합니다. 요청마다 탭을 만들지 않습니다. 추가 페이지는 즉시 회수하며 회수 실패·페이지 crash·Node 측 watchdog timeout에는 소유한 브라우저 세션을 폐기합니다. 200회 조회 또는 30분 사용 후 유휴 경계에서 정리하고 교체 세션을 한 번 미리 준비합니다. RSS 한도 회수도 같은 준비 경로를 사용합니다. 이전 소유 프로세스의 회수가 완료된 다음 새 브라우저를 띄우므로 브라우저 두 개를 겹쳐 유지하지 않습니다. 준비 중 health 상태는 `starting`입니다. 준비 실패 시 무한 재시도하지 않고 다음 명시적 요청을 기다립니다. 준비 중 도착한 요청은 여전히 준비 시간을 기다릴 수 있으므로 항상 15초 이내 응답을 보장하는 것은 아닙니다. 브라우저는 별도 guard 프로세스가 소유합니다. 중계 부모가 SIGKILL로 종료되어도 IPC 단절을 감지해 회수합니다. PID·생성 시각·프로세스 그룹을 확인한 소유 프로세스만 종료합니다. macOS에서 별도 그룹으로 분리되는 crashpad 보조 프로세스도 실행별 고유 경로로 식별해 메모리 집계와 회수에 포함합니다. 브라우저에는 GUI 실행에 필요한 환경만 전달하고 운영 토큰은 제외합니다. 정상 종료가 지연되면 확인된 그룹을 강제 종료하고 실제 소멸을 확인합니다. 개인 Chrome을 프로세스 이름으로 일괄 종료하지 않습니다. guard까지 강제 종료되거나 소유권 확인이 실패한 경우 소유 마커를 유지하여 재시작을 차단합니다. 이 경우 운영자가 잔존 프로세스를 확인해야 합니다. 소유 확인 없이 마커 파일을 지우면 안 됩니다. @@ -89,6 +98,10 @@ GitHub Actions의 `Sync Worker Secrets`는 위 네 OY secrets를 동기화하며 Cloudflare Access 사용 시 Worker secrets에 `OY_ACCESS_CLIENT_ID`와 `OY_ACCESS_CLIENT_SECRET`을 함께 설정합니다. 기존 `OY_RELAY_TOKEN`과 별개입니다. 사용자 도구 입력으로 전달하거나 덮어쓸 수 없습니다. Access 비밀이 다른 주소로 전달되지 않도록 리다이렉트를 거절합니다. Access 정책은 해당 서비스 토큰만 허용하는 Service Auth로 구성하며, Tunnel은 localhost:4319와 최종 404 경로만 연결합니다. 브라우저 디버깅 주소와 개인 네트워크 경로는 연결하지 않습니다. +### 실행 계정 선택 + +전용 표준 계정은 개인 파일과 실행 권한을 분리하는 권장 구성입니다. 운영자가 격리 차이를 이해하고 선택하면 현재 로그인 계정의 사용자 LaunchAgent와 사용자 Library 경로로 실행할 수도 있습니다. 이 경우 개인 브라우저 프로필을 사용하지 않아도 프로세스의 파일 접근 권한은 해당 계정과 같습니다. 아래 설치 도구는 전용 계정용이며 현재 계정 설치를 자동 수행하지 않습니다. + ### macOS 전용 계정 설치 시스템 설정에서 일반 사용자 계정 `daisorelay`를 만듭니다. 관리자 계정은 설치 스크립트가 거절합니다. 코드와 전용 브라우저는 `/Library/Application Support/DaisoRelay`에 root 소유로 설치하고, 토큰과 상태는 전용 계정 홈에만 둡니다. 자동 로그인은 설정하지 않습니다. 계정 생성과 최초 GUI 로그인은 사용자가 직접 수행해야 합니다. diff --git a/docs/superpowers/plans/2026-09-28-dtryx-comparison.md b/docs/superpowers/plans/2026-09-28-dtryx-comparison.md new file mode 100644 index 00000000..80eeda46 --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-dtryx-comparison.md @@ -0,0 +1,14 @@ +# 디트릭스 원격 비교 — 2026-09-28 + +프로덕션을 바꾸지 않고 인증 토큰이 필요한 임시 Worker를 만들었다. 임의 URL은 받지 않으며 443/30443과 극장000072/000067만 허용했다. compatibility_date2025-01-23, 동일 헤더·cache:no-store·6초 제한, 요청 순서를 교대로 적용했다. + +| 회차 | 극장 | 443 | 30443 | +|---|---|---|---| +| 1 | 000072 | 6000ms timeout | 6000ms timeout | +| 2 | 000067 | 199ms 성공/14건 | 6000ms timeout | +| 3 | 000072 | 6000ms timeout | 6000ms timeout | +| 4 | 000067 | 6000ms timeout | 6000ms timeout | + +443 1/4, 30443 0/4다. 한 번의 성공은 지속적 복구나 포트 원인을 입증하지 않는다. 실패는 모두 응답 헤더 이전이었다. 9/19·9/21 양 포트 실패 기록과 일치하며 #188을 영구 복구로 반영하지 않는다. 모든 시험 뒤 임시 Worker를 삭제했다. 새 배포의 라우트 전파 전404가 발생한 최초 시도도 finally에서 삭제한 뒤, 인증 거절401로 준비를 확인하도록 보강해 위 실험을 수행했다. + +별도 현 운영 좌석 조회3회는 503/15.65s, 503/15.65s, 200/0.82s였다. 오류를200으로 바꾸거나 15초 제한을 늘리는 변경은 하지 않았다. 다음 단계는 업스트림의 Cloudflare 출발 연결 로그와 동일 시각 TCP/TLS/헤더 지연을 대조하는 것이다. diff --git a/docs/superpowers/plans/2026-09-28-maintenance.md b/docs/superpowers/plans/2026-09-28-maintenance.md new file mode 100644 index 00000000..1eab2609 --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-maintenance.md @@ -0,0 +1,15 @@ +# 9월 28일 유지보수 실행 계획 + +## 승인된 목표 +올리브영 중계의 요청 용량과 원인 진단, 브라우저 회수 후 첫 요청을 보강하고 열린 의존성 PR을 반영한다. 디트릭스는 통제된 원격 시험에서 입증된 수정만 적용한다. 유료 Zyte는 비활성 상태를 유지한다. + +## 변경 단위와 검증 +- [x] client.ts 및 관련 도구/핸들러: 상품별 재고 요청 동시성을 제한하고 확인 개수 상한을 적용. 실패가 온라인 재고와 혼동되지 않도록 기존 unchecked 정보를 유지. 대량 입력과 병렬 활성 개수 테스트 먼저 작성. +- [x] transport.ts/core errors: 릴레이 429/502/503과 timeout/네트워크를 원인·HTTP 상태·재시도 가능 여부로 보존. API/MCP 기존 오류 계약을 유지하고 회귀 테스트 추가. +- [x] relay lifecycle: 30분/200회/RSS 회수 뒤 요청이 냉간 기동 전체를 기다리지 않도록 유휴 시 준비. 닫기·강제종료·중복 준비·실패 루프를 시험. 한 브라우저/한 탭과 guard 소유 회수 유지. +- [x] PR193~199 lockfile 갱신 통합. 전체 check/build/coverage100%/audit 및 Wrangler dry-run. +- [x] Dtryx443/30443 원격 비교와 기존 관측 대조. 근거가 없으면 #188을 보류하고 결과만 기록. +- [ ] 독립 코드 검토, GitHub CI 이후 통합 PR 병합·Worker 배포·릴리스. hm 설치본을 안전하게 교체하고 실제 MCP·재고·Access 보호·프로세스 회수 확인. + +## 경계 +KV/유료 API/무제한 재시도는 추가하지 않는다. 로컬 구동 파일은 사용자 Library의 배포 복사본이며 작업 worktree와 분리한다. 원본 main은 결과 통합 전 유지한다. 이슈/기여자 대화는 별도 메시지 전송 승인 없이 게시하지 않는다. diff --git a/package-lock.json b/package-lock.json index 69ba4cf6..2f422369 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daiso", - "version": "1.2.2", + "version": "1.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daiso", - "version": "1.2.2", + "version": "1.2.3", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", @@ -327,9 +327,9 @@ } }, "node_modules/@cloudflare/unenv-preset": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", - "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", "dev": true, "license": "MIT OR Apache-2.0", "peerDependencies": { @@ -343,9 +343,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260918.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260918.1.tgz", - "integrity": "sha512-H5Em6Wd0jjxaloYh2rp+WLBl2eWbkk7nSP1svGt6K1RSv/rNVqmKdpjZPJTBSavOmeYGa88qvtOWwS+33OHTqQ==", + "version": "1.20260923.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260923.1.tgz", + "integrity": "sha512-sFMyHjABspfVxAf9bmcRdYWm3rO5Vrmhb3fLboMB5Lt+Eo+X1Fj4n/3hgMYmXb6P+zW+cjWjZdH1ilE+Ad5kDw==", "cpu": [ "x64" ], @@ -360,9 +360,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260918.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260918.1.tgz", - "integrity": "sha512-CR9JRZEQo93fNgBVF4Df2H2/VYO4n7rxSseSwCVv3bJQEb0huADUSCj2FK4ipxar8ToOEB4wawyw0vJo5U/rQQ==", + "version": "1.20260923.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260923.1.tgz", + "integrity": "sha512-t/O1jcuag+JO5VnFblf20B960FU/WSmn/n2Lcy021cBUhWnhApO9yx1VjJ7I4KhS09yXLgC2shHTzB9EnbSQpg==", "cpu": [ "arm64" ], @@ -377,9 +377,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260918.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260918.1.tgz", - "integrity": "sha512-UQ2nnY3qpXLzQ80frmWO+8HvtqyWaQILe8QYZwpemdjT+sqwCz4Dz+0/WVkFco0v/04kIIqikVeLTY/7gEhmkw==", + "version": "1.20260923.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260923.1.tgz", + "integrity": "sha512-6X9fp5vFBJG0BXrjOcB4yqyqZRtZ/Jx4KRAmEp5FPxuNUbf3n6ogzZvD2s0YS+DC2zNKb0gd1lIiOye7AaGnvA==", "cpu": [ "x64" ], @@ -394,9 +394,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260918.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260918.1.tgz", - "integrity": "sha512-4rib51MaLNWweUIUxM/Xj558M5QmyZoBSf0ffv+lYah5VTrvwnez3XGxX72pElnBKHROvAPOi5msQ5Ts8JSI0A==", + "version": "1.20260923.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260923.1.tgz", + "integrity": "sha512-esOXfxhDp2YPjZmMrO3OLa/9qAfa/uXQGwFGDyUWNF3aXkwE8fc6oauZS01UDk8YwuYlxsyjLQIKjJOrzJ2N+g==", "cpu": [ "arm64" ], @@ -411,9 +411,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260918.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260918.1.tgz", - "integrity": "sha512-sATrMx5ShYYgmgUGrcTmvsFSJBFuN95NEkX3xwb1qk8w1A6h5N11sea7yN2IeibwPyPmXKjWNjXOno0hZAM71Q==", + "version": "1.20260923.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260923.1.tgz", + "integrity": "sha512-Hlda2apkBcRtPQD4NnEZiEqKLIhR7au+8skF+tBHBa1jsX1/GfuYEdIR0HJ6He1qkSNkxD/Y0VXo+TNgUc1EUg==", "cpu": [ "x64" ], @@ -428,9 +428,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "5.20260919.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260919.1.tgz", - "integrity": "sha512-xUdKJoTHhNcXwnBrfrM1STkzm3oSUc9cRVzP8fO5NyJZ/UtnDnWZHDk9z8IkTnbC2WRWFAS87HICmqZhbHDvQw==", + "version": "5.20260925.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260925.1.tgz", + "integrity": "sha512-E/Z3J8CzyNk0sinBu8g/1HjMCViSxG1kd/ARL8nzieSEdqtYZmgdnhlXbi95xyc3oetTwBR/r604/EuwuP6cnQ==", "dev": true, "license": "MIT OR Apache-2.0" }, @@ -1731,9 +1731,9 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.1.tgz", + "integrity": "sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", @@ -1838,19 +1838,6 @@ "supports-color": "^10.0.0" } }, - "node_modules/@poppinss/dumper/node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/@poppinss/exception": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", @@ -2252,17 +2239,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", - "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", + "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/type-utils": "8.70.0", - "@typescript-eslint/utils": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/type-utils": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2275,15 +2262,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.70.0", + "@typescript-eslint/parser": "^8.70.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", - "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", + "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", "dev": true, "license": "MIT", "engines": { @@ -2291,16 +2278,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", - "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz", + "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3" }, "engines": { @@ -2316,14 +2303,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", - "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz", + "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.70.0", - "@typescript-eslint/types": "^8.70.0", + "@typescript-eslint/tsconfig-utils": "^8.70.1", + "@typescript-eslint/types": "^8.70.1", "debug": "^4.4.3" }, "engines": { @@ -2338,14 +2325,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", - "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz", + "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0" + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2356,9 +2343,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", - "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz", + "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", "dev": true, "license": "MIT", "engines": { @@ -2373,15 +2360,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", - "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz", + "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2398,9 +2385,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", - "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz", + "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==", "dev": true, "license": "MIT", "engines": { @@ -2412,16 +2399,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", - "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz", + "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.70.0", - "@typescript-eslint/tsconfig-utils": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/project-service": "8.70.1", + "@typescript-eslint/tsconfig-utils": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2440,16 +2427,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", - "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz", + "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0" + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2464,13 +2451,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", - "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz", + "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/types": "8.70.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3817,9 +3804,9 @@ } }, "node_modules/hono": { - "version": "4.13.8", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.8.tgz", - "integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==", + "version": "4.13.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.9.tgz", + "integrity": "sha512-7dMkQmZoC4E6F7AtaQSPhlWAdnBti+j7rreMZl8QB4jFiEhP9TWbGWUMi8WYzBCgmgulxuvLQupKqo+Co6Omyg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -4642,16 +4629,16 @@ } }, "node_modules/miniflare": { - "version": "5.20260918.0-alpha", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260918.0-alpha.tgz", - "integrity": "sha512-vyIes7yW/OTzHtz4GhcBCTohZfqk2eQNiIkngZ07VRA5Qu24aNgW/TrFwlPkMLMjWDQ8R4JJBBHR/KX+liSDtg==", + "version": "5.20260923.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260923.0-alpha.tgz", + "integrity": "sha512-tmICPUd2P+/4WNa2A9kE22SPZexlu+Dw8nZIvaXCzRj2OVu62hc751Xq4S1P4HEscT5viDle3zxyHyxT2XY3Fw==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", - "workerd": "1.20260918.1", + "workerd": "1.20260923.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, @@ -5063,9 +5050,9 @@ } }, "node_modules/prettier": { - "version": "3.9.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz", - "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==", + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", "dev": true, "license": "MIT", "bin": { @@ -5592,6 +5579,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/tinybench": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", @@ -5725,9 +5725,9 @@ "optional": true }, "node_modules/tsx": { - "version": "4.23.13", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", - "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -5801,16 +5801,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", - "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.1.tgz", + "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.70.0", - "@typescript-eslint/parser": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/utils": "8.70.0" + "@typescript-eslint/eslint-plugin": "8.70.1", + "@typescript-eslint/parser": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6131,9 +6131,9 @@ } }, "node_modules/workerd": { - "version": "1.20260918.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260918.1.tgz", - "integrity": "sha512-NsjfQlBNQ0iEniv/STOy4zbp8s5k60PzL1Ter02Eg44arbDhHtf6UOs03E31XDRmmBFxSIkAZuCyld3RKH1wqA==", + "version": "1.20260923.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260923.1.tgz", + "integrity": "sha512-1xynegnJJqkgk/zuhNVcOFzAJXzsJ4UxazfThUe0K80eZXKpsRIXI2sHcT4trswLfeHCc+3uWTBvtSWLFRozbQ==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -6144,11 +6144,11 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260918.1", - "@cloudflare/workerd-darwin-arm64": "1.20260918.1", - "@cloudflare/workerd-linux-64": "1.20260918.1", - "@cloudflare/workerd-linux-arm64": "1.20260918.1", - "@cloudflare/workerd-windows-64": "1.20260918.1" + "@cloudflare/workerd-darwin-64": "1.20260923.1", + "@cloudflare/workerd-darwin-arm64": "1.20260923.1", + "@cloudflare/workerd-linux-64": "1.20260923.1", + "@cloudflare/workerd-linux-arm64": "1.20260923.1", + "@cloudflare/workerd-windows-64": "1.20260923.1" } }, "node_modules/workers-mcp": { @@ -6178,20 +6178,20 @@ } }, "node_modules/wrangler": { - "version": "4.135.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.135.0.tgz", - "integrity": "sha512-WrNBQSfIG6YcILJcodYr5ty8vgkzGsV8YX+kfd+uZ5/Bd8cUW0GnUIRKAVfn5kYKqh1m/BPcji9h1d7mE8euFw==", + "version": "4.139.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.139.0.tgz", + "integrity": "sha512-9RtaWkIkxqntg58bGwNVRfGVSgf+cAq/45YzC87LN5YniCt4DDNrFM2gXK5SHMj3iG5YnDHhRVvh63FWnmJnaA==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", - "@cloudflare/unenv-preset": "2.16.1", + "@cloudflare/unenv-preset": "2.16.2", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", - "miniflare": "5.20260918.0-alpha", + "miniflare": "5.20260923.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260918.1" + "workerd": "1.20260923.1" }, "bin": { "cf-wrangler": "bin/cf-wrangler.js", @@ -6205,7 +6205,7 @@ "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^5.20260918.1" + "@cloudflare/workers-types": "^5.20260923.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { diff --git a/package.json b/package.json index 115bc85b..7e6175a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "daiso", - "version": "1.2.2", + "version": "1.2.3", "description": "Korean retail and cinema MCP server, CLI, and Codex Skill for Daiso, convenience stores, marts, Olive Young, and theaters", "main": "dist/index.js", "bin": { diff --git a/scripts/relay/lifecycle.ts b/scripts/relay/lifecycle.ts index 64c4836d..d3049849 100644 --- a/scripts/relay/lifecycle.ts +++ b/scripts/relay/lifecycle.ts @@ -37,6 +37,7 @@ export function createBrowserLifecycle(launch: () => Promise) { let reason = 'startup'; let timer: ReturnType | undefined; let initializing: Promise | undefined; + let replacementPending = false; const retire = (why: string) => { reason = why; if (timer) clearInterval(timer); @@ -95,17 +96,18 @@ export function createBrowserLifecycle(launch: () => Promise) { 32000, ); await bounded(page.waitForURL('**/store/**', { timeout: 30000 }), 32000); + if (closed || owner !== sessionOwner) throw new Error('Browser lifecycle unavailable'); timer = setInterval(() => { if (!active && Date.now() - born >= 30 * 60 * 1000) { rotations++; - retireQuietly('age-limit'); + rotate('age-limit'); return; } void bounded(sessionOwner.rss(), 5000) .then((value) => { if (owner !== sessionOwner) return; rssBytes = value; - if (value > 2 ** 30) retireQuietly('rss-limit'); + if (value > 2 ** 30) rotate('rss-limit'); }) .catch(() => { if (owner === sessionOwner) retireQuietly('rss-failed'); @@ -122,6 +124,16 @@ export function createBrowserLifecycle(launch: () => Promise) { }); return initializing; }; + // 교체당 한 번만 미리 준비하고 실패 시 다음 명시적 요청을 기다립니다. + const prepareReplacement = () => { + replacementPending = false; + void ensure().catch(() => undefined); + }; + const rotate = (why: string) => { + replacementPending = true; + retireQuietly(why); + if (!active) prepareReplacement(); + }; const run: BrowserRunner = async (path, body) => { if (active) throw new Error('Browser already active'); active = true; @@ -132,6 +144,7 @@ export function createBrowserLifecycle(launch: () => Promise) { totalCalls++; if (calls >= 200 || Date.now() - born >= 30 * 60 * 1000) { rotations++; + replacementPending = true; await retire('rotation'); } return result; @@ -140,6 +153,7 @@ export function createBrowserLifecycle(launch: () => Promise) { throw error; } finally { active = false; + if (replacementPending) prepareReplacement(); } }; return { @@ -152,7 +166,7 @@ export function createBrowserLifecycle(launch: () => Promise) { await cleanup; }, status: () => ({ - state: failed ? 'failed' : closed ? 'closed' : owner ? 'ready' : 'idle', + state: failed ? 'failed' : closed ? 'closed' : initializing ? 'starting' : owner ? 'ready' : 'idle', active, calls, totalCalls, diff --git a/src/api/handlers.ts b/src/api/handlers.ts index 53d08a9f..e28e414b 100644 --- a/src/api/handlers.ts +++ b/src/api/handlers.ts @@ -11,7 +11,8 @@ import { fetchOliveyoungStores, } from '../services/oliveyoung/client.js'; import { fetchCuStock, fetchCuStores, geocodeCuAddress } from '../services/cu/client.js'; -import { type ApiContext, errorResponse, successResponse } from './response.js'; +import { ServiceError } from '../core/errors.js'; +import { type ApiContext, errorResponse, serviceErrorResponse, successResponse } from './response.js'; export { handleCheckInventory, handleFindStores, @@ -81,6 +82,7 @@ export async function handleOliveyoungSearchProducts(c: ApiContext) { { total: result.totalCount, page, pageSize: size }, ); } catch (error) { + if (error instanceof ServiceError) return serviceErrorResponse(c, error, 'product_search'); const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'OLIVEYOUNG_PRODUCT_SEARCH_FAILED', message, 500); } @@ -124,6 +126,7 @@ export async function handleOliveyoungFindStores(c: ApiContext) { { total: result.totalCount, page: pageIdx, pageSize: limit } ); } catch (error) { + if (error instanceof ServiceError) return serviceErrorResponse(c, error, 'store_search'); const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'OLIVEYOUNG_STORE_SEARCH_FAILED', message, 500); } @@ -227,6 +230,7 @@ export async function handleOliveyoungCheckInventory(c: ApiContext) { { total: productResult.totalCount, page, pageSize: size } ); } catch (error) { + if (error instanceof ServiceError) return serviceErrorResponse(c, error, 'inventory_check'); const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'OLIVEYOUNG_INVENTORY_CHECK_FAILED', message, 500); } diff --git a/src/api/response.ts b/src/api/response.ts index 549a9732..fbe399e3 100644 --- a/src/api/response.ts +++ b/src/api/response.ts @@ -3,7 +3,7 @@ */ import type { Context } from 'hono'; -import { toStandardErrorDiagnostics } from '../core/errors.js'; +import { ServiceError, toServiceErrorDiagnostics, toStandardErrorDiagnostics } from '../core/errors.js'; export interface AppBindings { DAILY_RATE_LIMITER?: DurableObjectNamespace; @@ -72,3 +72,12 @@ export function errorResponse( status, ); } + +/** 원문이 제거된 외부 서비스 오류를 실제 상태와 함께 반환합니다. */ +export function serviceErrorResponse(c: ApiContext, error: ServiceError, operation: string) { + return c.json>({ + success: false, + error: { code: error.code, message: error.message }, + diagnostics: toServiceErrorDiagnostics(error, operation), + }, error.status); +} diff --git a/src/core/errors.ts b/src/core/errors.ts index 5da9acd6..e59b8234 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -15,6 +15,20 @@ export interface StandardErrorDiagnostics { hint: string; } +/** 외부 응답 원문 없이 API와 MCP에 전달할 오류 진단입니다. */ +export class ServiceError extends Error { + constructor( + readonly code: string, + message: string, + readonly status: 429 | 502 | 503 | 504, + readonly retryable: boolean, + readonly upstreamStatus?: number, + ) { + super(message); + this.name = 'ServiceError'; + } +} + const SERVICE_PREFIXES = [ 'daiso', 'gs25', @@ -129,3 +143,16 @@ export function toStandardErrorDiagnostics( export function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; } + +/** 전송 계층에서 확인한 재시도 가능 여부를 공통 진단에도 유지합니다. */ +export function toServiceErrorDiagnostics(error: ServiceError, operation: string): StandardErrorDiagnostics { + return { + ...toStandardErrorDiagnostics(error.code, error.message, { + status: error.status, + upstreamStatus: error.upstreamStatus, + operation, + }), + retryable: error.retryable, + hint: buildHint(error.retryable), + }; +} diff --git a/src/core/registry.ts b/src/core/registry.ts index 8bb42042..920d47fd 100644 --- a/src/core/registry.ts +++ b/src/core/registry.ts @@ -7,7 +7,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { ServiceProvider, ServiceFactory } from './interfaces.js'; import type { ServiceInfo, ToolRegistration } from './types.js'; -import { getErrorMessage } from './errors.js'; +import { getErrorMessage, ServiceError, toServiceErrorDiagnostics } from './errors.js'; import { createToolOutputSchema } from './outputSchema.js'; function parseJsonText(text: string): Record | null { @@ -227,7 +227,12 @@ export class ServiceRegistry { try { result = await tool.handler(args); } catch (error) { - const message = getErrorMessage(error); + const message = error instanceof ServiceError + ? JSON.stringify({ + error: { code: error.code, message: error.message }, + diagnostics: toServiceErrorDiagnostics(error, tool.name.replace(/^[^_]+_/, '')), + }) + : getErrorMessage(error); return { isError: true, content: [{ type: 'text' as const, text: message }], diff --git a/src/services/oliveyoung/client.ts b/src/services/oliveyoung/client.ts index 7a07b540..13e37df8 100644 --- a/src/services/oliveyoung/client.ts +++ b/src/services/oliveyoung/client.ts @@ -312,57 +312,64 @@ export async function enrichOliveyoungProductsWithNearbyStoreInventory( params: EnrichProductsParams, options: RequestOptions = {} ): Promise<{ checkedCount: number; products: OliveyoungProduct[] }> { - const maxProducts = Math.max(0, Math.min(products.length, params.maxProducts)); + const requestedLimit = Number.isFinite(params.maxProducts) ? Math.floor(params.maxProducts) : 5; + const maxProducts = Math.max(0, Math.min(products.length, requestedLimit, 5)); if (maxProducts === 0) { return { checkedCount: 0, products }; } - const checkedProducts = await Promise.all( - products.slice(0, maxProducts).map(async (product) => { - let productId = ''; - try { - productId = await fetchOliveyoungProductId(product.goodsNumber, options); - } catch { - return { checked: false, product }; - } - - if (!productId) { - return { checked: false, product }; - } - - let storeInventory: OliveyoungProductStoreInventory; - try { - storeInventory = await fetchOliveyoungStockStores( - { - productId, - latitude: params.latitude, - longitude: params.longitude, - pageIdx: 1, - searchWords: params.storeKeyword, - }, - options - ); - } catch { - return { checked: false, product }; - } - - const inStock = storeInventory.inStockCount > 0; - const stockStatus: OliveyoungProduct['stockStatus'] = inStock ? 'in_stock' : 'out_of_stock'; - const stockSource: OliveyoungProduct['stockSource'] = 'nearby_store'; - - return { - checked: true, - product: { - ...product, - inStock, - stockStatus, - stockSource, - storeInventory, - }, - }; - }) - ); + const checkedProducts: Array<{ checked: boolean; product: OliveyoungProduct }> = []; + // 릴레이 슬롯을 한 요청이 독점하지 않도록 상품 보강을 2개씩 실행합니다. + for (let start = 0; start < maxProducts; start += 2) { + checkedProducts.push( + ...await Promise.all( + products.slice(start, Math.min(start + 2, maxProducts)).map(async (product) => { + let productId = ''; + try { + productId = await fetchOliveyoungProductId(product.goodsNumber, options); + } catch { + return { checked: false, product }; + } + + if (!productId) { + return { checked: false, product }; + } + + let storeInventory: OliveyoungProductStoreInventory; + try { + storeInventory = await fetchOliveyoungStockStores( + { + productId, + latitude: params.latitude, + longitude: params.longitude, + pageIdx: 1, + searchWords: params.storeKeyword, + }, + options + ); + } catch { + return { checked: false, product }; + } + + const inStock = storeInventory.inStockCount > 0; + const stockStatus: OliveyoungProduct['stockStatus'] = inStock ? 'in_stock' : 'out_of_stock'; + const stockSource: OliveyoungProduct['stockSource'] = 'nearby_store'; + + return { + checked: true, + product: { + ...product, + inStock, + stockStatus, + stockSource, + storeInventory, + }, + }; + }) + ) + ); + } const enrichedProducts = [ ...checkedProducts.map((result) => result.product), diff --git a/src/services/oliveyoung/errors.ts b/src/services/oliveyoung/errors.ts new file mode 100644 index 00000000..9d2161c3 --- /dev/null +++ b/src/services/oliveyoung/errors.ts @@ -0,0 +1,22 @@ +/** 릴레이 원문과 자격증명을 보존하지 않는 안전한 오류 분류. */ +import { ServiceError } from '../../core/errors.js'; +import { HttpError, UnexpectedHtmlResponseError } from '../../utils/http.js'; + +export function toOliveyoungRelayError(error: unknown): ServiceError { + if (error instanceof HttpError) { + return new ServiceError( + 'OLIVEYOUNG_RELAY_HTTP_ERROR', + `올리브영 브라우저 릴레이 요청 실패 (HTTP ${error.status})`, + [429, 502, 503].includes(error.status) ? error.status as 429 | 502 | 503 : 502, + error.status >= 500 || error.status === 408 || error.status === 429, + error.status, + ); + } + if (error instanceof Error && ['AbortError', 'TimeoutError'].includes(error.name)) { + return new ServiceError('OLIVEYOUNG_RELAY_TIMEOUT', '올리브영 브라우저 릴레이 요청 시간 초과', 504, true); + } + if (error instanceof SyntaxError || error instanceof UnexpectedHtmlResponseError) { + return new ServiceError('OLIVEYOUNG_RELAY_INVALID_RESPONSE', '올리브영 브라우저 릴레이 응답 형식 오류', 502, false); + } + return new ServiceError('OLIVEYOUNG_RELAY_NETWORK_ERROR', '올리브영 브라우저 릴레이 네트워크 요청 실패', 502, true); +} diff --git a/src/services/oliveyoung/tools/checkInventory.ts b/src/services/oliveyoung/tools/checkInventory.ts index 74604f6d..5e6a009e 100644 --- a/src/services/oliveyoung/tools/checkInventory.ts +++ b/src/services/oliveyoung/tools/checkInventory.ts @@ -150,7 +150,7 @@ export function createCheckInventoryTool(apiKey?: string, transport: OliveyoungR .number() .optional() .default(5) - .describe('주변 매장별 재고를 보강할 상품 수 (기본값: 5, 낮출수록 빠름)'), + .describe('주변 매장별 재고를 보강할 상품 수 (기본값: 5, 최대: 5, 낮출수록 빠름)'), timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, diff --git a/src/services/oliveyoung/transport.ts b/src/services/oliveyoung/transport.ts index 1347c981..25aed178 100644 --- a/src/services/oliveyoung/transport.ts +++ b/src/services/oliveyoung/transport.ts @@ -1,5 +1,6 @@ /** 올리브영 무료 직접 요청 및 운영자가 설정한 브라우저 릴레이 전송. */ import { fetchJson } from '../../utils/http.js'; +import { toOliveyoungRelayError } from './errors.js'; import { OLIVEYOUNG_API } from './api.js'; import type { OliveyoungApiResponse } from './types.js'; @@ -80,16 +81,17 @@ export async function requestOliveyoung( expectedStatus: 200, }); } catch (error) { + if (relayUrl) throw toOliveyoungRelayError(error); if (error instanceof Error && error.name === 'AbortError') { throw new Error('올리브영 API 요청 시간 초과'); } - if (relayUrl) throw new Error('올리브영 브라우저 릴레이 요청 실패'); throw new Error( '올리브영 직접 요청 실패. 운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN으로 브라우저 릴레이를 설정해주세요.', { cause: error }, ); } if (result?.status !== 'SUCCESS') { + if (relayUrl) throw toOliveyoungRelayError(new SyntaxError()); throw new Error(`올리브영 API 상태 오류: ${result?.status || 'UNKNOWN'}`); } return result; diff --git a/tests/api/oliveyoung-relay-diagnostics.test.ts b/tests/api/oliveyoung-relay-diagnostics.test.ts new file mode 100644 index 00000000..38103c18 --- /dev/null +++ b/tests/api/oliveyoung-relay-diagnostics.test.ts @@ -0,0 +1,35 @@ +import { Hono } from 'hono'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { handleOliveyoungSearchProducts, handleOliveyoungFindStores, handleOliveyoungCheckInventory } from '../../src/api/handlers.js'; +import { __testOnlyClearOliveyoungCaches } from '../../src/services/oliveyoung/client.js'; +import type { AppBindings } from '../../src/api/response.js'; +const app = new Hono<{ Bindings: AppBindings }>(); +app.get('/products', handleOliveyoungSearchProducts); +app.get('/stores', handleOliveyoungFindStores); +app.get('/inventory', handleOliveyoungCheckInventory); +beforeEach(() => __testOnlyClearOliveyoungCaches()); +afterEach(() => vi.unstubAllGlobals()); +it.each([ + ['/products', 429, 429, true], ['/stores', 502, 502, true], ['/inventory', 503, 503, true], + ['/products', 403, 502, false], +])('%s preserves relay HTTP %i diagnostics', async (path, upstreamStatus, status, retryable) => { + vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => new Response('private-token', { status: upstreamStatus }))); + const response = await app.request(`${path}?keyword=팩`, {}, { OY_RELAY_URL: 'https://private-relay.example', OY_RELAY_TOKEN: 'private-token' }); + expect(response.status).toBe(status); + const payload = await response.json(); + expect(payload).toMatchObject({ success: false, error: { code: 'OLIVEYOUNG_RELAY_HTTP_ERROR' }, diagnostics: { code: 'OLIVEYOUNG_RELAY_HTTP_ERROR', status, upstreamStatus, retryable, service: 'oliveyoung' } }); + expect(JSON.stringify(payload)).not.toContain('private-'); +}); +it.each([ + [new DOMException('private-token', 'AbortError'), 'TIMEOUT', 504, true], + [new TypeError('private-token'), 'NETWORK_ERROR', 502, true], + [new SyntaxError('private-token'), 'INVALID_RESPONSE', 502, false], +])('API에도 릴레이 비HTTP 실패 원인을 유지한다', async (failure, suffix, status, retryable) => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(failure)); + const response = await app.request('/products?keyword=팩', {}, { OY_RELAY_URL: 'https://private-relay.example', OY_RELAY_TOKEN: 'private-token' }); + expect(response.status).toBe(status); + const payload = await response.json(); + expect(payload).toMatchObject({ diagnostics: { code: `OLIVEYOUNG_RELAY_${suffix}`, status, retryable } }); + expect(payload.diagnostics).not.toHaveProperty('upstreamStatus'); + expect(JSON.stringify(payload)).not.toContain('private-'); +}); diff --git a/tests/core/registry-mcp-error-contract.test.ts b/tests/core/registry-mcp-error-contract.test.ts index 06c5e0a6..7739768c 100644 --- a/tests/core/registry-mcp-error-contract.test.ts +++ b/tests/core/registry-mcp-error-contract.test.ts @@ -1,3 +1,6 @@ +import { requestOliveyoung } from '../../src/services/oliveyoung/transport.js'; +import { afterEach, vi } from 'vitest'; +import { ServiceError } from '../../src/core/errors.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -89,3 +92,38 @@ describe('ServiceRegistry MCP SDK 오류 계약', () => { expect(result).not.toHaveProperty('isError'); }); }); + +it('표준 서비스 오류의 진단을 성공 스키마 검증 없이 MCP 텍스트로 전달한다', async () => { + const result = await callRegisteredTool({ + name: 'oliveyoung_search_products', + metadata: { title: '오류 진단', description: '릴레이 진단', inputSchema: {}, outputSchema: { value: z.string() } }, + handler: async () => { throw new ServiceError('OLIVEYOUNG_RELAY_HTTP_ERROR', '안전한 오류', 503, true, 503); }, + }); + expect(result.isError).toBe(true); + expect(result).not.toHaveProperty('structuredContent'); + const content = result.content as Array<{ text: string }>; + expect(JSON.parse(content[0].text)).toMatchObject({ + error: { code: 'OLIVEYOUNG_RELAY_HTTP_ERROR', message: '안전한 오류' }, + diagnostics: { status: 503, upstreamStatus: 503, retryable: true, service: 'oliveyoung', operation: 'search_products' }, + }); +}); + +afterEach(() => vi.unstubAllGlobals()); +it.each([429, 502, 503])('실제 릴레이 HTTP %i를 MCP 클라이언트 진단까지 전달한다', async (status) => { + const fetch = vi.fn().mockResolvedValue(new Response('private-token', { status })); + vi.stubGlobal('fetch', fetch); + const result = await callRegisteredTool({ + name: 'oliveyoung_search_products', + metadata: { title: '릴레이 오류', description: '릴레이 오류 진단', inputSchema: {}, outputSchema: { value: z.string() } }, + handler: async () => { + await requestOliveyoung('/p', {}, { relayUrl: 'https://private-relay.example', relayToken: 'private-token' }); + return { content: [] }; + }, + }); + expect(result.isError).toBe(true); + expect(result).not.toHaveProperty('structuredContent'); + const content = result.content as Array<{ text: string }>; + expect(JSON.parse(content[0].text)).toMatchObject({ diagnostics: { code: 'OLIVEYOUNG_RELAY_HTTP_ERROR', status, upstreamStatus: status, retryable: true } }); + expect(JSON.stringify(result)).not.toContain('private-'); + expect(fetch).toHaveBeenCalledTimes(1); +}); diff --git a/tests/relay/lifecycle.test.ts b/tests/relay/lifecycle.test.ts index 36d8c5b8..c3926be3 100644 --- a/tests/relay/lifecycle.test.ts +++ b/tests/relay/lifecycle.test.ts @@ -83,12 +83,14 @@ it('메모리 한도와30분 유휴 수명을 점검한다', async () => { expect(f.launch).toHaveBeenCalledTimes(3); await life.close(); }); -it('200번째 조회 후 다음 요청 없이 유휴 세션을 회수한다', async () => { +it('200번째 조회 후 다음 요청 없이 세션을 교체하고 다음 조회는 준비된 페이지를 쓴다', async () => { const f = fixture(); const life = createBrowserLifecycle(f.launch); for (let i = 0; i < 200; i++) await life.run('/p', {}); expect(f.owner.close).toHaveBeenCalledTimes(1); - expect(f.launch).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(f.page.waitForURL).toHaveBeenCalledTimes(2)); + await life.run('/p', {}); + expect(f.launch).toHaveBeenCalledTimes(2); await life.close(); }); it('유휴30분 만료는 다음 요청 없이 회수한다', async () => { @@ -98,6 +100,7 @@ it('유휴30분 만료는 다음 요청 없이 회수한다', async () => { await life.start(); await vi.advanceTimersByTimeAsync(30 * 60 * 1000); expect(f.owner.close).toHaveBeenCalledTimes(1); + expect(f.launch).toHaveBeenCalledTimes(2); await life.close(); }); it('종료된 수명은 재실행하지 않고 상태를 보고한다', async () => { @@ -219,3 +222,95 @@ it('이전 세션 RSS 실패는 종료 후 추가 작업을 만들지 않는다' await vi.advanceTimersByTimeAsync(0); expect(vi.getTimerCount()).toBe(0); }); + +it('자동 교체 중 종료는 준비 중인 브라우저를 회수하고 타이머를 남기지 않는다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + await life.start(); + let finish!: () => void; + f.page.goto.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + expect(f.launch).toHaveBeenCalledTimes(2); + expect(life.status().state).toBe('starting'); + const closing = life.close(); + finish(); + await closing; + expect(f.owner.close).toHaveBeenCalledTimes(2); + expect(life.status().state).toBe('closed'); + expect(vi.getTimerCount()).toBe(0); +}); +it('자동 교체 준비 실패는 무한 재시작 없이 유휴 상태에서 멈춘다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + await life.start(); + f.page.goto.mockRejectedValueOnce(new Error('navigation')); + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + expect(f.launch).toHaveBeenCalledTimes(2); + expect(life.status()).toMatchObject({ state: 'idle', reason: 'startup-failed' }); + await vi.advanceTimersByTimeAsync(60 * 60 * 1000); + expect(f.launch).toHaveBeenCalledTimes(2); + await life.close(); +}); +it('조회 중 RSS 초과는 조회 종료 후 한 번만 새 세션을 준비한다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + await life.start(); + let finish!: (v: unknown) => void; + f.page.evaluate.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + await vi.advanceTimersByTimeAsync(29000); + const pending = life.run('/p', {}); + f.owner.rss.mockResolvedValue(2 ** 30 + 1); + await vi.advanceTimersByTimeAsync(1000); + expect(f.owner.close).toHaveBeenCalledTimes(1); + expect(f.launch).toHaveBeenCalledTimes(1); + f.owner.rss.mockResolvedValue(1); + finish({ status: 200, body: { status: 'SUCCESS' } }); + await pending; + await vi.advanceTimersByTimeAsync(0); + expect(f.launch).toHaveBeenCalledTimes(2); + await life.close(); +}); +it('자동 교체의 종료 확인 실패는 새 브라우저 실행을 차단한다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + await life.start(); + f.owner.close.mockRejectedValue(new Error('uncertain')); + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + expect(f.launch).toHaveBeenCalledTimes(1); + expect(life.status().state).toBe('failed'); + await expect(life.close()).rejects.toThrow('uncertain'); +}); +it('자동 준비의 페이지가 멈추면 제한 시간 후 회수하고 재시도하지 않는다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + await life.start(); + f.page.goto.mockImplementationOnce(() => new Promise(() => {})); + await vi.advanceTimersByTimeAsync(30 * 60 * 1000 + 32000); + expect(f.owner.close).toHaveBeenCalledTimes(2); + expect(life.status().state).toBe('idle'); + await vi.advanceTimersByTimeAsync(60000); + expect(f.launch).toHaveBeenCalledTimes(2); + await life.close(); +}); +it('자동 준비 중 팝업 회수 실패는 준비 완료로 보고하지 않는다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + await life.start(); + let finish!: () => void; + f.page.waitForURL.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + f.context.emit('page', { close: vi.fn().mockRejectedValue(new Error('popup')) }); + await vi.advanceTimersByTimeAsync(0); + finish(); + await vi.advanceTimersByTimeAsync(0); + expect(life.status().state).toBe('idle'); + expect(vi.getTimerCount()).toBe(0); + expect(f.launch).toHaveBeenCalledTimes(2); + await life.close(); +}); diff --git a/tests/services/oliveyoung/capacity.test.ts b/tests/services/oliveyoung/capacity.test.ts new file mode 100644 index 00000000..0468a0b0 --- /dev/null +++ b/tests/services/oliveyoung/capacity.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { + __testOnlyClearOliveyoungCaches, + enrichOliveyoungProductsWithNearbyStoreInventory, +} from '../../../src/services/oliveyoung/client.js'; +import type { OliveyoungProduct } from '../../../src/services/oliveyoung/types.js'; + +const products: OliveyoungProduct[] = Array.from({ length: 7 }, (_, index) => ({ + goodsNumber: `CAPACITY-${index}`, + goodsName: `상품 ${index}`, + priceToPay: 1000, + originalPrice: 1000, + discountRate: 0, + o2oStockFlag: false, + o2oRemainQuantity: 0, + inStock: false, + stockStatus: 'out_of_stock', + stockSource: 'global_search', +})); +const params = { latitude: 37.5, longitude: 127, storeKeyword: '' }; + +beforeEach(() => __testOnlyClearOliveyoungCaches()); +afterEach(() => vi.unstubAllGlobals()); + +it('재고 요청을 최대 2개씩 실행하고 보강 상품을 최대 5개로 제한한다', async () => { + const pending: Array<() => void> = []; + let active = 0; + let peak = 0; + const goodsNumbers: string[] = []; + vi.stubGlobal('fetch', vi.fn((url: string, init: RequestInit) => { + active += 1; + peak = Math.max(peak, active); + const payload = JSON.parse(init.body as string); + const goodsInfo = url.endsWith('/stock-goods-info-v3'); + if (goodsInfo) goodsNumbers.push(payload.goodsNo); + return new Promise((resolve) => { + pending.push(() => { + active -= 1; + resolve(Response.json({ + status: 'SUCCESS', + data: goodsInfo + ? { goodsInfo: { masterGoodsNumber: payload.goodsNo } } + : { totalCount: 0, storeList: [] }, + })); + }); + }); + })); + + let finished = false; + const resultPromise = enrichOliveyoungProductsWithNearbyStoreInventory(products, { + ...params, maxProducts: 1000, + }).then((result) => { + finished = true; + return result; + }); + while (!finished) { + await vi.waitFor(() => expect(pending.length > 0 || finished).toBe(true)); + pending.shift()?.(); + } + const result = await resultPromise; + expect(peak).toBe(2); + expect(goodsNumbers).toEqual(products.slice(0, 5).map((product) => product.goodsNumber)); + expect(result.checkedCount).toBe(5); + expect(result.products).toHaveLength(7); + expect(result.products.slice(5)).toEqual(products.slice(5)); +}); + +it.each([ + [0, 0], [-1, 0], [1.9, 1], [3, 3], [NaN, 5], [Infinity, 5], +])('보강 제한 %s를 안전한 상품 수 %s로 적용한다', async (maxProducts, expected) => { + const fetchMock = vi.fn((url: string) => Promise.resolve(Response.json({ + status: 'SUCCESS', + data: url.endsWith('/stock-goods-info-v3') + ? { goodsInfo: { masterGoodsNumber: 'MASTER' } } + : { totalCount: 0, storeList: [] }, + }))); + vi.stubGlobal('fetch', fetchMock); + const result = await enrichOliveyoungProductsWithNearbyStoreInventory(products, { ...params, maxProducts }); + expect(result.checkedCount).toBe(expected); + expect(result.products).toHaveLength(products.length); + expect(fetchMock).toHaveBeenCalledTimes(expected * 2); +}); + +it.each(['api', 'mcp'])('%s는 큰 제한값을 허용하되 실패/미보강 상품을 미확인으로 센다', async (surface) => { + vi.stubGlobal('fetch', vi.fn((url: string, init: RequestInit) => { + const payload = JSON.parse(init.body as string); + let data: unknown = { totalCount: 0, storeList: [] }; + if (url.endsWith('/product-search-v3')) data = { totalCount: 7, searchList: products }; + if (url.endsWith('/stock-goods-info-v3')) { + data = { goodsInfo: { masterGoodsNumber: payload.goodsNo === 'CAPACITY-0' ? '' : payload.goodsNo } }; + } + return Promise.resolve(Response.json({ status: 'SUCCESS', data })); + })); + let inventory: { stockCheckedCount: number; stockUncheckedCount: number; products: OliveyoungProduct[] }; + if (surface === 'mcp') { + const { createCheckInventoryTool } = await import('../../../src/services/oliveyoung/tools/checkInventory.js'); + const result = await createCheckInventoryTool().handler({ keyword: '상품', stockCheckLimit: 1000 }); + inventory = JSON.parse(result.content[0].text).inventory; + } else { + const { handleOliveyoungCheckInventory } = await import('../../../src/api/handlers.js'); + const context = { + req: { query: (key: string) => ({ keyword: '상품', stockCheckLimit: '1000' })[key] }, + json: (data: unknown) => data, + } as unknown as Parameters[0]; + const result = await handleOliveyoungCheckInventory(context) as unknown as { + data: { inventory: { stockCheckedCount: number; stockUncheckedCount: number; products: OliveyoungProduct[] } }; + }; + inventory = result.data.inventory; + } + expect(inventory.stockCheckedCount).toBe(4); + expect(inventory.stockUncheckedCount).toBe(3); + expect(inventory.products.filter((product: OliveyoungProduct) => product.stockSource === 'global_search')) + .toHaveLength(3); +}); diff --git a/tests/services/oliveyoung/relayErrors.test.ts b/tests/services/oliveyoung/relayErrors.test.ts new file mode 100644 index 00000000..558126e9 --- /dev/null +++ b/tests/services/oliveyoung/relayErrors.test.ts @@ -0,0 +1,71 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { requestOliveyoung } from '../../../src/services/oliveyoung/transport.js'; + +const options = { relayUrl: 'https://private-relay.example', relayToken: 'private-token' }; +afterEach(() => vi.unstubAllGlobals()); + +it.each([ + [429, 429, true], [502, 502, true], [503, 503, true], + [401, 502, false], [403, 502, false], [302, 502, false], [201, 502, false], + [408, 502, true], [500, 502, true], +])('릴레이 HTTP %i의 상태와 재시도 가능 여부를 비밀 없이 보존한다', async (upstreamStatus, status, retryable) => { + const fetch = vi.fn().mockResolvedValue(new Response('private-token private-relay.example', { + status: upstreamStatus, + statusText: 'private-token', + })); + vi.stubGlobal('fetch', fetch); + const error = await requestOliveyoung('/p', {}, options).catch((caught: unknown) => caught); + expect(error).toMatchObject({ code: 'OLIVEYOUNG_RELAY_HTTP_ERROR', status, upstreamStatus, retryable }); + expect(String(error)).toContain(`HTTP ${upstreamStatus}`); + expect(String(error)).not.toContain('private-'); + expect(JSON.stringify(error)).not.toContain('private-'); + expect(error).not.toHaveProperty('cause'); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch.mock.calls[0][1].redirect).toBe('manual'); +}); + +it.each([ + [new DOMException('private-token', 'AbortError'), 'TIMEOUT', 504, true], + [new DOMException('private-token', 'TimeoutError'), 'TIMEOUT', 504, true], + [new TypeError('private-relay.example private-token'), 'NETWORK_ERROR', 502, true], + ['private-token', 'NETWORK_ERROR', 502, true], +])('릴레이 요청 실패를 안전한 원인으로 구분한다: %s', async (failure, suffix, status, retryable) => { + const fetch = vi.fn().mockRejectedValue(failure); + vi.stubGlobal('fetch', fetch); + const error = await requestOliveyoung('/p', {}, options).catch((caught: unknown) => caught); + expect(error).toMatchObject({ code: `OLIVEYOUNG_RELAY_${suffix}`, status, retryable }); + expect(JSON.stringify(error)).not.toContain('private-'); + expect(String(error)).not.toContain('private-'); + expect(error).not.toHaveProperty('cause'); + expect(fetch).toHaveBeenCalledTimes(1); +}); + +it.each(['private-token', '{private-token'])('릴레이의 잘못된 응답 원문을 노출하지 않는다', async (body) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body))); + const error = await requestOliveyoung('/p', {}, options).catch((caught: unknown) => caught); + expect(error).toMatchObject({ code: 'OLIVEYOUNG_RELAY_INVALID_RESPONSE', status: 502, retryable: false }); + expect(String(error)).not.toContain('private-token'); + expect(error).not.toHaveProperty('cause'); +}); + +it.each([null, {}, { status: 'private-token' }])('릴레이 실패 봉투의 상태 원문도 노출하지 않는다', async (response) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(response))); + const error = await requestOliveyoung('/p', {}, options).catch((caught: unknown) => caught); + expect(error).toMatchObject({ code: 'OLIVEYOUNG_RELAY_INVALID_RESPONSE', status: 502, retryable: false }); + expect(String(error)).not.toContain('private-token'); +}); + +it('릴레이 응답 본문을 읽는 중 실제 제한 시간이 지나도 시간 초과 원인을 유지한다', async () => { + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn(async (_url: string, request: RequestInit) => ({ + text: () => new Promise((_resolve, reject) => request.signal!.addEventListener('abort', () => reject(new DOMException('private-token', 'AbortError')))), + }))); + try { + const request = requestOliveyoung('/p', {}, { ...options, timeout: 10 }); + const assertion = expect(request).rejects.toMatchObject({ code: 'OLIVEYOUNG_RELAY_TIMEOUT', status: 504, retryable: true }); + await vi.advanceTimersByTimeAsync(10); + await assertion; + } finally { + vi.useRealTimers(); + } +});