From 71bb5cedc81911ac44537f183473ff3ed3078cf6 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Sun, 25 Oct 2026 12:56:26 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9B=90=EB=B3=B8=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=20=EC=A0=9C=ED=95=9C=EA=B3=BC=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dtryx-relay.md | 2 +- scripts/relay/daiso-jp.ts | 20 ++- src/api/healthCheckEvidence.ts | 57 +++++- src/core/errors.ts | 7 +- src/services/daisoJp/productRelay.ts | 4 + tests/api/health-inventory-evidence.test.ts | 176 +++++++++++++++---- tests/relay/daiso-jp.test.ts | 29 ++- tests/services/daisoJp/product-relay.test.ts | 36 ++++ 8 files changed, 285 insertions(+), 46 deletions(-) diff --git a/docs/dtryx-relay.md b/docs/dtryx-relay.md index c16bc140..785a7dae 100644 --- a/docs/dtryx-relay.md +++ b/docs/dtryx-relay.md @@ -57,4 +57,4 @@ node --max-old-space-size=128 --env-file=/absolute/path/dtryx.env \ ## 일본 상품 상세의 외부 호출 제한 -일본 공식 상품 JSON이429를 반환하면 같은 Mac 중계의 모든 일본 상품 상세 요청이 대기 시간을 공유합니다. Retry-After를 읽고, 안내가 없으면60초 대기하며 최소1초·최대24시간으로 제한합니다. 인증된 캐시 우회도 원본 제한 대기는 우회하지 않습니다. 기존 성공 응답 캐시는 기존 정책대로 유지합니다. 이 대기는 정상 품절이나 자체 일일/분당 한도를 뜻하지 않습니다. 실패는502로 유지하며 x-relay-upstream-status:429를 Worker 공개 진단의 upstreamStatus에 전달합니다. 이 처리는 원본429를 없애거나 검사를 성공으로 바꾸지 않고 중복 원본 호출을 줄이는 조치입니다. +일본 공식 상품 JSON이429를 반환하면 같은 Mac 중계의 모든 일본 상품 상세 요청이 대기 시간을 공유합니다. Retry-After를 읽고, 안내가 없으면60초 대기하며 최소1초·최대24시간으로 제한합니다. 인증된 캐시 우회도 원본 제한 대기는 우회하지 않습니다. 기존 성공 응답 캐시는 기존 정책대로 유지합니다. 이 대기는 정상 품절이나 자체 일일/분당 한도를 뜻하지 않습니다. 원본 제한은HTTP429와 Retry-After로 전달하며 자체 quotaReason은 붙이지 않습니다. 그 외 원본 실패는 기존502를 유지하고 x-relay-upstream-status도 제공합니다. Worker 공개 진단에서도 원본429와 대기 시간을 유지합니다. 이 처리는 원본429를 없애거나 검사를 성공으로 바꾸지 않고 중복 원본 호출을 줄이는 조치입니다. diff --git a/scripts/relay/daiso-jp.ts b/scripts/relay/daiso-jp.ts index f4669f07..652d8533 100644 --- a/scripts/relay/daiso-jp.ts +++ b/scripts/relay/daiso-jp.ts @@ -13,7 +13,7 @@ export function createJapanProductRelay( options: Pick, ) { let blockedUntil = 0; - return createHttpRelay(token, { + const relay = createHttpRelay(token, { ...options, prefix: '/v1/dtryx/', operations: ['daiso-jp-product'], @@ -27,7 +27,7 @@ export function createJapanProductRelay( }, async upstream(_operation, value, signal, fetcher) { // 원본 제한은 품절이나 자체 할당량으로 바꾸지 않고 모든 상품에 대기를 공유합니다. - if (Date.now() < blockedUntil) throw new RelayError(502, undefined, undefined, 429); + if (Date.now() < blockedUntil) throw new RelayError(429, undefined, undefined, 429); const { id } = value as { id: string }; const response = await abortable( fetcher(new URL(`https://jp.daisonet.com/products/${id}.js`), { @@ -42,11 +42,25 @@ export function createJapanProductRelay( Math.max(1000, Math.min(parseRetryAfterDelayMs(response) ?? 60000, 86400000)); if (response.status !== 200) { void response.body?.cancel().catch(() => undefined); - throw new RelayError(502, undefined, undefined, response.status); + throw new RelayError( + response.status === 429 ? 429 : 502, + undefined, + undefined, + response.status, + ); } const data = await readJson(response.body, 2 * 1024 * 1024, signal, 502); parseProductDetail(data, id); return data; }, }); + return async (request: Request) => { + const response = await relay(request); + if (response.status === 429 && response.headers.get('x-relay-upstream-status') === '429') + response.headers.set( + 'Retry-After', + String(Math.max(1, Math.ceil((blockedUntil - Date.now()) / 1000))), + ); + return response; + }; } diff --git a/src/api/healthCheckEvidence.ts b/src/api/healthCheckEvidence.ts index 6527e519..26282da8 100644 --- a/src/api/healthCheckEvidence.ts +++ b/src/api/healthCheckEvidence.ts @@ -11,27 +11,66 @@ export function healthFailureDiagnostics(body: FailureBody): Partial 0 && retry <= 86400) result.retryAfter = retry; } + if ( + (result.quotaReason || diagnostics.upstreamStatus === 429) && + typeof retry === 'number' && + Number.isInteger(retry) && + retry > 0 && + retry <= 86400 + ) + result.retryAfter = retry; return result; } export function hasCheckedStoreStock(id: string, data: unknown): boolean { - if (!['cu.inventory', 'oliveyoung.inventory', 'gs25.inventory', 'seveneleven.inventory'].includes(id)) return true; + if ( + !['cu.inventory', 'oliveyoung.inventory', 'gs25.inventory', 'seveneleven.inventory'].includes( + id, + ) + ) + return true; if (!data || typeof data !== 'object') return false; const record = data as { nearbyStores?: { stores?: Array<{ stock?: unknown }> }; - inventory?: { stockCheckedCount?: number; products?: Array<{ stockSource?: unknown; storeInventory?: { stores?: Array<{ stockStatus?: unknown }> } }>; stores?: Array<{ realStockQuantity?: unknown; stockQuantity?: unknown }> }; + inventory?: { + stockCheckedCount?: number; + products?: Array<{ + stockSource?: unknown; + storeInventory?: { stores?: Array<{ stockStatus?: unknown }> }; + }>; + stores?: Array<{ realStockQuantity?: unknown; stockQuantity?: unknown }>; + }; }; - if (id === 'oliveyoung.inventory') return (record.inventory?.stockCheckedCount ?? 0) > 0 && (record.inventory?.products?.some(product => product.stockSource === 'nearby_store' && (product.storeInventory?.stores?.some(store => ['in_stock', 'out_of_stock', 'not_sold'].includes(String(store.stockStatus))) ?? false)) ?? false); - if (id === 'gs25.inventory') return record.inventory?.stores?.some(store => knownStock(store.realStockQuantity)) ?? false; - if (id === 'seveneleven.inventory') return record.inventory?.stores?.some(store => knownStock(store.stockQuantity)) ?? false; - return record.nearbyStores?.stores?.some(store => knownStock(store.stock)) ?? false; + if (id === 'oliveyoung.inventory') + return ( + (record.inventory?.stockCheckedCount ?? 0) > 0 && + (record.inventory?.products?.some( + (product) => + product.stockSource === 'nearby_store' && + (product.storeInventory?.stores?.some((store) => + ['in_stock', 'out_of_stock', 'not_sold'].includes(String(store.stockStatus)), + ) ?? + false), + ) ?? + false) + ); + if (id === 'gs25.inventory') + return record.inventory?.stores?.some((store) => knownStock(store.realStockQuantity)) ?? false; + if (id === 'seveneleven.inventory') + return record.inventory?.stores?.some((store) => knownStock(store.stockQuantity)) ?? false; + return record.nearbyStores?.stores?.some((store) => knownStock(store.stock)) ?? false; } function knownStock(value: unknown): boolean { diff --git a/src/core/errors.ts b/src/core/errors.ts index 0eb54a8f..0f8f94d7 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -34,11 +34,12 @@ export class ServiceError extends Error { readonly upstreamStatus?: number, quota?: RelayQuota, readonly upstreamError?: SevenStockFailure, + retryAfter?: number, ) { super(message); this.name = 'ServiceError'; this.quotaReason = quota?.quotaReason; - this.retryAfter = quota?.retryAfter; + this.retryAfter = quota?.retryAfter ?? retryAfter; } } @@ -176,6 +177,8 @@ export function toServiceErrorDiagnostics( : {}), hint: error.quotaReason ? `중계 사용량 제한입니다. ${error.retryAfter}초 후 재시도해주세요.` - : error.upstreamError ? EXTERNAL_SERVICE_RETRY_HINT : buildHint(error.retryable), + : error.upstreamError + ? EXTERNAL_SERVICE_RETRY_HINT + : buildHint(error.retryable), }; } diff --git a/src/services/daisoJp/productRelay.ts b/src/services/daisoJp/productRelay.ts index c4634171..038c7111 100644 --- a/src/services/daisoJp/productRelay.ts +++ b/src/services/daisoJp/productRelay.ts @@ -2,6 +2,7 @@ import { ServiceError } from '../../core/errors.js'; import { diagnosticHeaders } from '../../utils/diagnostics.js'; import { parseRelayQuota } from '../../utils/relayQuota.js'; +import { parseRetryAfterDelayMs } from '../../utils/http.js'; import { dtryxTransportFromBindings, isValidDtryxRelayUrl, @@ -84,6 +85,7 @@ export async function requestJapanProductRelay( if (response.status !== 200) { void response.body?.cancel().catch(() => undefined); const status = response.status; + const delay = status === 429 ? parseRetryAfterDelayMs(response) : null; const upstreamStatus = /^[1-5]\d{2}$/.test( response.headers.get('x-relay-upstream-status') ?? '', ) @@ -96,6 +98,8 @@ export async function requestJapanProductRelay( status === 429 || status >= 500, upstreamStatus, parseRelayQuota(status, response.headers), + undefined, + delay === null ? undefined : Math.max(1, Math.min(86400, Math.ceil(delay / 1000))), ); } return parseProductDetail(await response.json(), id); diff --git a/tests/api/health-inventory-evidence.test.ts b/tests/api/health-inventory-evidence.test.ts index 09c73c4a..b0774a6b 100644 --- a/tests/api/health-inventory-evidence.test.ts +++ b/tests/api/health-inventory-evidence.test.ts @@ -1,58 +1,174 @@ import { describe, expect, it, vi } from 'vitest'; import { HEALTH_CHECKS } from '../../src/api/healthCheckDefinitions.js'; import { runHealthChecks } from '../../src/api/healthChecks.js'; -import { healthFailureDiagnostics, hasCheckedStoreStock } from '../../src/api/healthCheckEvidence.js'; +import { + healthFailureDiagnostics, + hasCheckedStoreStock, +} from '../../src/api/healthCheckEvidence.js'; describe('실제 재고 헬스체크', () => { it('할당량 이외 진단과 잘못된 재시도 시간을 구분한다', () => { expect(healthFailureDiagnostics({})).toEqual({}); expect(healthFailureDiagnostics({ diagnostics: {} })).toEqual({}); - expect(healthFailureDiagnostics({ error: { code: 'UPSTREAM_FAILED' }, diagnostics: { upstreamStatus: 403, quotaReason: 'vendor', retryAfter: 1 } })).toEqual({ errorCode: 'UPSTREAM_FAILED', upstreamStatus: 403 }); + expect( + healthFailureDiagnostics({ + error: { code: 'UPSTREAM_FAILED' }, + diagnostics: { upstreamStatus: 403, quotaReason: 'vendor', retryAfter: 1 }, + }), + ).toEqual({ errorCode: 'UPSTREAM_FAILED', upstreamStatus: 403 }); for (const reason of ['minute', 'daily', 'consumer', 'consumer-busy']) { - expect(healthFailureDiagnostics({ diagnostics: { quotaReason: reason, retryAfter: 1 } })).toEqual({ quotaReason: reason, retryAfter: 1 }); + expect( + healthFailureDiagnostics({ diagnostics: { quotaReason: reason, retryAfter: 1 } }), + ).toEqual({ quotaReason: reason, retryAfter: 1 }); for (const retryAfter of [undefined, '1', 0, -1, 0.5, 86401]) { - expect(healthFailureDiagnostics({ diagnostics: { quotaReason: reason, retryAfter } })).toEqual({ quotaReason: reason }); + expect( + healthFailureDiagnostics({ diagnostics: { quotaReason: reason, retryAfter } }), + ).toEqual({ quotaReason: reason }); } } }); it('0개는 확인된 품절이고 미확인 수량은 확인 실패다', () => { expect(hasCheckedStoreStock('daiso.products', null)).toBe(true); - for (const value of [null, 'bad', {}, { nearbyStores: {} }, { nearbyStores: { stores: [] } }]) expect(hasCheckedStoreStock('cu.inventory', value)).toBe(false); - for (const stock of [undefined, '0', -1, NaN, Infinity]) expect(hasCheckedStoreStock('cu.inventory', { nearbyStores: { stores: [{ stock }] } })).toBe(false); - expect(hasCheckedStoreStock('cu.inventory', { nearbyStores: { stores: [{ stock: 0 }] } })).toBe(true); + for (const value of [null, 'bad', {}, { nearbyStores: {} }, { nearbyStores: { stores: [] } }]) + expect(hasCheckedStoreStock('cu.inventory', value)).toBe(false); + for (const stock of [undefined, '0', -1, NaN, Infinity]) + expect(hasCheckedStoreStock('cu.inventory', { nearbyStores: { stores: [{ stock }] } })).toBe( + false, + ); + expect(hasCheckedStoreStock('cu.inventory', { nearbyStores: { stores: [{ stock: 0 }] } })).toBe( + true, + ); for (const id of ['gs25.inventory', 'seveneleven.inventory']) { - for (const data of [{}, { inventory: {} }]) expect(hasCheckedStoreStock(id, data)).toBe(false); - expect(hasCheckedStoreStock(id, { inventory: { stores: [{ realStockQuantity: 0, stockQuantity: 0 }] } })).toBe(true); - expect(hasCheckedStoreStock(id, { inventory: { stores: [{ realStockQuantity: -1, stockQuantity: -1 }] } })).toBe(false); + for (const data of [{}, { inventory: {} }]) + expect(hasCheckedStoreStock(id, data)).toBe(false); + expect( + hasCheckedStoreStock(id, { + inventory: { stores: [{ realStockQuantity: 0, stockQuantity: 0 }] }, + }), + ).toBe(true); + expect( + hasCheckedStoreStock(id, { + inventory: { stores: [{ realStockQuantity: -1, stockQuantity: -1 }] }, + }), + ).toBe(false); } - expect(hasCheckedStoreStock('oliveyoung.inventory', { inventory: { stockCheckedCount: 1 } })).toBe(false); - for (const product of [{ stockStatus: 'out_of_stock' }, { stockSource: 'nearby_store' }, { stockSource: 'nearby_store', storeInventory: {} }, { stockSource: 'nearby_store', storeInventory: { stores: [] } }, { stockSource: 'nearby_store', storeInventory: { stores: [{ stockStatus: 'unknown' }] } }]) expect(hasCheckedStoreStock('oliveyoung.inventory', { inventory: { stockCheckedCount: 1, products: [product] } })).toBe(false); - expect(hasCheckedStoreStock('oliveyoung.inventory', { inventory: { stockCheckedCount: 1, products: [{ stockStatus: 'unknown' }] } })).toBe(false); - for (const data of [{}, { inventory: {} }, { inventory: { stockCheckedCount: 0 } }]) expect(hasCheckedStoreStock('oliveyoung.inventory', data)).toBe(false); - expect(hasCheckedStoreStock('oliveyoung.inventory', { inventory: { stockCheckedCount: 1, products: [{ stockSource: 'nearby_store', storeInventory: { stores: [{ stockStatus: 'in_stock' }] } }] } })).toBe(true); + expect( + hasCheckedStoreStock('oliveyoung.inventory', { inventory: { stockCheckedCount: 1 } }), + ).toBe(false); + for (const product of [ + { stockStatus: 'out_of_stock' }, + { stockSource: 'nearby_store' }, + { stockSource: 'nearby_store', storeInventory: {} }, + { stockSource: 'nearby_store', storeInventory: { stores: [] } }, + { stockSource: 'nearby_store', storeInventory: { stores: [{ stockStatus: 'unknown' }] } }, + ]) + expect( + hasCheckedStoreStock('oliveyoung.inventory', { + inventory: { stockCheckedCount: 1, products: [product] }, + }), + ).toBe(false); + expect( + hasCheckedStoreStock('oliveyoung.inventory', { + inventory: { stockCheckedCount: 1, products: [{ stockStatus: 'unknown' }] }, + }), + ).toBe(false); + for (const data of [{}, { inventory: {} }, { inventory: { stockCheckedCount: 0 } }]) + expect(hasCheckedStoreStock('oliveyoung.inventory', data)).toBe(false); + expect( + hasCheckedStoreStock('oliveyoung.inventory', { + inventory: { + stockCheckedCount: 1, + products: [ + { + stockSource: 'nearby_store', + storeInventory: { stores: [{ stockStatus: 'in_stock' }] }, + }, + ], + }, + }), + ).toBe(true); }); it('편의점과 올리브영은 한 매장 실제 재고를 요청한다', () => { - for (const id of ['cu.inventory', 'gs25.inventory', 'seveneleven.inventory', 'oliveyoung.inventory']) { - const url = new URL(HEALTH_CHECKS.find(check => check.id === id)!.path, 'https://test.invalid'); + for (const id of [ + 'cu.inventory', + 'gs25.inventory', + 'seveneleven.inventory', + 'oliveyoung.inventory', + ]) { + const url = new URL( + HEALTH_CHECKS.find((check) => check.id === id)!.path, + 'https://test.invalid', + ); expect(url.searchParams.get('storeLimit')).toBe('1'); if (id === 'seveneleven.inventory') expect(url.searchParams.get('keyword')).toBe('핫식스'); if (id === 'cu.inventory') expect(url.searchParams.get('storeCheck')).toBe('true'); if (id === 'oliveyoung.inventory') expect(url.searchParams.get('stockCheckLimit')).toBe('1'); } }); - it.each(['cu.inventory', 'oliveyoung.inventory', 'gs25.inventory', 'seveneleven.inventory'])('상품만 받은 %s 응답은 정상 재고로 표시하지 않는다', async check => { - const fetchImpl = vi.fn().mockResolvedValue(new Response(JSON.stringify({ success: true, data: { - inventory: { items: [{ itemCode: 'C1' }], products: [{ goodsNumber: 'A1' }], stores: [{ storeCode: 'S1' }] }, stores: [{ storeCode: 'S1' }], - }, meta: { total: 1 } }))); - const result = await runHealthChecks({ baseUrl: 'https://test.invalid', check, mode: 'deep', fresh: true, fetchImpl }); - expect(result.checks[0]).toMatchObject({ status: 'fail', message: 'store stock was not verified' }); - }); + it.each(['cu.inventory', 'oliveyoung.inventory', 'gs25.inventory', 'seveneleven.inventory'])( + '상품만 받은 %s 응답은 정상 재고로 표시하지 않는다', + async (check) => { + const fetchImpl = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + data: { + inventory: { + items: [{ itemCode: 'C1' }], + products: [{ goodsNumber: 'A1' }], + stores: [{ storeCode: 'S1' }], + }, + stores: [{ storeCode: 'S1' }], + }, + meta: { total: 1 }, + }), + ), + ); + const result = await runHealthChecks({ + baseUrl: 'https://test.invalid', + check, + mode: 'deep', + fresh: true, + fetchImpl, + }); + expect(result.checks[0]).toMatchObject({ + status: 'fail', + message: 'store stock was not verified', + }); + }, + ); it('자체 할당량 사유와 재시도 시간을 결과에 보존한다', async () => { - const fetchImpl = vi.fn().mockResolvedValue(new Response(JSON.stringify({ success: false, - error: { code: 'GS25_RELAY_QUOTA_EXCEEDED', message: 'quota exceeded' }, - diagnostics: { quotaReason: 'minute', retryAfter: 37, upstreamStatus: 429 }, - }), { status: 429 })); - const result = await runHealthChecks({ baseUrl: 'https://test.invalid', check: 'gs25.inventory', mode: 'deep', fresh: true, fetchImpl }); - expect(result.checks[0]).toMatchObject({ status: 'fail', errorCode: 'GS25_RELAY_QUOTA_EXCEEDED', quotaReason: 'minute', retryAfter: 37, upstreamStatus: 429 }); + const fetchImpl = vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ + success: false, + error: { code: 'GS25_RELAY_QUOTA_EXCEEDED', message: 'quota exceeded' }, + diagnostics: { quotaReason: 'minute', retryAfter: 37, upstreamStatus: 429 }, + }), + { status: 429 }, + ), + ); + const result = await runHealthChecks({ + baseUrl: 'https://test.invalid', + check: 'gs25.inventory', + mode: 'deep', + fresh: true, + fetchImpl, + }); + expect(result.checks[0]).toMatchObject({ + status: 'fail', + errorCode: 'GS25_RELAY_QUOTA_EXCEEDED', + quotaReason: 'minute', + retryAfter: 37, + upstreamStatus: 429, + }); }); }); + +it('원본429의 대기는 자체 할당량 분류 없이 헬스 결과에 남긴다', () => { + expect( + healthFailureDiagnostics({ diagnostics: { upstreamStatus: 429, retryAfter: 120 } }), + ).toEqual({ upstreamStatus: 429, retryAfter: 120 }); +}); diff --git a/tests/relay/daiso-jp.test.ts b/tests/relay/daiso-jp.test.ts index 5f0a8f6e..0d3926d8 100644 --- a/tests/relay/daiso-jp.test.ts +++ b/tests/relay/daiso-jp.test.ts @@ -72,7 +72,7 @@ it('원본429 뒤 Retry-After 동안 다른 상품과 캐시 우회도 원본을 ); const bypass = req({ id: '4550746075918' }); bypass.headers.set('x-relay-cache-bypass', 'true'); - expect((await handler(bypass)).status).toBe(502); + expect((await handler(bypass)).status).toBe(429); expect(fetcher).toHaveBeenCalledTimes(1); vi.setSystemTime(Date.now() + 120001); expect((await handler(req({ id: raw.handle }))).status).toBe(200); @@ -96,3 +96,30 @@ it('원본429에 대기 안내가 없으면1분 후 원본을 재검증한다', vi.useRealTimers(); } }); + +it('원본429 대기는 표준 헤더로 제공하고 자체 분당 제한은 구분한다', async () => { + vi.useFakeTimers(); + try { + const handler = createJapanProductRelay('test-token', { + takeQuota: async () => true, + fetcher: async () => new Response(null, { status: 429, headers: { 'Retry-After': '120' } }), + }); + for (let i = 0; i < 2; i++) { + const r = await handler(req({ id: raw.handle })); + expect(r.status).toBe(429); + expect(r.headers.get('Retry-After')).toBe('120'); + expect(r.headers.get('x-relay-quota-reason')).toBeNull(); + } + const own = createJapanProductRelay('test-token', { + takeQuota: Object.assign(async () => false, { + status: () => ({ blockedBy: 'minute' as const, retryAfter: 7 }), + }), + }); + const r = await own(req({ id: raw.handle })); + expect(r.status).toBe(429); + expect(r.headers.get('Retry-After')).toBe('7'); + expect(r.headers.get('x-relay-quota-reason')).toBe('minute'); + } finally { + vi.useRealTimers(); + } +}); diff --git a/tests/services/daisoJp/product-relay.test.ts b/tests/services/daisoJp/product-relay.test.ts index a91cd55b..497c06e3 100644 --- a/tests/services/daisoJp/product-relay.test.ts +++ b/tests/services/daisoJp/product-relay.test.ts @@ -143,3 +143,39 @@ it.each(['429', '403', 'bad'])('원본 상태 %s를 자체 할당량과 구분 quotaReason: undefined, }); }); + +it('원본429의 대기 시간을 자체 할당량으로 분류하지 않고 보존한다', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 429, headers: { 'Retry-After': '120' } })), + ); + await expect(requestJapanProductRelay(raw.handle, options)).rejects.toMatchObject({ + status: 429, + upstreamStatus: 429, + retryAfter: 120, + quotaReason: undefined, + }); +}); + +it('실제 중계 응답의 원본 상태 커스텀 헤더가 없어도429와 대기는 보존된다', async () => { + const { createJapanProductRelay } = await import('../../../scripts/relay/daiso-jp.js'); + const { toServiceErrorDiagnostics, ServiceError } = await import('../../../src/core/errors.js'); + const relay = createJapanProductRelay('test-token', { + takeQuota: async () => true, + fetcher: async () => new Response(null, { status: 429, headers: { 'Retry-After': '120' } }), + }); + vi.stubGlobal('fetch', async (url: string, init: RequestInit) => { + const r = await relay(new Request(url, init)); + r.headers.delete('x-relay-upstream-status'); + return r; + }); + try { + await requestJapanProductRelay(raw.handle, options); + throw Error('unexpected success'); + } catch (e) { + expect(e).toBeInstanceOf(ServiceError); + expect( + toServiceErrorDiagnostics(e as InstanceType, 'product_details'), + ).toMatchObject({ status: 429, upstreamStatus: 429, retryAfter: 120, quotaReason: undefined }); + } +});