diff --git a/src/api/healthCheckDefinitions.ts b/src/api/healthCheckDefinitions.ts
index aef3248..b545c66 100644
--- a/src/api/healthCheckDefinitions.ts
+++ b/src/api/healthCheckDefinitions.ts
@@ -10,6 +10,11 @@ export const GS25_CLOUDFRONT_403_PATTERNS = [
'403 ERROR',
];
+export const EMART24_UPSTREAM_403_PATTERNS = [
+ '403 Forbidden',
+ '
403 Forbidden',
+];
+
export const HEALTH_CHECKS: HealthCheckDefinition[] = [
{
id: 'cli.contract',
@@ -45,6 +50,7 @@ export const HEALTH_CHECKS: HealthCheckDefinition[] = [
path: '/api/emart24/products?keyword=%EC%BB%A4%ED%94%BC&pageSize=1',
collectionKey: 'products',
requiredFields: ['pluCd', 'goodsName', 'itemName', 'name'],
+ degradedFailurePatterns: EMART24_UPSTREAM_403_PATTERNS,
},
{
id: 'gs25.products',
diff --git a/src/api/healthChecks.ts b/src/api/healthChecks.ts
index dac523e..e3f06c5 100644
--- a/src/api/healthChecks.ts
+++ b/src/api/healthChecks.ts
@@ -2,7 +2,11 @@
* 개별 서비스 헬스 체크 실행기
*/
-import { GS25_CLOUDFRONT_403_PATTERNS, HEALTH_CHECKS } from './healthCheckDefinitions.js';
+import {
+ EMART24_UPSTREAM_403_PATTERNS,
+ GS25_CLOUDFRONT_403_PATTERNS,
+ HEALTH_CHECKS,
+} from './healthCheckDefinitions.js';
import { hasRequiredRepresentativeFields, toCount, toFirstName } from './healthCheckShape.js';
import type {
HealthCheckDefinition,
@@ -129,7 +133,13 @@ function shouldDegradeFailedResponse(check: HealthCheckDefinition, message: stri
}
function shouldDegradeCliContractPath(path: string, message: string): boolean {
- return path.startsWith('/api/gs25/') && GS25_CLOUDFRONT_403_PATTERNS.some((pattern) => message.includes(pattern));
+ if (path.startsWith('/api/gs25/')) {
+ return GS25_CLOUDFRONT_403_PATTERNS.some((pattern) => message.includes(pattern));
+ }
+ if (path.startsWith('/api/emart24/')) {
+ return EMART24_UPSTREAM_403_PATTERNS.some((pattern) => message.includes(pattern));
+ }
+ return false;
}
function resolveCheckTimeoutMs(check: Pick, timeoutMs: number): number {
diff --git a/tests/app/app-health-checks.test.ts b/tests/app/app-health-checks.test.ts
index 9221dc5..570b4a5 100644
--- a/tests/app/app-health-checks.test.ts
+++ b/tests/app/app-health-checks.test.ts
@@ -216,6 +216,78 @@ describe('GET /api/health/checks', () => {
);
});
+ it('이마트24 상품 upstream 403은 degraded로 집계한다', async () => {
+ mockFetch.mockResolvedValueOnce(
+ jsonResponse(
+ {
+ success: false,
+ error: { message: 'API 요청 실패: 403 Forbidden - 403 Forbidden' },
+ },
+ 502,
+ ),
+ );
+
+ const res = await app.request(
+ '/api/health/checks?check=emart24.products&fresh=true&transport=network',
+ {
+ headers: { Authorization: 'Bearer test-secret' },
+ },
+ {
+ HEALTH_CHECK_SECRET: 'test-secret',
+ },
+ );
+
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.status).toBe('degraded');
+ expect(data.checks[0]).toEqual(
+ expect.objectContaining({
+ id: 'emart24.products',
+ status: 'degraded',
+ message: expect.stringContaining('403 Forbidden'),
+ }),
+ );
+ });
+
+ it('CLI 계약 체크에서 이마트24 upstream 403은 degraded로 집계한다', async () => {
+ mockFetch.mockImplementation((input: RequestInfo | URL) =>
+ Promise.resolve(
+ String(input).includes('/api/emart24/products')
+ ? jsonResponse(
+ {
+ success: false,
+ error: { message: 'API 요청 실패: 403 Forbidden - 403 Forbidden' },
+ },
+ 502,
+ )
+ : String(input).includes('/health')
+ ? jsonResponse({ status: 'ok' })
+ : jsonResponse({ success: true, data: { products: [{ name: '상품' }] }, meta: { total: 1 } }),
+ ),
+ );
+
+ const res = await app.request(
+ '/api/health/checks?check=cli.contract&mode=deep&fresh=true&transport=network',
+ {
+ headers: { Authorization: 'Bearer test-secret' },
+ },
+ {
+ HEALTH_CHECK_SECRET: 'test-secret',
+ },
+ );
+
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data.status).toBe('degraded');
+ expect(data.checks[0]).toEqual(
+ expect.objectContaining({
+ id: 'cli.contract',
+ status: 'degraded',
+ message: expect.stringContaining('/api/emart24/products'),
+ }),
+ );
+ });
+
it('fresh가 아니면 동일한 체크 결과를 캐시한다', async () => {
mockFetch.mockResolvedValue(
jsonResponse({