8000 fix: 원본 호출 제한과 재시도 대기 보존 by hmmhmmhm · Pull Request #313 · hmmhmmhm/daiso-mcp · GitHub
[go: up one dir, main page]

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: 1 addition & 1 deletion docs/dtryx-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -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를 없애거나 검사를 성공으로 바꾸지 않고 중복 원본 호출을 줄이는 조치입니다.
20 changes: 17 additions & 3 deletions scripts/relay/daiso-jp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function createJapanProductRelay(
options: Pick<HttpRelayOptions, 'takeQuota' | 'fetcher' | 'onEvent'>,
) {
let blockedUntil = 0;
return createHttpRelay(token, {
const relay = createHttpRelay(token, {
...options,
prefix: '/v1/dtryx/',
operations: ['daiso-jp-product'],
Expand All @@ -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`), {
Expand All @@ -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;
};
}
57 changes: 48 additions & 9 deletions src/api/healthCheckEvidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,66 @@ export function healthFailureDiagnostics(body: FailureBody): Partial<HealthCheck
if (body.error?.code) result.errorCode = body.error.code;
const diagnostics = body.diagnostics;
if (!diagnostics) return result;
if (typeof diagnostics.upstreamStatus === 'number') result.upstreamStatus = diagnostics.upstreamStatus;
if (typeof diagnostics.upstreamStatus === 'number')
result.upstreamStatus = diagnostics.upstreamStatus;
const reason = diagnostics.quotaReason;
const retry = diagnostics.retryAfter;
if (reason === 'minute' || reason === 'daily' || reason === 'consumer' || reason === 'consumer-busy') {
if (
reason === 'minute' ||
reason === 'daily' ||
reason === 'consumer' ||
reason === 'consumer-busy'
) {
result.quotaReason = reason;
if (typeof retry === 'number' && Number.isInteger(retry) && retry > 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 {
Expand Down
7 changes: 5 additions & 2 deletions src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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),
};
}
4 changes: 4 additions & 0 deletions src/services/daisoJp/productRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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') ?? '',
)
Expand All @@ -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);
Expand Down
176 changes: 146 additions & 30 deletions tests/api/health-inventory-evidence.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
Loading
Loading
0