diff --git a/README.md b/README.md index 174a1d0d..3acd830d 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,9 @@ CLI는 로컬 npm 패키지를 실행합니다. 공개 원격 MCP 연결에는 [응답 모델](docs/service-reference.md#mcp-표준-응답-모델)에서 필드 정의를 확인하세요. 공개 GET API 중 올리브영·CGV·CU·GS25에는 IP당 하루 합산 3,000회(KST 기준) 제한이 적용됩니다. + +Mac 중계의 일일 예산은 100배, 분당 속도는 10배로 증액했습니다. 원본 호출 기준 한도는 편의점 공유 일 1,000만 회·분 690회, 올리브영과 Dtryx·CGV 공유는 각각 일 30만 회·분 300회입니다. 자세한 집계와 재시도 기준은 [중계 한도 안내](docs/health-check-stock-and-quota.md)를 참고하세요. + 조회가 실패하면 [서비스 상태](https://aka-page.betteruptime.com/)를 확인하세요. 서버 운영과 제한 집계는 [운영 가이드](scripts/ops/README.md)에 있습니다. diff --git a/docs/cgv-relay-recovery.md b/docs/cgv-relay-recovery.md index e4a5dd34..3c2d52c3 100644 --- a/docs/cgv-relay-recovery.md +++ b/docs/cgv-relay-recovery.md @@ -2,9 +2,9 @@ 2026-10-08 점검에서 Worker 직접 요청은 HTTP 403으로 차단되었으나, 같은 CGV 공식 API를 Mac에서 서명·브라우저 헤더와 함께 호출하면 HTTP 200을 반환했습니다. 인증된 로컬 중계 실측은 극장 174건, 영화 5건, 시간표 30건을 확인했습니다. 이 수치는 점검 시점의 결과이며 날짜와 극장에 따라 달라집니다. -CGV는 `/v1/cgv/{theaters,movies,timetable,timetable-movie}` 경로로 기존 Dtryx 4320 프로세스를 사용합니다. 별도 예산을 만들지 않고 `DTRYX_RELAY_TOKEN`, `DTRYX_RELAY_STATE_DIR/quota.json`, Dtryx 로그를 공유합니다. 기본 상한은 `scripts/relay/quota.ts`의 일 3,000회·분 30회이며 CGV와 Dtryx의 실제 중계 원본 요청을 합산합니다. CU·세븐일레븐·GS25의 별도 편의점 원장은 공유하지 않습니다. +CGV는 `/v1/cgv/{theaters,movies,timetable,timetable-movie}` 경로로 기존 Dtryx 4320 프로세스를 사용합니다. 별도 예산을 만들지 않고 `DTRYX_RELAY_TOKEN`, `DTRYX_RELAY_STATE_DIR/quota.json`, Dtryx 로그를 공유합니다. 기본 상한은 `scripts/relay/quota.ts`의 일 300,000회·분 300회이며 CGV와 Dtryx의 실제 중계 원본 요청을 합산합니다. CU·세븐일레븐·GS25의 별도 편의점 원장은 공유하지 않습니다. -중계는 검증된 성공 응답만 재사용합니다. 극장·영화 목록은 5분, 시간표·영화별 시간표는 30초 뒤 만료됩니다. 동일 작업과 조회 조건으로 겹친 진행 요청은 원본 조회와 예산을 한 번만 소비합니다. 원본 HTTP 오류·업무 오류는 캐시하지 않으며, 시간표 캐시가 만료되면 좌석 수량을 다시 조회합니다. 이 중계 캐시는 REST 캐시와 별도이며 MCP 요청에도 적용됩니다. 분·일 상한은 변경하지 않습니다. +중계는 검증된 성공 응답만 재사용합니다. 극장·영화 목록은 5분, 시간표·영화별 시간표는 30초 뒤 만료됩니다. 동일 작업과 조회 조건으로 겹친 진행 요청은 원본 조회와 예산을 한 번만 소비합니다. 원본 HTTP 오류·업무 오류는 캐시하지 않으며, 시간표 캐시가 만료되면 좌석 수량을 다시 조회합니다. 이 중계 캐시는 REST 캐시와 별도이며 MCP 요청에도 적용됩니다. 캐시는 설정된 분·일 예산 안에서 원본 호출을 줄입니다. 5분 주기 경로 점검은 CGV 직접 요청과 중계를 확인해 사용 가능한 경로를 선택합니다. 직접 경로가 차단되면 중계를 사용하고, 직접 경로가 회복되면 점검 결과에 따라 복귀합니다. 일반 요청 중 직접 실패도 경로 선택에 반영됩니다. `/health`의 `config.cgvRelay`는 유효 설정 여부만 나타내며 연결 성공을 뜻하지 않습니다. diff --git a/docs/convenience-relay.md b/docs/convenience-relay.md index a4a57651..41ed9e48 100644 --- a/docs/convenience-relay.md +++ b/docs/convenience-relay.md @@ -15,7 +15,7 @@ CONVENIENCE_RELAY_TOKEN=your_convenience_token CONVENIENCE_RELAY_STATE_DIR=/absolute/path/to/convenience-state ``` -상태 경로는 Dtryx 상태 경로와 다른 디렉터리여야 합니다. 이 디렉터리의 `quota.json`에는 재시작해도 유지되는 호출 원장이, `logs/`에는 입력·토큰·키를 제외한 제한된 관측 로그가 저장됩니다. CU·GS25·세븐일레븐이 공유하는 편의점 예산은 분당 69회, 하루 최대 100,000회이며 Dtryx·올리브영 예산과 공유하지 않습니다. +상태 경로는 Dtryx 상태 경로와 다른 디렉터리여야 합니다. 이 디렉터리의 `quota.json`에는 재시작해도 유지되는 호출 원장이, `logs/`에는 입력·토큰·키를 제외한 제한된 관측 로그가 저장됩니다. CU·GS25·세븐일레븐이 공유하는 편의점 예산은 분당 690회, 하루 최대 10,000,000회이며 Dtryx·올리브영 예산과 공유하지 않습니다. Worker에는 아래 바인딩을 설정합니다. URL은 중계 호스트의 기본 URL이며 `/v1/convenience`를 붙이지 않습니다. HTTPS 또는 로컬 루프백 HTTP만 허용합니다. REST와 MCP가 동일한 설정을 사용합니다. @@ -60,11 +60,11 @@ CU 매장 키워드만 입력한 조회는 공식 웹에서 해당 지역 점포 본문 16 KiB, 원본 응답 2 MiB, 동시 원본 호출 4개, 전체 요청 32개를 제한합니다. 본문·대기·원장·원본 처리를 합쳐 15초 기한을 적용하며 취소된 대기 요청은 예산을 소비하지 않습니다. 인증·입력 검증 뒤 성공한 상품·메타정보·초기화·카탈로그는 5분, 매장·실재고는 30초 동안 최대 256개·16 MiB의 메모리 캐시에 저장합니다. 캐시 적중은 호출 예산을 소비하지 않습니다. 오류는 캐시하지 않습니다. -캐시 가능한 작업은 인증·입력 검증을 통과한 뒤 작업명과 모든 조회 조건이 같은 진행 요청을 공유합니다. 최초 동시 조회도 원본 호출·예산 소비는 한 번입니다. 각 응답 body와 request ID는 독립이며 공유를 기다린 요청은 `coalesced` 단계로 기록합니다. 한 요청의 취소는 다른 대기자에게 전파하지 않고, 마지막 대기자가 떠나면 원본 작업과 큐를 취소합니다. 최초 요청의 전체 15초 기한이 지난 공유 작업은 후속 대기자에게도 504를 반환하며, 실패·취소 결과는 캐시하지 않습니다. 동시 접수 32개·원본 4개·분당 69회·일 100,000회 상한은 유지합니다. 서로 다른 상품·지역·인증키·조회 조건은 임의로 합치지 않습니다. +캐시 가능한 작업은 인증·입력 검증을 통과한 뒤 작업명과 모든 조회 조건이 같은 진행 요청을 공유합니다. 최초 동시 조회도 원본 호출·예산 소비는 한 번입니다. 각 응답 body와 request ID는 독립이며 공유를 기다린 요청은 `coalesced` 단계로 기록합니다. 한 요청의 취소는 다른 대기자에게 전파하지 않고, 마지막 대기자가 떠나면 원본 작업과 큐를 취소합니다. 최초 요청의 전체 15초 기한이 지난 공유 작업은 후속 대기자에게도 504를 반환하며, 실패·취소 결과는 캐시하지 않습니다. 동시 접수 32개·원본 4개·분당 690회·일 10,000,000회 상한은 유지합니다. 서로 다른 상품·지역·인증키·조회 조건은 임의로 합치지 않습니다. 리다이렉트·비 JSON·실패 envelope·잘못된 수량은 오류로 반환합니다. 재고 오류를 수량 0으로 바꾸지 않습니다. 세븐일레븐 수량 `-1`은 미확인이며 품절로 표시하지 않습니다. 반환된 매장의 수량이 모두 미확인이면 `stockAvailable`은 false입니다. 캐시 수량은 조회 시점 이후 바뀔 수 있으므로 실제 방문 전 점포 확인이 필요합니다. -분당 상한은 UTC 기준 각 1분 구간에 적용됩니다. 69 × 1,440분 = 99,360회이므로 정상적인 하루 합계는 10만 회 이하이며, 일일 100,000회 상한도 함께 검사합니다. 일일 원장은 UTC 자정(한국 시각 오전 9시)에 새 날짜로 전환됩니다. 한도 변경이나 재시작 시 기존 원장을 초기화하지 않습니다. +분당 상한은 UTC 기준 각 1분 구간에 적용됩니다. 690 × 1,440분 = 993,600회이므로 이 속도를 하루 내내 채워도 합계는 993,600회이며, 일일 10,000,000회 상한도 함께 검사합니다. 2026-10-08 사용자 요청에 따라 일일 예산은 기존의 100배, 분당 속도는 10배로 증액했습니다. 일일 원장은 UTC 자정(한국 시각 오전 9시)에 새 날짜로 전환됩니다. 한도 변경이나 재시작 시 기존 원장을 초기화하지 않습니다. 조회 재시도와 CU 주소·미확인 재고 처리는 [조회 오류 복구와 제한](status-resilience.md)을 참조하세요. 재시도도 같은 편의점 원장을 사용합니다. diff --git a/docs/dtryx-relay.md b/docs/dtryx-relay.md index ee94deeb..cc86a65b 100644 --- a/docs/dtryx-relay.md +++ b/docs/dtryx-relay.md @@ -46,7 +46,7 @@ node --max-old-space-size=128 --env-file=/absolute/path/dtryx.env \ - `GET /v1/dtryx/health`: 인증된 상태 조회. - 임의URL·헤더·추가필드·알 수 없는 경로는 거절한다. - 동시4개, 대기 포함32개, 전체15초 제한. 요청 본문16KiB, 원본 응답2MiB. -- 별도 파일 원장으로 분당30회·UTC일3000회를 제한한다. 재시작해도 유지되며 원장 장애는 차단한다. +- 별도 파일 원장으로 분당300회·UTC일300000회를 제한한다. 재시작해도 유지되며 원장 장애는 차단한다. - 성공은 원본의 `RetCode=success` 및 유효한 `Recordset`을 확인한 뒤 반환한다. 실패나 미확인 좌석을 정상 데이터로 만들지 않는다. ## 점검과 복구 diff --git a/docs/gs25-auth-recovery-runbook.md b/docs/gs25-auth-recovery-runbook.md index 62b3e039..29c38265 100644 --- a/docs/gs25-auth-recovery-runbook.md +++ b/docs/gs25-auth-recovery-runbook.md @@ -38,7 +38,7 @@ REST / MCP / CLI → Worker → Access/Tunnel → Mac 편의점 중계 | 공개 주소에서 Python 기본 UA만 403 | 실제 CLI/SDK와 같은 요청으로 대조한다. 당시 `daiso-cli/1.2.9` UA는 200이었다. 차단 기준을 추측해 우회하지 않는다. | | 200인데 다른 상품/지역 | 상품 코드·상품명·좌표·필터·수량을 검증한다. HTTP 200만으로 복구 완료가 아니다. | -69/분·100,000/일은 편의점 중계의 **합산 자체 상한**이다. 동시 원본 4, 접수 32, 전체 15초, 재고 캐시 30초를 유지한다. CU/Seven의 운영 트래픽도 같은 편의점 원장을 소비한다. 캐시 적중·동일 진행 조회 병합은 원본 소비가 없거나 1회지만 서로 다른 조건은 각각 소비한다. 인증 갱신은 별도 인증 요청이다. +690/분·10,000,000/일은 편의점 중계의 **합산 자체 상한**이다. 동시 원본 4, 접수 32, 전체 15초, 재고 캐시 30초를 유지한다. CU/Seven의 운영 트래픽도 같은 편의점 원장을 소비한다. 캐시 적중·동일 진행 조회 병합은 원본 소비가 없거나 1회지만 서로 다른 조건은 각각 소비한다. 인증 갱신은 별도 인증 요청이다. ## 2. 기존 세션을 먼저 점검한다 diff --git a/docs/health-check-stock-and-quota.md b/docs/health-check-stock-and-quota.md index 4091bb71..56a29187 100644 --- a/docs/health-check-stock-and-quota.md +++ b/docs/health-check-stock-and-quota.md @@ -11,7 +11,7 @@ ## 429의 출처 -CU·GS25·세븐일레븐 Mac 중계는 같은 원장을 공유하며 분당 69회, UTC 하루 100,000회로 제한합니다. Cloudflare 직접 경로의 호출과 별개입니다. 분당 상한에 도달해도 일일 상한에 도달했다는 의미는 아닙니다. +CU·GS25·세븐일레븐 Mac 중계는 같은 원장을 공유하며 분당 690회, UTC 하루 10,000,000회로 제한합니다. Cloudflare 직접 경로의 호출과 별개입니다. 분당 상한에 도달해도 일일 상한에 도달했다는 의미는 아닙니다. 응답의 `diagnostics.quotaReason`이 `minute`, `daily`, `consumer`, `consumer-busy`이면 중계가 확인한 자체 정책 제한입니다. `retryAfter`는 재시도까지 기다릴 초 단위 시간입니다. 단순 HTTP 429만으로 자체 제한과 원본 업체 제한을 구분하지 않습니다. diff --git a/docs/oliveyoung-free-relay.md b/docs/oliveyoung-free-relay.md index cf607fef..fc41eed6 100644 --- a/docs/oliveyoung-free-relay.md +++ b/docs/oliveyoung-free-relay.md @@ -94,7 +94,7 @@ GitHub Actions의 `Sync Worker Secrets`는 위 네 OY secrets를 동기화하며 ### 전체 호출량과 인증 -모든 중계 호출은 전체 분당 30회, UTC 하루 3,000회로 제한합니다. MCP·REST 모두 같은 중계를 거치므로 호출 경로로 우회할 수 없습니다. 원장을 `OY_RELAY_STATE_DIR`에 원자적으로 저장하며 저장 실패·손상은 조회 거절로 처리합니다. 본문 업로드 중인 요청도 최대 8개 슬롯에 포함합니다. 실패한 원본 요청도 이미 소비한 예산을 돌려주지 않습니다. +모든 중계 호출은 전체 분당 300회, UTC 하루 300,000회로 제한합니다. MCP·REST 모두 같은 중계를 거치므로 호출 경로로 우회할 수 없습니다. 원장을 `OY_RELAY_STATE_DIR`에 원자적으로 저장하며 저장 실패·손상은 조회 거절로 처리합니다. 소비자별 신규 원본 작업은 분당 240회로 제한하며 소비자 동시 요청은 2개입니다. 본문 업로드 중인 요청도 최대 8개 슬롯에 포함합니다. 실패한 원본 요청도 이미 소비한 예산을 돌려주지 않습니다. Cloudflare Access 사용 시 Worker secrets에 `OY_ACCESS_CLIENT_ID`와 `OY_ACCESS_CLIENT_SECRET`을 함께 설정합니다. 기존 `OY_RELAY_TOKEN`과 별개입니다. 사용자 도구 입력으로 전달하거나 덮어쓸 수 없습니다. Access 비밀이 다른 주소로 전달되지 않도록 리다이렉트를 거절합니다. Access 정책은 해당 서비스 토큰만 허용하는 Service Auth로 구성하며, Tunnel은 localhost:4319와 최종 404 경로만 연결합니다. 브라우저 디버깅 주소와 개인 네트워크 경로는 연결하지 않습니다. diff --git a/docs/relay-observability.md b/docs/relay-observability.md index a48d4af1..0fa0f427 100644 --- a/docs/relay-observability.md +++ b/docs/relay-observability.md @@ -47,10 +47,12 @@ rg --fixed-strings '확인할-request-id' "$OY_RELAY_STATE_DIR/logs" "$DTRYX_REL 인증과 payload 검증 뒤 공통 릴레이 캐시를 확인한다. REST와 MCP가 같은 조건을 요청하면 공유된다. 상품 검색/매장은 5분, 상품 ID는 10분, 재고는 1분 TTL이다. 최대 256항목 및 키·값 직렬화 16MiB, 오래된 항목부터 퇴출한다. 성공만 저장하며 동일 진행 요청은 합친다. 탭이나 브라우저를 추가하지 않는다. -캐시 적중은 quota를 소비하지 않는다. `x-relay-cache: hit/miss/coalesced` 및 health.cache로 확인한다. 서로 다른 검색이 대부분이면 절감 효과는 작다. quota는 기존 분당30/UTC 일일3000을 유지한다. REST 캐시도 별도로 존재하며 products5분/stores24시간/inventory10분이므로 외부 REST 응답의 최신성은 두 계층의 영향을 받는다. `_healthCheck`는 REST 캐시를 새로 조회하지만 공통 릴레이 캐시는 계속 적용된다. +캐시 적중은 quota를 소비하지 않는다. `x-relay-cache: hit/miss/coalesced` 및 health.cache로 확인한다. 서로 다른 검색이 대부분이면 절감 효과는 작다. 현재 OY quota는 분당 300회·UTC 일일 300,000회다. 소비자별 신규 원본 작업은 분당 240회이며, 사용한 원장은 유지한다. REST 캐시도 별도로 존재하며 products5분/stores24시간/inventory10분이므로 외부 REST 응답의 최신성은 두 계층의 영향을 받는다. `_healthCheck`는 REST 캐시를 새로 조회하지만 공통 릴레이 캐시는 계속 적용된다. -## OY 요청 집중 보호 (1.2.7) +## 과거 OY 요청 집중 보호 (1.2.7) + +아래 1.2.7·1.2.8 숫자는 변경 이력이며 현재 한도가 아니다. 현재 값은 위의 OY 캐시 안내와 [중계 한도 안내](health-check-stock-and-quota.md)를 따른다. 전역 상한(분당 30회, UTC 하루 3,000회)에 더해, 익명 소비자별 실제 신규 실행을 분당 12회로 제한한다. 요청 본문을 읽기 전부터 동시 미완료 요청은 소비자당 2개만 허용한다. 따라서 세 번째 동시 요청은 캐시 적중 여부를 확인하기 전에 거절될 수 있다. 캐시 적중과 진행 요청 합치기는 실행 횟수를 차감하지 않는다. 대기 작업은 용량을 예약하고 전역 quota를 통과한 실행 직전에 차감하며, 취소·만료·전역 quota 오류는 예약을 반환한다. @@ -66,7 +68,7 @@ quick/full 모두 새 검사 요약을 요청한다. 실패 후 한 번 재시 CGV는 `CGV_UPSTREAM_UNAVAILABLE`, API HTTP 503, 원본 HTTP 403이 모두 확인될 때만 `degraded`로 분류한다. 이는 해당 기능이 제한된 상태이며 복구를 뜻하지 않는다. 인증 오류, 타임아웃, JSON 파싱 실패 등은 계속 `fail`이다. 선택 데이터가 없는 `skipped` 검사는 정상 요약에 포함될 수 있다. Actions 성공 여부와 별도로 요약의 저하 서비스 목록을 확인해야 한다. 유료 Zyte 재시도는 활성화하지 않는다. -## 1.2.8 요청 집중 및 교체 시간 개선 +## 과거 1.2.8 요청 집중 및 교체 시간 개선 소비자별 신규 실행은 분당 **24회**로 완화했다. 전역 분당 30회·UTC 일일 3,000회는 그대로이며, 소비자별 동시 2개·전체 8개 슬롯과 메모리 상한도 유지한다. 단일 소비자가 지속적으로 요청하면 여전히 제한될 수 있다. diff --git a/docs/seveneleven-stock-recovery-20261008.md b/docs/seveneleven-stock-recovery-20261008.md index fdcc4ff2..ffbc1f3c 100644 --- a/docs/seveneleven-stock-recovery-20261008.md +++ b/docs/seveneleven-stock-recovery-20261008.md @@ -24,7 +24,7 @@ Worker의 재고 캐시는 `seveneleven-inventory-v3` 키를 사용하여 기존 30초 저장하고 stale 재사용은 0초로 설정합니다. 확인된 0개 재고는 저장할 수 있으나 오류·미확인 결과는 `Cache-Control: no-store`를 적용합니다. -CU·GS25·세븐일레븐 공통 중계 예산은 분당 69회, 일당 100,000회입니다. +CU·GS25·세븐일레븐 공통 중계 예산은 분당 690회, 일당 10,000,000회입니다. 재고 복구를 위해 이 한도를 올리거나 원장을 초기화하지 않습니다. 429 이후에는 중계가 제공하는 재시도 시간과 운영 예산을 따릅니다. 기본 재고 요청은 최대 20개 점포를 조회합니다. 상품 검색 상한 10회, 매장 검색 1회, @@ -49,6 +49,19 @@ Incapsula HTML을 반환했으며, Node 25.9.0은 HTTP 200 JSON을 반환했습 `stockAvailable: true`, `stockError: null`을 확인했습니다. 이는 점검 시점의 결과이며 향후에도 특정 버전이 항상 허용된다는 뜻은 아닙니다. +2026-10-08 00:58 UTC 점검에서는 실제 Node 25.9.0 장기 실행 중계에서도 상품·매장· +인기 검색어 조회가 다시 502를 반환했습니다. 이후 같은 실행 파일의 단일 직접 조회는 +상품·매장 모두 HTTP 200이며 설치된 응답 검증도 통과했습니다. 따라서 Node 25로 +교체하는 것만으로 지속적인 복구를 보장하지 않습니다. 시간·연결·부하에 따른 차단 +가능성은 추가 관측이 필요하며, TLS 지문이나 특정 내부 차단 원인을 확정하지 않습니다. + +중계 오류 이벤트의 `status`는 기존 중계 응답 상태이고 `upstreamStatus`는 Seven +원본 HTTP 상태입니다. 예를 들어 `status: 502`, `upstreamStatus: 403`은 원본 HTTP +접근 거절이고, `upstreamStatus: 200`이면 원본 본문 읽기·JSON·응답 검증 오류를 +살펴봅니다. HTTP 상태를 확인하지 못한 네트워크·미분류 스트림 오류에는 값을 추정해 +붙이지 않습니다. 로그는 정수 100~599만 허용하고 원본 본문·헤더·검색 조건·인증 +정보를 저장하지 않습니다. 자체 한도는 `stage: quota`와 별도 한도 진단으로 구분합니다. + 변경 전 새 실행 파일로 중계 모듈을 로드해 호환성을 확인합니다. launchd는 `bootout` 후 기존 프로세스가 종료되기 전에 `bootstrap`하면 일시적으로 오류 5를 반환할 수 있습니다. 종료 상태를 확인하고 재등록한 다음 건강 응답과 CU·GS25·세븐일레븐·CGV를 diff --git a/scripts/relay/consumer.ts b/scripts/relay/consumer.ts index bc0a65a0..68916b36 100644 --- a/scripts/relay/consumer.ts +++ b/scripts/relay/consumer.ts @@ -26,7 +26,7 @@ export function createConsumerQuota(now = Date.now, maxEntries = 1024) { return { reserve() { resetMinute(); - if (entry.count + entry.reserved >= 24) return null; + if (entry.count + entry.reserved >= 240) return null; entry.reserved++; let reserved = true; return { diff --git a/scripts/relay/convenience.ts b/scripts/relay/convenience.ts index 53b35b1e..4a6e5622 100644 --- a/scripts/relay/convenience.ts +++ b/scripts/relay/convenience.ts @@ -311,43 +311,50 @@ export function createConvenienceRelay( }), signal, ); - if (operation === 'seven-stock' && response.status === 400) { - const data = await readJson(response.body, 16384, signal, 502); - const rejected = z - .object({ success: z.literal(false), code: z.literal(501), message: z.string() }) - .safeParse(data); - throw new RelayError( - 502, - rejected.success ? parseSevenStockFailure({ ...rejected.data, status: 400 }) : undefined, - ); - } - if (response.status !== 200) { - void response.body?.cancel().catch(() => undefined); - throw new RelayError( - operation === 'gs25-stock' && [401, 403].includes(response.status) - ? response.status - : 502, - ); + try { + if (operation === 'seven-stock' && response.status === 400) { + const data = await readJson(response.body, 16384, signal, 502); + const rejected = z + .object({ success: z.literal(false), code: z.literal(501), message: z.string() }) + .safeParse(data); + throw new RelayError( + 502, + rejected.success ? parseSevenStockFailure({ ...rejected.data, status: 400 }) : undefined, + ); + } + if (response.status !== 200) { + void response.body?.cancel().catch(() => undefined); + throw new RelayError( + operation === 'gs25-stock' && [401, 403].includes(response.status) + ? response.status + : 502, + ); + } + const data = await readJson(response.body, 2 * 1024 * 1024, signal, 502); + if ( + operation === 'cu-store' && + body.itemCd && + !z + .object({ storeList: z.array(z.object({ stock: quantity }).passthrough()) }) + .safeParse(data).success + ) + throw new RelayError(502); + if ( + operation === 'gs25-stock' && + (body.itemCode || body.keyword) && + !z + .object({ stores: z.array(z.object({ realStockQuantity: quantity }).passthrough()) }) + .safeParse(data).success + ) + throw new RelayError(502); + if (!successSchema.and(schema).safeParse(data).success) throw new RelayError(502); + return data; + } catch (error) { + // 원본 본문·인증·검색 조건은 보존하지 않고 Seven 오류의 HTTP 상태만 기록합니다. + if (operation.startsWith('seven-') && error instanceof RelayError) + throw new RelayError(error.status, error.upstreamError, error.quota, response.status); + throw error; } - const data = await readJson(response.body, 2 * 1024 * 1024, signal, 502); - if ( - operation === 'cu-store' && - body.itemCd && - !z - .object({ storeList: z.array(z.object({ stock: quantity }).passthrough()) }) - .safeParse(data).success - ) - throw new RelayError(502); - if ( - operation === 'gs25-stock' && - (body.itemCode || body.keyword) && - !z - .object({ stores: z.array(z.object({ realStockQuantity: quantity }).passthrough()) }) - .safeParse(data).success - ) - throw new RelayError(502); - if (!successSchema.and(schema).safeParse(data).success) throw new RelayError(502); - return data; }, }); } diff --git a/scripts/relay/http-relay.ts b/scripts/relay/http-relay.ts index 2848c40d..8bf75d5e 100644 --- a/scripts/relay/http-relay.ts +++ b/scripts/relay/http-relay.ts @@ -23,16 +23,21 @@ export interface HttpRelayOptions { requestId: string; outcome: string; status: number; + upstreamStatus?: number; durationMs: number; }) => void | Promise; } export class RelayError extends Error { + readonly upstreamStatus?: number; constructor( readonly status: number, readonly upstreamError?: SevenStockFailure, readonly quota?: RelayQuota, + upstreamStatus?: number, ) { super('Relay failure'); + if (typeof upstreamStatus === 'number' && Number.isInteger(upstreamStatus) && + upstreamStatus >= 100 && upstreamStatus <= 599) this.upstreamStatus = upstreamStatus; } } const reply = (status: number, body: unknown, headers: Record = {}) => @@ -92,6 +97,7 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) { waiters: number; done: boolean; stage: string; + upstreamStatus?: number; } const pending = new Map(); let outstanding = 0; @@ -177,6 +183,7 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) { if (cache) cache.set(key, body, options.cacheTtl!(name)); return body; } catch (error) { + if (error instanceof RelayError) work.upstreamStatus = error.upstreamStatus; if (expired) throw new RelayError(504); throw error; } finally { @@ -274,6 +281,7 @@ export function createHttpRelay(token: string, options: HttpRelayOptions) { requestId, outcome: resultStatus === 200 ? 'ok' : 'error', status: resultStatus, + ...(work?.upstreamStatus === undefined ? {} : { upstreamStatus: work.upstreamStatus }), durationMs: performance.now() - deadline + 15000, }), ).catch(() => undefined); diff --git a/scripts/relay/logging.ts b/scripts/relay/logging.ts index f2bdbf90..6276f12d 100644 --- a/scripts/relay/logging.ts +++ b/scripts/relay/logging.ts @@ -116,8 +116,11 @@ export function createRelayLogger(directory: string, options: LoggerOptions) { if (typeof value === 'number' && Number.isFinite(value) && value >= 0) safe[field] = value; } + const upstreamStatus = (event as Record).upstreamStatus; + if (typeof upstreamStatus === 'number' && Number.isInteger(upstreamStatus) && + upstreamStatus >= 100 && upstreamStatus <= 599) safe.upstreamStatus = upstreamStatus; const line = `${JSON.stringify(safe)}\n`; - // 허용 필드 7개×64자와 숫자 4개로 각 이벤트는 2KiB 미만입니다. + // 허용 필드 7개×64자와 숫자 5개로 각 이벤트는 2KiB 미만입니다. if (queued >= 256) { dropped++; return; diff --git a/scripts/relay/quota.ts b/scripts/relay/quota.ts index 7dec153f..9dac4d5a 100644 --- a/scripts/relay/quota.ts +++ b/scripts/relay/quota.ts @@ -17,8 +17,8 @@ export interface QuotaLimits { readonly daily: number; readonly minute: number; } -export const CONVENIENCE_QUOTA_LIMITS: QuotaLimits = { daily: 100000, minute: 69 }; -const DEFAULT_QUOTA_LIMITS: QuotaLimits = { daily: 3000, minute: 30 }; +export const CONVENIENCE_QUOTA_LIMITS: QuotaLimits = { daily: 10000000, minute: 690 }; +const DEFAULT_QUOTA_LIMITS: QuotaLimits = { daily: 300000, minute: 300 }; export type FileQuota = (() => Promise) & { status: () => QuotaStatus }; export async function createFileQuota( path: string, diff --git a/tests/relay/consumer.test.ts b/tests/relay/consumer.test.ts index c4c64a05..50ca841c 100644 --- a/tests/relay/consumer.test.ts +++ b/tests/relay/consumer.test.ts @@ -9,7 +9,7 @@ it('동시 요청과 분당 신규 작업을 제한하고 만료 예산만 정 expect(quota.enter('a')).toBeNull(); expect(take()).toBe(true); b.release(); - for (let i = 1; i < 24; i++) expect(take()).toBe(true); + for (let i = 1; i < 240; i++) expect(take()).toBe(true); expect(take()).toBe(false); expect(quota.retryAfter()).toBe(60); const other = quota.enter('b')!; @@ -26,7 +26,7 @@ it('대기 예약을 상한에 포함하고 분이 바뀌면 실행 시점의 let now = 0; const quota = createConsumerQuota(() => now); const admission = quota.enter('a')!; - for (let i = 0; i < 23; i++) { const reservation = admission.reserve()!; reservation.commit(); reservation.release(); } + for (let i = 0; i < 239; i++) { const reservation = admission.reserve()!; reservation.commit(); reservation.release(); } const queued = admission.reserve()!; expect(admission.reserve()).toBeNull(); now = 60000; @@ -34,7 +34,7 @@ it('대기 예약을 상한에 포함하고 분이 바뀌면 실행 시점의 queued.release(); const canceled = admission.reserve()!; canceled.release(); - for (let i = 0; i < 23; i++) { const reservation = admission.reserve()!; reservation.commit(); reservation.release(); } + for (let i = 0; i < 239; i++) { const reservation = admission.reserve()!; reservation.commit(); reservation.release(); } expect(admission.reserve()).toBeNull(); admission.release(); }); diff --git a/tests/relay/convenience-quota.test.ts b/tests/relay/convenience-quota.test.ts index 554b80cf..4bd7d4c9 100644 --- a/tests/relay/convenience-quota.test.ts +++ b/tests/relay/convenience-quota.test.ts @@ -1,16 +1,17 @@ /** 편의점의 파일 원장과 캐시·진행 요청 병합을 함께 검증합니다. */ -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, writeFile } 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'; -it('분당 69회 원본 호출 후에도 캐시는 응답하며 최초 동시 조회는 한 번만 차감한다', async () => { +it('분당 690회 원본 호출 후에도 캐시는 응답하며 최초 동시 조회는 한 번만 차감한다', async () => { const directory = await mkdtemp(join(tmpdir(), 'convenience-quota-')); try { let now = Date.UTC(2026, 9, 5); const path = join(directory, 'quota.json'); + await writeFile(path, JSON.stringify({ day: Math.floor(now / 86400000), minute: Math.floor(now / 60000), dayCount: 688, minuteCount: 688 })); const takeQuota = await createFileQuota(path, () => now, CONVENIENCE_QUOTA_LIMITS); const fetcher = vi.fn(async () => Response.json({ SearchQueryResult: {} })); const relay = createConvenienceRelay('test-token', { takeQuota, fetcher }); @@ -20,17 +21,17 @@ it('분당 69회 원본 호출 후에도 캐시는 응답하며 최초 동시 const responses = await Promise.all(Array.from({ length: 8 }, () => relay(request(0)))); expect(responses.every(response => response.status === 200)).toBe(true); expect(fetcher).toHaveBeenCalledTimes(1); - for (let index = 1; index < 69; index++) expect((await relay(request(index))).status).toBe(200); + for (let index = 1; index < 2; index++) expect((await relay(request(index))).status).toBe(200); expect((await relay(request(0))).status).toBe(200); - const denied = await relay(request(69)); + const denied = await relay(request(2)); expect(denied.status).toBe(429); expect(denied.headers.get('x-relay-quota-reason')).toBe('minute'); expect(denied.headers.get('Retry-After')).toBe('60'); - expect(fetcher).toHaveBeenCalledTimes(69); - expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(69); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(690); now += 60000; - expect((await relay(request(69))).status).toBe(200); - expect(fetcher).toHaveBeenCalledTimes(70); + expect((await relay(request(2))).status).toBe(200); + expect(fetcher).toHaveBeenCalledTimes(3); } finally { await rm(directory, { recursive: true, force: true }); } diff --git a/tests/relay/fairness.test.ts b/tests/relay/fairness.test.ts index 5bac5ff1..0a6f3889 100644 --- a/tests/relay/fairness.test.ts +++ b/tests/relay/fairness.test.ts @@ -23,18 +23,18 @@ it('캐시와 공유 작업은 분당 예산을 소비하지 않으며 다음 vi.spyOn(Date, 'now').mockReturnValue(0); const run = vi.fn().mockResolvedValue({ status: 'SUCCESS' }); const relay = createOliveyoungRelay('test', run); - for (let i = 0; i < 24; i++) expect((await relay(request(String(i)))).status).toBe(200); + for (let i = 0; i < 240; i++) expect((await relay(request(String(i)))).status).toBe(200); expect((await relay(request('0'))).status).toBe(200); - expect((await relay(request('24'))).status).toBe(429); - expect((await relay(request('24', 'b'.repeat(64)))).status).toBe(200); + expect((await relay(request('240'))).status).toBe(429); + expect((await relay(request('240', 'b'.repeat(64)))).status).toBe(200); vi.spyOn(Date, 'now').mockReturnValue(60000); - expect((await relay(request('25'))).status).toBe(200); + expect((await relay(request('241'))).status).toBe(200); }); it('실행 전에 취소된 작업은 소비자 분당 사용량에 포함하지 않는다', async () => { const relay = createOliveyoungRelay('test', vi.fn().mockResolvedValue({ status: 'SUCCESS' })); const controller = new AbortController(); controller.abort(); - for (let i = 0; i < 24; i++) expect((await relay(new Request(request(String(i)), { signal: controller.signal }))).status).toBe(503); + for (let i = 0; i < 240; i++) expect((await relay(new Request(request(String(i)), { signal: controller.signal }))).status).toBe(503); expect((await relay(request('next'))).status).toBe(200); }); it('병합된 요청은 두 번째 소비자의 분당 예산을 차감하지 않는다', async () => { @@ -48,8 +48,8 @@ it('병합된 요청은 두 번째 소비자의 분당 예산을 차감하지 finish({ status: 'SUCCESS' }); expect((await first).status).toBe(200); expect((await second).headers.get('x-relay-cache')).toBe('coalesced'); - for (let i = 0; i < 24; i++) expect((await relay(request(String(i), 'b'.repeat(64)))).status).toBe(200); - expect((await relay(request('25', 'b'.repeat(64)))).status).toBe(429); + for (let i = 0; i < 240; i++) expect((await relay(request(String(i), 'b'.repeat(64)))).status).toBe(200); + expect((await relay(request('241', 'b'.repeat(64)))).status).toBe(429); }); it('본문 업로드부터 소비자별 두 슬롯을 적용하며 잘못된 식별자는 legacy를 공유한다', async () => { const run = vi.fn().mockResolvedValue({ status: 'SUCCESS' }); @@ -67,11 +67,11 @@ it('본문 업로드부터 소비자별 두 슬롯을 적용하며 잘못된 식 it('예산이 소진된 소비자의 신규 요청에 다른 소비자가 묶이지 않는다', async () => { const run = vi.fn().mockResolvedValue({ status: 'SUCCESS' }); const relay = createOliveyoungRelay('test', run); - for (let i = 0; i < 24; i++) await relay(request(`used-${i}`)); + for (let i = 0; i < 240; i++) await relay(request(`used-${i}`)); let finish!: (value: { status: string }) => void; run.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); const blocker = relay(request('blocker', 'c'.repeat(64))); - await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(25)); + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(241)); const exhausted = relay(request('shared')); const fresh = relay(request('shared', 'b'.repeat(64))); await new Promise(resolve => setTimeout(resolve, 0)); @@ -84,10 +84,10 @@ it('글로벌 예산 거절과 확인 오류는 소비자 사용량을 차감하 const takeQuota = vi.fn().mockResolvedValue(false); const run = vi.fn().mockResolvedValue({ status: 'SUCCESS' }); const relay = createOliveyoungRelay('test', run, { takeQuota }); - for (let i = 0; i < 24; i++) expect((await relay(request(`denied-${i}`))).status).toBe(429); + for (let i = 0; i < 240; i++) expect((await relay(request(`denied-${i}`))).status).toBe(429); takeQuota.mockRejectedValueOnce(new Error('unavailable')).mockResolvedValue(true); expect((await relay(request('unavailable'))).status).toBe(503); - for (let i = 0; i < 24; i++) expect((await relay(request(`allowed-${i}`))).status).toBe(200); + for (let i = 0; i < 240; i++) expect((await relay(request(`allowed-${i}`))).status).toBe(200); expect((await relay(request('exhausted'))).status).toBe(429); - expect(run).toHaveBeenCalledTimes(24); + expect(run).toHaveBeenCalledTimes(240); }); diff --git a/tests/relay/logging.test.ts b/tests/relay/logging.test.ts index 4593f54d..393449d7 100644 --- a/tests/relay/logging.test.ts +++ b/tests/relay/logging.test.ts @@ -24,6 +24,19 @@ const event = { quotaReason: 'minute', }; const space = async () => ({ bavail: 3 * 1024 ** 3, bsize: 1 }); +it('원본 상태는 HTTP 정수만 기록하며 본문과 헤더는 기록하지 않는다', async () => { + const dir = await directory(); + const logger = createRelayLogger(dir, { service: 'convenience', statfs: space }); + logger.append({ ...event, upstreamStatus: 403, body: 'private-body', headers: 'private-headers' }); + for (const upstreamStatus of ['403', undefined, 99, 600, 403.5, NaN, Infinity]) logger.append({ upstreamStatus }); + await logger.flush(); + const files = await readdir(dir); + const text = (await Promise.all(files.map(name => readFile(join(dir, name), 'utf8')))).join(''); + const rows = text.trim().split('\n').map(line => JSON.parse(line)); + expect(rows[0].upstreamStatus).toBe(403); + expect(rows.slice(1).every(row => row.upstreamStatus === undefined)).toBe(true); + expect(text).not.toContain('private'); +}); it('허용 필드만 안전한 권한의 JSONL로 기록한다', async () => { const dir = await directory(); const logger = createRelayLogger(dir, { service: 'oliveyoung', statfs: space }); diff --git a/tests/relay/quota.test.ts b/tests/relay/quota.test.ts index d0e84bde..800ab5c4 100644 --- a/tests/relay/quota.test.ts +++ b/tests/relay/quota.test.ts @@ -12,19 +12,20 @@ async function file() { afterEach(async () => { await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); -it('동시 요청과 재시작에도 분당 30회 상한을 유지한다', async () => { +it('동시 요청과 재시작에도 분당 300회 상한을 유지한다', async () => { const path = await file(); let now = Date.UTC(2026, 8, 15); + await writeFile(path, JSON.stringify({ day: Math.floor(now / 86400000), minute: Math.floor(now / 60000), dayCount: 295, minuteCount: 295 })); const take = await createFileQuota(path, () => now); expect( - (await Promise.all(Array.from({ length: 35 }, () => take()))).filter(Boolean), - ).toHaveLength(30); + (await Promise.all(Array.from({ length: 10 }, () => take()))).filter(Boolean), + ).toHaveLength(5); expect(await (await createFileQuota(path, () => now))()).toBe(false); now += 60000; expect(await take()).toBe(true); - expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(31); + expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(301); }); -it('하루 3000회 이후 차단하고 다음 UTC 날짜에 재개한다', async () => { +it('하루 300000회 이후 차단하고 다음 UTC 날짜에 재개한다', async () => { const path = await file(); let now = Date.UTC(2026, 8, 15); await writeFile( @@ -32,7 +33,7 @@ it('하루 3000회 이후 차단하고 다음 UTC 날짜에 재개한다', async JSON.stringify({ day: Math.floor(now / 86400000), minute: Math.floor(now / 60000), - dayCount: 2999, + dayCount: 299999, minuteCount: 0, }), ); @@ -68,23 +69,23 @@ it('상태 조회는 원장을 쓰지 않고 현재 창과 재시도 시간을 let now = Date.UTC(2026, 8, 15) + 1000; const take = await createFileQuota(path, () => now); expect(take.status()).toMatchObject({ - dailyRemaining: 3000, - minuteRemaining: 30, + dailyRemaining: 300000, + minuteRemaining: 300, blockedBy: null, retryAfter: 0, }); await expect(readFile(path)).rejects.toThrow(); - await Promise.all(Array.from({ length: 30 }, () => take())); + await Promise.all(Array.from({ length: 300 }, () => take())); expect(take.status()).toMatchObject({ - dailyRemaining: 2970, + dailyRemaining: 299700, minuteRemaining: 0, blockedBy: 'minute', retryAfter: 59, }); now += 60000; - expect(take.status().minuteRemaining).toBe(30); + expect(take.status().minuteRemaining).toBe(300); now += 86400000; - expect(take.status().dailyRemaining).toBe(3000); + expect(take.status().dailyRemaining).toBe(300000); }); it('일일 상한과 시계 역행에도 정확한 상태를 제공한다', async () => { const path = await file(); @@ -94,8 +95,8 @@ it('일일 상한과 시계 역행에도 정확한 상태를 제공한다', asyn JSON.stringify({ day: Math.floor(now / 86400000), minute: Math.floor(now / 60000), - dayCount: 3000, - minuteCount: 30, + dayCount: 300000, + minuteCount: 300, }), ); const take = await createFileQuota(path, () => now - 1000); @@ -121,18 +122,19 @@ it('원장 디렉터리의 슬래시·점·심볼릭 링크 별칭을 같은 저 }); const convenienceLimits = CONVENIENCE_QUOTA_LIMITS; -it('편의점 동시 요청과 재시작에도 분당 69회만 허용한다', async () => { +it('편의점 동시 요청과 재시작에도 분당 690회만 허용한다', async () => { const path = await file(); let now = Date.UTC(2026, 9, 5) + 1000; + await writeFile(path, JSON.stringify({ day: Math.floor(now / 86400000), minute: Math.floor(now / 60000), dayCount: 685, minuteCount: 685 })); const take = await createFileQuota(path, () => now, convenienceLimits); - const results = await Promise.all(Array.from({ length: 74 }, () => take())); - expect(results.filter(Boolean)).toHaveLength(69); - expect(take.status()).toMatchObject({ dailyRemaining: 99931, minuteRemaining: 0, blockedBy: 'minute', retryAfter: 59 }); + const results = await Promise.all(Array.from({ length: 10 }, () => take())); + expect(results.filter(Boolean)).toHaveLength(5); + expect(take.status()).toMatchObject({ dailyRemaining: 9999310, minuteRemaining: 0, blockedBy: 'minute', retryAfter: 59 }); const restarted = await createFileQuota(path, () => now, convenienceLimits); expect(await restarted()).toBe(false); now += 60000; expect(await restarted()).toBe(true); - expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(70); + expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(691); }); it('편의점 한도를 늘려도 기존 3000회 사용 기록을 초기화하지 않는다', async () => { @@ -140,24 +142,46 @@ it('편의점 한도를 늘려도 기존 3000회 사용 기록을 초기화하 const now = Date.UTC(2026, 9, 5); await writeFile(path, JSON.stringify({ day: Math.floor(now / 86400000), minute: Math.floor(now / 60000), dayCount: 3000, minuteCount: 0 })); const take = await createFileQuota(path, () => now, convenienceLimits); - expect(take.status()).toMatchObject({ dailyRemaining: 97000, minuteRemaining: 69, blockedBy: null }); + expect(take.status()).toMatchObject({ dailyRemaining: 9997000, minuteRemaining: 690, blockedBy: null }); expect(await take()).toBe(true); expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(3001); expect(await (await createFileQuota(path, () => now, convenienceLimits))()).toBe(true); expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(3002); }); -it('편의점 일일 10만 회를 넘기지 않고 다음 UTC 날짜에 재개한다', async () => { +it('편의점 일일 1000만 회를 넘기지 않고 다음 UTC 날짜에 재개한다', async () => { const path = await file(); let now = Date.UTC(2026, 9, 5); - await writeFile(path, JSON.stringify({ day: Math.floor(now / 86400000), minute: Math.floor(now / 60000), dayCount: 99999, minuteCount: 0 })); + await writeFile(path, JSON.stringify({ day: Math.floor(now / 86400000), minute: Math.floor(now / 60000), dayCount: 9999999, minuteCount: 0 })); const take = await createFileQuota(path, () => now, convenienceLimits); expect(await take()).toBe(true); expect(take.status().dailyRemaining).toBe(0); now += 60000; expect(await take()).toBe(false); - expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(100000); + expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(10000000); now += 86400000; expect(await take()).toBe(true); - expect(take.status().dailyRemaining).toBe(99999); + expect(take.status().dailyRemaining).toBe(9999999); +}); + +it('기본 프로필 한도 증액도 기존 일일·분당 원장을 그대로 이어 쓴다', async () => { + const path = await file(); + const now = Date.UTC(2026, 9, 8); + await writeFile(path, JSON.stringify({ day: Math.floor(now / 86400000), minute: Math.floor(now / 60000), dayCount: 3000, minuteCount: 30 })); + const take = await createFileQuota(path, () => now); + expect(take.status()).toMatchObject({ dailyRemaining: 297000, minuteRemaining: 270, blockedBy: null }); + expect(await take()).toBe(true); + expect(JSON.parse(await readFile(path, 'utf8'))).toMatchObject({ dayCount: 3001, minuteCount: 31 }); + const restarted = await createFileQuota(path, () => now); + expect(restarted.status()).toMatchObject({ dailyRemaining: 296999, minuteRemaining: 269 }); +}); +it('편의점 증액도 기존 분당 사용량을 보존한다', async () => { + const path = await file(); + const now = Date.UTC(2026, 9, 8); + await writeFile(path, JSON.stringify({ day: Math.floor(now / 86400000), minute: Math.floor(now / 60000), dayCount: 100000, minuteCount: 69 })); + const take = await createFileQuota(path, () => now, convenienceLimits); + expect(take.status()).toMatchObject({ dailyRemaining: 9900000, minuteRemaining: 621, blockedBy: null }); + expect(await take()).toBe(true); + expect(JSON.parse(await readFile(path, 'utf8'))).toMatchObject({ dayCount: 100001, minuteCount: 70 }); + expect((await createFileQuota(path, () => now, convenienceLimits)).status()).toMatchObject({ dailyRemaining: 9899999, minuteRemaining: 620 }); }); diff --git a/tests/relay/seven-upstream-status.test.ts b/tests/relay/seven-upstream-status.test.ts new file mode 100644 index 00000000..007aea50 --- /dev/null +++ b/tests/relay/seven-upstream-status.test.ts @@ -0,0 +1,76 @@ +import { expect, it, vi } from 'vitest'; +import { createConvenienceRelay } from '../../scripts/relay/convenience.js'; +import { RelayError } from '../../scripts/relay/http-relay.js'; + +const store = { collection: 'store', query: 'test', sort: 'Date/desc', listCount: 1 }; +function request(operation = 'seven-store', payload: unknown = store) { + return new Request(`http://127.0.0.1/v1/convenience/${operation}`, { + method: 'POST', headers: { Authorization: 'Bearer test-token' }, body: JSON.stringify(payload), + }); +} +it.each([403, 429, 500])('Seven 원본 HTTP %i를 중계 502와 별도 숫자로 기록한다', async status => { + const onEvent = vi.fn(); + const relay = createConvenienceRelay('test-token', { + takeQuota: async () => true, onEvent, + fetcher: async () => new Response('private-upstream-body', { status }), + }); + const response = await relay(request()); + expect(response.status).toBe(502); + expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ status: 502, upstreamStatus: status })); + expect(JSON.stringify(onEvent.mock.calls)).not.toContain('private-upstream-body'); + expect(await response.text()).not.toContain('upstreamStatus'); +}); +it.each(['private-invalid-json', JSON.stringify({ success: false, data: {} })])('HTTP 200 본문 검증 실패도 원본 상태를 보존한다', async body => { + const onEvent = vi.fn(); + const relay = createConvenienceRelay('test-token', { + takeQuota: async () => true, onEvent, fetcher: async () => new Response(body), + }); + expect((await relay(request())).status).toBe(502); + expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ status: 502, upstreamStatus: 200 })); + expect(JSON.stringify(onEvent.mock.calls)).not.toContain(body); +}); +it('Seven stock 400의 기존 공개 오류와 원본 상태를 함께 보존한다', async () => { + const onEvent = vi.fn(); + const relay = createConvenienceRelay('test-token', { + takeQuota: async () => true, onEvent, + fetcher: async () => Response.json({ success: false, code: 501, message: '정상적인 점포 조회 요청이 아닙니다.' }, { status: 400 }), + }); + const response = await relay(request('seven-stock', { smCd: '1', stokMngCd: '1', stokMngQty: 1, stockApplicationRate: '100', storeList: ['s'] })); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ upstreamError: { status: 400, code: 501 } }); + expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ status: 502, upstreamStatus: 400 })); +}); +it('미분류 Seven stock 400은 본문을 공개하지 않고 상태만 기록한다', async () => { + const onEvent = vi.fn(); + const relay = createConvenienceRelay('test-token', { + takeQuota: async () => true, onEvent, + fetcher: async () => Response.json({ code: 500, message: 'private-upstream-error' }, { status: 400 }), + }); + const response = await relay(request('seven-stock', { smCd: '1', stokMngCd: '1', stokMngQty: 1, stockApplicationRate: '100', storeList: ['s'] })); + expect(response.status).toBe(502); + expect(await response.text()).not.toContain('private'); + expect(onEvent).toHaveBeenCalledWith(expect.objectContaining({ status: 502, upstreamStatus: 400 })); + expect(JSON.stringify(onEvent.mock.calls)).not.toContain('private'); +}); +it('Seven 이외의 오류 기록은 바꾸지 않는다', async () => { + const onEvent = vi.fn(); + const relay = createConvenienceRelay('test-token', { takeQuota: async () => true, onEvent, fetcher: async () => new Response('', { status: 500 }) }); + expect((await relay(request('cu-prime', {}))).status).toBe(502); + expect(onEvent.mock.calls[0][0]).not.toHaveProperty('upstreamStatus'); +}); +it('응답 스트림의 미분류 오류에 원본 상태를 추정해서 붙이지 않는다', async () => { + const onEvent = vi.fn(); + const relay = createConvenienceRelay('test-token', { + takeQuota: async () => true, onEvent, + fetcher: async () => ({ status: 200, body: { getReader() { throw new Error('private-stream-error'); } } }) as unknown as Response, + }); + expect((await relay(request())).status).toBe(502); + expect(onEvent.mock.calls[0][0]).not.toHaveProperty('upstreamStatus'); + expect(JSON.stringify(onEvent.mock.calls)).not.toContain('private-stream-error'); +}); +it.each([100, 200, 403, 599])('원본 HTTP 상태 %i만 유한한 진단으로 허용한다', status => { + expect(new RelayError(502, undefined, undefined, status).upstreamStatus).toBe(status); +}); +it.each([undefined, '403', 99, 600, 403.5, NaN, Infinity])('무효 원본 상태 %s는 기록하지 않는다', status => { + expect(new RelayError(502, undefined, undefined, status as number).upstreamStatus).toBeUndefined(); +});