Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/sync-worker-secrets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
ZYTE_API_KEY: ${{ secrets.ZYTE_API_KEY }}
GS25_API_KEY: ${{ secrets.GS25_API_KEY }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
NAVER_CLIENT_ID: ${{ secrets.NAVER_CLIENT_ID }}
NAVER_CLIENT_SECRET: ${{ secrets.NAVER_CLIENT_SECRET }}
Expand All @@ -48,6 +49,7 @@ jobs:
}

put_secret_if_set ZYTE_API_KEY "$ZYTE_API_KEY"
put_secret_if_set GS25_API_KEY "$GS25_API_KEY"
put_secret_if_set GOOGLE_MAPS_API_KEY "$GOOGLE_MAPS_API_KEY"
put_secret_if_set NAVER_CLIENT_ID "$NAVER_CLIENT_ID"
put_secret_if_set NAVER_CLIENT_SECRET "$NAVER_CLIENT_SECRET"
Expand Down
114 changes: 114 additions & 0 deletions docs/superpowers/plans/2026-07-28-service-reliability-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Service Reliability Recovery Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 운영에서 확인된 다섯 장애와 CGV 이슈 #156을 정직한 오류 계약과 실제 복구 경로로 개선한다.

**Architecture:** 공용 날짜와 헬스체크 shape를 중앙에서 교정하고, 서비스별 전송 계층은 성공 가능한 경로를 우선 사용한다. 외부 인증·결제·차단으로 복구할 수 없는 경우 빈 성공 대신 서비스별 503 응답을 반환한다.

**Tech Stack:** TypeScript 6, Hono, Cloudflare Workers, Vitest, Wrangler

---

### Task 1: 한국 날짜 고정

**Files:**
- Modify: `src/utils/format.ts`
- Create: `tests/utils/format.test.ts`

- [ ] `2026-07-27T15:30:00Z`가 `20260728`이 되는 실패 테스트를 추가한다.
- [ ] `npx vitest run tests/utils/format.test.ts --maxWorkers=1 --no-file-parallelism`로 기존 로컬 날짜 구현의 실패를 확인한다.
- [ ] `Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit' })`의 `formatToParts`로 `YYYYMMDD`를 만든다.
- [ ] 같은 단일 테스트를 다시 실행해 통과를 확인한다.

### Task 2: 헬스체크 shape와 빈 결과 판정

**Files:**
- Modify: `src/api/healthCheckTypes.ts`
- Modify: `src/api/healthCheckShape.ts`
- Modify: `src/api/healthCheckDefinitions.ts`
- Modify: `src/api/healthChecks.ts`
- Modify: `tests/api/health-checks.test.ts`

- [ ] `inventoryStores`, 이마트24 top-level stores, count 미확인, 선택적 빈 결과의 기대 판정을 테스트한다.
- [ ] `npx vitest run tests/api/health-checks.test.ts --maxWorkers=1 --no-file-parallelism`로 실패를 확인한다.
- [ ] `collectionKey: 'inventoryStores'`와 `allowEmpty?: boolean`을 추가한다.
- [ ] `inventory.stores`를 count·sample·shape에 포함하고 빈 필수 컬렉션을 shape 실패로 처리한다.
- [ ] `count === null`은 degraded, `allowEmpty && count === 0`은 skipped로 판정한다.
- [ ] 서비스 정의의 실제 컬렉션과 대표 필드를 교정하고 단일 테스트를 통과시킨다.

### Task 3: GS25 인증 Secret과 unavailable 계약

**Files:**
- Modify: `src/api/response.ts`
- Modify: `src/index.ts`
- Modify: `src/services/gs25/index.ts`
- Modify: `src/services/gs25/client.ts`
- Modify: `src/services/gs25/tools/checkInventory.ts`
- Modify: `src/api/gs25Handlers.ts`
- Modify: `.github/workflows/sync-worker-secrets.yml`
- Modify: `tests/services/gs25/client.test.ts`
- Modify: `tests/api/gs25-handlers.test.ts`

- [ ] `Api-Key` 헤더 주입과 401 인증 실패의 503 변환 테스트를 추가한다.
- [ ] GS25 클라이언트 테스트와 핸들러 테스트를 각각 단독 실행해 실패를 확인한다.
- [ ] `GS25_API_KEY`를 AppBindings와 서비스 옵션으로 전달하고 stock 요청 헤더에만 추가한다.
- [ ] 401/403 인증 실패를 `Gs25UpstreamUnavailableError`로 정규화하고 핸들러에서 `GS25_UPSTREAM_UNAVAILABLE` 503을 반환한다.
- [ ] MCP 도구와 Worker Secret 동기화 경로를 연결하고 두 단일 테스트를 통과시킨다.

### Task 4: 롯데마트 전송 복구

**Files:**
- Modify: `src/services/lottemart/api.ts`
- Modify: `src/services/lottemart/config.ts`
- Modify: `src/services/lottemart/session.ts`
- Modify: `tests/services/lottemart/session.test.ts`
- Modify: `tests/services/lottemart/debug.test.ts`

- [ ] 표준 fetch 우선, HTTP origin, 제한된 fallback 순서를 검증하는 실패 테스트를 추가한다.
- [ ] 두 테스트 파일을 각각 단독 실행해 실패를 확인한다.
- [ ] 공식 HTTP base URL을 사용하고 표준 fetch 성공 시 소켓과 Zyte를 호출하지 않도록 한다.
- [ ] 표준 fetch 실패 시에만 남은 시간 예산으로 소켓과 Zyte를 순차 실행한다.
- [ ] 두 단일 테스트를 통과시키고 운영 debug 요청이 제한시간 안에 매장을 반환하는지 확인한다.

### Task 5: 세븐일레븐 선택적 인기 검색어

**Files:**
- Modify: `src/api/healthCheckDefinitions.ts`
- Modify: `tests/api/health-checks.test.ts`
- Modify: `tests/app/app-api-seveneleven.test.ts`

- [ ] 원본 빈 객체가 `available:false` 응답과 skipped 헬스 상태로 유지되는 테스트를 추가한다.
- [ ] 각 테스트 파일을 단독 실행해 실패를 확인한다.
- [ ] popwords 정의에 `allowEmpty: true`를 적용하고 정상 데이터가 있을 때는 기존 ok 판정을 유지한다.
- [ ] 두 단일 테스트를 통과시킨다.

### Task 6: CGV 이슈 #156 graceful 처리

**Files:**
- Create: `src/services/cgv/errors.ts`
- Modify: `src/services/cgv/transport.ts`
- Modify: `src/api/cgvHandlers.ts`
- Modify: `tests/services/cgv/transport.test.ts`
- Modify: `tests/api/cgv-handlers.test.ts`

- [ ] 직접 403 뒤 Zyte 403이 발생하면 typed unavailable 오류가 되는 테스트를 추가한다.
- [ ] API가 `CGV_UPSTREAM_UNAVAILABLE` 503을 반환하는 테스트를 추가한다.
- [ ] 두 테스트를 각각 단독 실행해 실패를 확인한다.
- [ ] `CgvUpstreamUnavailableError`와 판별 함수를 추가하고 결제 상세를 일반 안내로 정규화한다.
- [ ] 세 CGV 핸들러가 typed 오류에만 503을 반환하도록 최소 수정한다.
- [ ] 두 단일 테스트를 통과시킨다.

### Task 7: 전체 검증과 배포

**Files:**
- Verify all changed files

- [ ] `npm run format:check`, `npm run lint`, `npm run lint:biome`, `npm run typecheck`, `npm run check:source-lines`를 차례로 실행한다.
- [ ] `npx vitest run --maxWorkers=1 --no-file-parallelism`로 전체 테스트를 단일 워커로 실행한다.
- [ ] `npx vitest run --coverage --maxWorkers=1 --no-file-parallelism`로 100% 커버리지를 확인한다.
- [ ] `npm run build`와 `npm audit`를 순차 실행한다.
- [ ] 변경사항을 자체 리뷰하고 커밋한 뒤 브랜치를 push하여 PR을 만든다.
- [ ] PR CI를 확인하고 `main`에 병합한 뒤 Deploy 완료를 확인한다.
- [ ] 운영 API에서 다이소·편의점·마트·영화관·오피넷·장소·비교 기능을 순차 재검증한다.
- [ ] CGV 이슈 #156에 수정·배포·운영 확인 결과를 답변하고 필요하면 종료한다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# 서비스 신뢰성 복구 설계

## 목표

운영 점검에서 확인된 영화관 기본 날짜, 헬스체크 오판, GS25 재고 인증 실패,
롯데마트 매장 검색 지연, 세븐일레븐 인기 검색어 공백과 GitHub 이슈 #156의
CGV Zyte 장애 처리를 함께 개선한다.

## 검토한 접근

1. 빈 결과를 계속 성공으로 반환한다.
구현은 단순하지만 실제 장애와 정상적인 검색 결과 0건을 구분할 수 없다.
2. 모든 외부 오류를 즉시 실패로 바꾼다.
관측은 정확해지지만 선택적 데이터와 복구 가능한 경로까지 중단한다.
3. 실제 대체 경로를 먼저 사용하고, 복구 불가능한 경우에만 명시적인
`upstream unavailable` 상태를 반환한다.

세 번째 접근을 사용한다. 실제 데이터가 있는 경우에는 기존 성공 계약을 유지하고,
인증·결제·차단 문제를 빈 성공으로 위장하지 않는다.

## 설계

### 한국 날짜

공용 `toYyyymmdd`가 런타임의 로컬 시간대에 의존하지 않도록
`Asia/Seoul` 달력 날짜를 `Intl.DateTimeFormat`으로 계산한다. CGV, 메가박스,
롯데시네마의 API와 MCP 도구는 이미 공용 함수를 사용하므로 한 번의 수정으로
같은 동작을 얻는다.

### 헬스체크

재고 응답별 실제 컬렉션을 구분한다.

- CU: `data.inventory.items`
- GS25·세븐일레븐: `data.inventory.stores`
- 이마트24: `data.stores`
- 올리브영: `data.inventory.products`

필수 컬렉션을 찾지 못해 개수를 계산할 수 없는 경우도 `degraded`로 판정한다.
빈 컬렉션의 대표 필드 검사를 자동 통과시키지 않는다. 세븐일레븐 인기 검색어처럼
공식 API가 선택적 빈 데이터를 반환하는 체크는 `allowEmpty`로 표시하고
`skipped`로 구분한다.

### GS25 재고

공식 앱이 사용하는 `Api-Key`를 `GS25_API_KEY` Secret으로만 주입한다. 키는
소스·로그·오류 메시지에 기록하지 않는다. 401/403 또는 인증 오류 envelope를
감지하면 빈 매장 목록 대신 `GS25_UPSTREAM_UNAVAILABLE` 503을 반환한다.
MCP 서비스에도 같은 Secret을 전달한다. Secret을 확보할 수 없는 배포에서도
거짓 재고 0건은 반환하지 않는다.

### 롯데마트 매장

공식 매장 안내가 가리키는 HTTP origin을 사용한다. Cloudflare 소켓을 먼저
시도해 전체 요청 시간을 소모하지 않고, 표준 fetch를 우선한다. 표준 fetch가
실패할 때만 제한된 소켓·Zyte 경로를 순차적으로 사용한다. 각 단계는 하나의
총 요청 예산을 나눠 사용해 운영 요청이 45초 이상 멈추지 않도록 한다.

### 세븐일레븐 인기 검색어

공식 원본이 `200 {"data":{}}`를 반환하므로 인기 데이터를 임의 생성하지 않는다.
API와 MCP 응답의 `available:false` 계약을 유지하고, 헬스체크에서 선택적 데이터
미제공으로 정확히 분류한다.

### CGV 이슈 #156

직접 CGV 호출을 계속 우선하고 403일 때만 Zyte를 사용한다. Zyte 결제 정지,
차단, 키 누락 등으로 우회까지 실패하면 결제 상세를 노출하지 않고
`CGV_UPSTREAM_UNAVAILABLE` 503과 재시도 안내를 반환한다. 배포 후 CGV 극장과
시간표를 운영 환경에서 확인하고 이슈 #156에 결과를 답변한다.

## 테스트와 배포

각 수정은 실패하는 단일 회귀 테스트를 먼저 실행한 다음 최소 구현으로 통과시킨다.
마지막에 포맷, ESLint, Biome, 타입 검사, 450줄 제한, 전체 테스트, 100% 커버리지,
빌드를 순차 실행한다. PR을 `main`에 병합하고 Deploy·CI·Coverage·CodeQL을 확인한
뒤 운영 API 전체 스모크를 다시 수행한다.
27 changes: 24 additions & 3 deletions src/api/cgvHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
* CGV GET API 핸들러
*/

import { fetchCgvMovies, fetchCgvTheaters, fetchCgvTimetable, toYyyymmdd } from '../services/cgv/client.js';
import {
fetchCgvMovies,
fetchCgvTheaters,
fetchCgvTimetable,
toYyyymmdd,
} from '../services/cgv/client.js';
import { isCgvUpstreamUnavailableError } from '../services/cgv/errors.js';
import { fetchCgvNearbyTheaters, resolveCgvNearestTheater } from '../services/cgv/location.js';
import { filterAndSortTimetable } from '../services/cgv/timetable.js';
import { type ApiContext, errorResponse, successResponse } from './response.js';
Expand Down Expand Up @@ -75,6 +81,9 @@ export async function handleCgvFindTheaters(c: ApiContext) {
{ total: sliced.length, pageSize: limit },
);
} catch (error) {
if (isCgvUpstreamUnavailableError(error)) {
return errorResponse(c, 'CGV_UPSTREAM_UNAVAILABLE', error.message, 503);
}
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
return errorResponse(c, 'CGV_THEATER_SEARCH_FAILED', message, 500);
}
Expand All @@ -95,7 +104,10 @@ export async function handleCgvSearchMovies(c: ApiContext) {
try {
let resolvedTheater = null;

if (!theaterCode && (keyword || typeof latitude === 'number' || typeof longitude === 'number')) {
if (
!theaterCode &&
(keyword || typeof latitude === 'number' || typeof longitude === 'number')
) {
const resolved = await resolveCgvNearestTheater(
{
playDate,
Expand Down Expand Up @@ -141,6 +153,9 @@ export async function handleCgvSearchMovies(c: ApiContext) {
{ total: movies.length },
);
} catch (error) {
if (isCgvUpstreamUnavailableError(error)) {
return errorResponse(c, 'CGV_UPSTREAM_UNAVAILABLE', error.message, 503);
}
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
return errorResponse(c, 'CGV_MOVIE_SEARCH_FAILED', message, 500);
}
Expand All @@ -163,7 +178,10 @@ export async function handleCgvGetTimetable(c: ApiContext) {
try {
let resolvedTheater = null;

if (!theaterCode && (keyword || typeof latitude === 'number' || typeof longitude === 'number')) {
if (
!theaterCode &&
(keyword || typeof latitude === 'number' || typeof longitude === 'number')
) {
const resolved = await resolveCgvNearestTheater(
{
playDate,
Expand Down Expand Up @@ -218,6 +236,9 @@ export async function handleCgvGetTimetable(c: ApiContext) {
{ total: filtered.length, pageSize: limit },
);
} catch (error) {
if (isCgvUpstreamUnavailableError(error)) {
return errorResponse(c, 'CGV_UPSTREAM_UNAVAILABLE', error.message, 503);
}
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
return errorResponse(c, 'CGV_TIMETABLE_FETCH_FAILED', message, 500);
}
Expand Down
10 changes: 10 additions & 0 deletions src/api/gs25Handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
selectGs25StoresForKeyword,
sortGs25Stores,
} from '../services/gs25/client.js';
import { isGs25UpstreamUnavailableError } from '../services/gs25/errors.js';

const GS25_FALLBACK_STORE_LOOKUP_ITEM_CODE = '8801117752804';

Expand Down Expand Up @@ -62,6 +63,7 @@ export async function handleGs25FindStores(c: ApiContext) {
{
timeout: 20000,
zyteApiKey: c.env?.ZYTE_API_KEY,
apiKey: c.env?.GS25_API_KEY,
},
);
let fallbackUsed = false;
Expand All @@ -84,6 +86,7 @@ export async function handleGs25FindStores(c: ApiContext) {
{
timeout: 20000,
zyteApiKey: c.env?.ZYTE_API_KEY,
apiKey: c.env?.GS25_API_KEY,
},
);

Expand Down Expand Up @@ -242,6 +245,7 @@ export async function handleGs25CheckInventory(c: ApiContext) {
{
timeout: 20000,
zyteApiKey: c.env?.ZYTE_API_KEY,
apiKey: c.env?.GS25_API_KEY,
},
);

Expand Down Expand Up @@ -284,6 +288,7 @@ export async function handleGs25CheckInventory(c: ApiContext) {
{
timeout: 20000,
zyteApiKey: c.env?.ZYTE_API_KEY,
apiKey: c.env?.GS25_API_KEY,
},
);
} else {
Expand All @@ -309,6 +314,7 @@ export async function handleGs25CheckInventory(c: ApiContext) {
{
timeout: 20000,
zyteApiKey: c.env?.ZYTE_API_KEY,
apiKey: c.env?.GS25_API_KEY,
},
);
} else {
Expand All @@ -324,6 +330,7 @@ export async function handleGs25CheckInventory(c: ApiContext) {
{
timeout: 20000,
zyteApiKey: c.env?.ZYTE_API_KEY,
apiKey: c.env?.GS25_API_KEY,
},
);
}
Expand Down Expand Up @@ -378,6 +385,9 @@ export async function handleGs25CheckInventory(c: ApiContext) {
},
});
} catch (error) {
if (isGs25UpstreamUnavailableError(error)) {
return errorResponse(c, 'GS25_UPSTREAM_UNAVAILABLE', error.message, 503);
}
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
return errorResponse(c, 'GS25_INVENTORY_CHECK_FAILED', message, 500);
}
Expand Down
Loading