daiso 1.2.5 → 1.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/api/cgvHandlers.js +4 -4
- package/dist/api/healthChecks.js +14 -5
- package/dist/api/healthFailurePolicy.js +6 -0
- package/dist/api/response.js +2 -0
- package/dist/api/routes/oliveyoungRoutes.js +7 -6
- package/dist/core/errors.js +7 -1
- package/dist/core/registry.js +4 -0
- package/dist/index.js +3 -1
- package/dist/middleware/dailyRateLimit.js +1 -1
- package/dist/middleware/diagnostics.js +18 -0
- package/dist/services/cgv/errors.js +3 -5
- package/dist/services/dtryx/transport.js +2 -0
- package/dist/services/oliveyoung/errors.js +1 -1
- package/dist/services/oliveyoung/transport.js +22 -2
- package/dist/utils/diagnostics.js +86 -0
- package/dist/utils/http.js +12 -6
- package/dist/utils/oliveyoungCacheKey.js +28 -0
- package/dist/utils/relayQuota.js +44 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,11 +85,11 @@
|
|
|
85
85
|
**[실시간 서비스 상태 보기](https://aka-page.betteruptime.com/)**
|
|
86
86
|
|
|
87
87
|
<!-- WORKERS_INVOCATIONS_CHART:START -->
|
|
88
|
-
<h3>Cloudflare 요청 수 (2026-08-
|
|
88
|
+
<h3>Cloudflare 요청 수 (2026-08-30 ~ 2026-09-28, 30일)</h3>
|
|
89
89
|
|
|
90
|
-
<img src="./assets/analytics/workers-invocations.png?v=2026-09-
|
|
90
|
+
<img src="./assets/analytics/workers-invocations.png?v=2026-09-28T21:15:27.313Z" alt="Cloudflare 요청 수 그래프 (2026-08-30 ~ 2026-09-28)" width="100%">
|
|
91
91
|
|
|
92
|
-
<sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-09-
|
|
92
|
+
<sub>기준 워커: <code>daiso-mcp</code> · 마지막 갱신: 2026-09-29 06:15 KST</sub>
|
|
93
93
|
<br><sub>집계: Worker 실행 + 루트 GET 리디렉션 요청 · 사용자 수와 다릅니다.</sub>
|
|
94
94
|
|
|
95
95
|
</div>
|
package/dist/api/cgvHandlers.js
CHANGED
|
@@ -5,7 +5,7 @@ import { fetchCgvMovies, fetchCgvTheaters, fetchCgvTimetable, toYyyymmdd, } from
|
|
|
5
5
|
import { isCgvUpstreamUnavailableError } from '../services/cgv/errors.js';
|
|
6
6
|
import { fetchCgvNearbyTheaters, resolveCgvNearestTheater } from '../services/cgv/location.js';
|
|
7
7
|
import { filterAndSortTimetable } from '../services/cgv/timetable.js';
|
|
8
|
-
import { errorResponse, successResponse } from './response.js';
|
|
8
|
+
import { errorResponse, serviceErrorResponse, successResponse } from './response.js';
|
|
9
9
|
function parseOptionalNumber(value) {
|
|
10
10
|
if (!value) {
|
|
11
11
|
return undefined;
|
|
@@ -62,7 +62,7 @@ export async function handleCgvFindTheaters(c) {
|
|
|
62
62
|
}
|
|
63
63
|
catch (error) {
|
|
64
64
|
if (isCgvUpstreamUnavailableError(error)) {
|
|
65
|
-
return
|
|
65
|
+
return serviceErrorResponse(c, error, 'theaters');
|
|
66
66
|
}
|
|
67
67
|
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
|
|
68
68
|
return errorResponse(c, 'CGV_THEATER_SEARCH_FAILED', message, 500);
|
|
@@ -120,7 +120,7 @@ export async function handleCgvSearchMovies(c) {
|
|
|
120
120
|
}
|
|
121
121
|
catch (error) {
|
|
122
122
|
if (isCgvUpstreamUnavailableError(error)) {
|
|
123
|
-
return
|
|
123
|
+
return serviceErrorResponse(c, error, 'movies');
|
|
124
124
|
}
|
|
125
125
|
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
|
|
126
126
|
return errorResponse(c, 'CGV_MOVIE_SEARCH_FAILED', message, 500);
|
|
@@ -188,7 +188,7 @@ export async function handleCgvGetTimetable(c) {
|
|
|
188
188
|
}
|
|
189
189
|
catch (error) {
|
|
190
190
|
if (isCgvUpstreamUnavailableError(error)) {
|
|
191
|
-
return
|
|
191
|
+
return serviceErrorResponse(c, error, 'timetable');
|
|
192
192
|
}
|
|
193
193
|
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
|
|
194
194
|
return errorResponse(c, 'CGV_TIMETABLE_FETCH_FAILED', message, 500);
|
package/dist/api/healthChecks.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isKnownCgvBlock } from './healthFailurePolicy.js';
|
|
1
2
|
/**
|
|
2
3
|
* 개별 서비스 헬스 체크 실행기
|
|
3
4
|
*/
|
|
@@ -70,10 +71,14 @@ function aggregateStatus(checks) {
|
|
|
70
71
|
}
|
|
71
72
|
return 'ok';
|
|
72
73
|
}
|
|
73
|
-
function shouldDegradeFailedResponse(check, message) {
|
|
74
|
+
function shouldDegradeFailedResponse(check, message, status, body) {
|
|
75
|
+
if (isKnownCgvBlock(check.path, status, body))
|
|
76
|
+
return true;
|
|
74
77
|
return check.degradedFailurePatterns?.some((pattern) => message.includes(pattern)) ?? false;
|
|
75
78
|
}
|
|
76
|
-
function shouldDegradeCliContractPath(path, message) {
|
|
79
|
+
function shouldDegradeCliContractPath(path, message, status, body) {
|
|
80
|
+
if (isKnownCgvBlock(path, status, body))
|
|
81
|
+
return true;
|
|
77
82
|
if (path.startsWith('/api/gs25/')) {
|
|
78
83
|
return GS25_CLOUDFRONT_403_PATTERNS.some((pattern) => message.includes(pattern));
|
|
79
84
|
}
|
|
@@ -131,7 +136,7 @@ async function runCliContractCheck(check, params) {
|
|
|
131
136
|
const body = (await response.json().catch(() => ({})));
|
|
132
137
|
if (!response.ok || !isCliCompatibleEnvelope(path, body)) {
|
|
133
138
|
const message = body.error?.message || `${path} CLI 계약 응답이 올바르지 않습니다.`;
|
|
134
|
-
if (shouldDegradeCliContractPath(path, message)) {
|
|
139
|
+
if (shouldDegradeCliContractPath(path, message, response.status, body)) {
|
|
135
140
|
degradedMessages.push(`${path}: ${message}`);
|
|
136
141
|
continue;
|
|
137
142
|
}
|
|
@@ -179,7 +184,11 @@ async function runSingleCheck(check, params) {
|
|
|
179
184
|
const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, check, timeoutMs, cacheBustValue), {
|
|
180
185
|
signal: AbortSignal.timeout(timeoutMs),
|
|
181
186
|
});
|
|
182
|
-
const body = (await response.json().catch(() =>
|
|
187
|
+
const body = (await response.json().catch(() => {
|
|
188
|
+
if (response.ok)
|
|
189
|
+
throw new Error('invalid JSON response');
|
|
190
|
+
return {};
|
|
191
|
+
}));
|
|
183
192
|
const durationMs = params.now() - startedAt;
|
|
184
193
|
if (!response.ok || body.success === false) {
|
|
185
194
|
const message = body.error?.message || `HTTP ${response.status}`;
|
|
@@ -187,7 +196,7 @@ async function runSingleCheck(check, params) {
|
|
|
187
196
|
id: check.id,
|
|
188
197
|
service: check.service,
|
|
189
198
|
target: check.target,
|
|
190
|
-
status: shouldDegradeFailedResponse(check, message) ? 'degraded' : 'fail',
|
|
199
|
+
status: shouldDegradeFailedResponse(check, message, response.status, body) ? 'degraded' : 'fail',
|
|
191
200
|
durationMs,
|
|
192
201
|
httpStatus: response.status,
|
|
193
202
|
message,
|
package/dist/api/response.js
CHANGED
|
@@ -24,6 +24,8 @@ export function errorResponse(c, code, message, status = 400) {
|
|
|
24
24
|
}
|
|
25
25
|
/** 원문이 제거된 외부 서비스 오류를 실제 상태와 함께 반환합니다. */
|
|
26
26
|
export function serviceErrorResponse(c, error, operation) {
|
|
27
|
+
if (error.retryAfter !== undefined)
|
|
28
|
+
c.header('Retry-After', String(error.retryAfter));
|
|
27
29
|
return c.json({
|
|
28
30
|
success: false,
|
|
29
31
|
error: { code: error.code, message: error.message },
|
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 올리브영 GET API 라우트 등록
|
|
3
3
|
*/
|
|
4
|
+
import { oliveyoungCacheKey } from '../../utils/oliveyoungCacheKey.js';
|
|
4
5
|
import { withEdgeCache } from '../../utils/cache.js';
|
|
5
6
|
import { handleOliveyoungFindStores, handleOliveyoungCheckInventory, handleOliveyoungSearchProducts, } from '../handlers.js';
|
|
6
7
|
export function registerOliveyoungRoutes(app) {
|
|
7
|
-
app.get('/api/oliveyoung/products', async (c) => withEdgeCache(c.req.url, {
|
|
8
|
+
app.get('/api/oliveyoung/products', async (c) => withEdgeCache(oliveyoungCacheKey(c.req.url), {
|
|
8
9
|
ttlSeconds: 60 * 5,
|
|
9
10
|
staleWhileRevalidateSeconds: 60,
|
|
10
|
-
keyPrefix: 'oliveyoung-products-
|
|
11
|
+
keyPrefix: 'oliveyoung-products-v2',
|
|
11
12
|
}, () => handleOliveyoungSearchProducts(c)));
|
|
12
|
-
app.get('/api/oliveyoung/stores', async (c) => withEdgeCache(c.req.url, {
|
|
13
|
+
app.get('/api/oliveyoung/stores', async (c) => withEdgeCache(oliveyoungCacheKey(c.req.url), {
|
|
13
14
|
ttlSeconds: 60 * 60 * 24,
|
|
14
15
|
staleWhileRevalidateSeconds: 60 * 5,
|
|
15
|
-
keyPrefix: 'oliveyoung-stores-
|
|
16
|
+
keyPrefix: 'oliveyoung-stores-v2',
|
|
16
17
|
}, () => handleOliveyoungFindStores(c)));
|
|
17
|
-
app.get('/api/oliveyoung/inventory', async (c) => withEdgeCache(c.req.url, {
|
|
18
|
+
app.get('/api/oliveyoung/inventory', async (c) => withEdgeCache(oliveyoungCacheKey(c.req.url), {
|
|
18
19
|
ttlSeconds: 60 * 10,
|
|
19
20
|
staleWhileRevalidateSeconds: 60,
|
|
20
|
-
keyPrefix: 'oliveyoung-inventory-
|
|
21
|
+
keyPrefix: 'oliveyoung-inventory-v2',
|
|
21
22
|
}, () => handleOliveyoungCheckInventory(c)));
|
|
22
23
|
}
|
package/dist/core/errors.js
CHANGED
|
@@ -8,13 +8,17 @@ export class ServiceError extends Error {
|
|
|
8
8
|
status;
|
|
9
9
|
retryable;
|
|
10
10
|
upstreamStatus;
|
|
11
|
-
|
|
11
|
+
quotaReason;
|
|
12
|
+
retryAfter;
|
|
13
|
+
constructor(code, message, status, retryable, upstreamStatus, quota) {
|
|
12
14
|
super(message);
|
|
13
15
|
this.code = code;
|
|
14
16
|
this.status = status;
|
|
15
17
|
this.retryable = retryable;
|
|
16
18
|
this.upstreamStatus = upstreamStatus;
|
|
17
19
|
this.name = 'ServiceError';
|
|
20
|
+
this.quotaReason = quota?.quotaReason;
|
|
21
|
+
this.retryAfter = quota?.retryAfter;
|
|
18
22
|
}
|
|
19
23
|
}
|
|
20
24
|
const SERVICE_PREFIXES = [
|
|
@@ -116,6 +120,8 @@ export function toServiceErrorDiagnostics(error, operation) {
|
|
|
116
120
|
operation,
|
|
117
121
|
}),
|
|
118
122
|
retryable: error.retryable,
|
|
123
|
+
quotaReason: error.quotaReason,
|
|
124
|
+
retryAfter: error.retryAfter,
|
|
119
125
|
hint: buildHint(error.retryable),
|
|
120
126
|
};
|
|
121
127
|
}
|
package/dist/core/registry.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 서비스 프로바이더를 등록하고 MCP 서버에 도구를 연결합니다.
|
|
5
5
|
*/
|
|
6
|
+
import { diagnosticEvent } from '../utils/diagnostics.js';
|
|
6
7
|
import { getErrorMessage, ServiceError, toServiceErrorDiagnostics } from './errors.js';
|
|
7
8
|
import { createToolOutputSchema } from './outputSchema.js';
|
|
8
9
|
function parseJsonText(text) {
|
|
@@ -172,11 +173,13 @@ export class ServiceRegistry {
|
|
|
172
173
|
outputSchema: createToolOutputSchema(tool.name, tool.metadata.outputSchema),
|
|
173
174
|
};
|
|
174
175
|
server.registerTool(tool.name, metadata, async (args) => {
|
|
176
|
+
const started = Date.now();
|
|
175
177
|
let result;
|
|
176
178
|
try {
|
|
177
179
|
result = await tool.handler(args);
|
|
178
180
|
}
|
|
179
181
|
catch (error) {
|
|
182
|
+
diagnosticEvent({ stage: 'tool', operation: tool.name, outcome: 'error', durationMs: Date.now() - started });
|
|
180
183
|
const message = error instanceof ServiceError
|
|
181
184
|
? JSON.stringify({
|
|
182
185
|
error: { code: error.code, message: error.message },
|
|
@@ -188,6 +191,7 @@ export class ServiceRegistry {
|
|
|
188
191
|
content: [{ type: 'text', text: message }],
|
|
189
192
|
};
|
|
190
193
|
}
|
|
194
|
+
diagnosticEvent({ stage: 'tool', operation: tool.name, outcome: result.isError ? 'error' : 'ok', durationMs: Date.now() - started });
|
|
191
195
|
const content = result.content.map((item) => ({
|
|
192
196
|
type: item.type,
|
|
193
197
|
text: item.text,
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Cloudflare Workers에서 실행되는 플러그인 기반 MCP 서버입니다.
|
|
5
5
|
* 다이소, 편의점, 백화점 등 다양한 서비스를 확장할 수 있습니다.
|
|
6
6
|
*/
|
|
7
|
+
import { diagnosticsMiddleware } from './middleware/diagnostics.js';
|
|
7
8
|
import { dtryxTransportFromBindings } from './services/dtryx/transport.js';
|
|
8
9
|
import { Hono } from 'hono';
|
|
9
10
|
import { cors } from 'hono/cors';
|
|
@@ -245,6 +246,7 @@ const handleRootMcpRequest = async (c) => {
|
|
|
245
246
|
};
|
|
246
247
|
// Hono 앱 생성
|
|
247
248
|
const app = new Hono();
|
|
249
|
+
app.use('*', diagnosticsMiddleware);
|
|
248
250
|
// CORS 설정
|
|
249
251
|
app.use('*', cors({
|
|
250
252
|
origin: '*',
|
|
@@ -257,7 +259,7 @@ app.use('*', cors({
|
|
|
257
259
|
'Last-Event-ID',
|
|
258
260
|
'mcp-protocol-version',
|
|
259
261
|
],
|
|
260
|
-
exposeHeaders: ['mcp-session-id', 'mcp-protocol-version'],
|
|
262
|
+
exposeHeaders: ['mcp-session-id', 'mcp-protocol-version', 'x-request-id'],
|
|
261
263
|
}));
|
|
262
264
|
// Zyte 연동 공개 GET API의 일일 호출량을 IP별로 제한합니다.
|
|
263
265
|
app.use('/api/*', createDailyRateLimitMiddleware());
|
|
@@ -35,7 +35,7 @@ export async function hashRateLimitIdentity(ip) {
|
|
|
35
35
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ip));
|
|
36
36
|
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
37
37
|
}
|
|
38
|
-
function resolveRateLimitIdentity(request) {
|
|
38
|
+
export function resolveRateLimitIdentity(request) {
|
|
39
39
|
const ip = request.headers.get('CF-Connecting-IP')?.trim();
|
|
40
40
|
if (!ip || ip !== CROSS_ZONE_WORKER_CLIENT_IP) {
|
|
41
41
|
return ip || null;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { resolveRateLimitIdentity } from './dailyRateLimit.js';
|
|
2
|
+
import { withDiagnostics, diagnosticEvent, diagnosticHeaders, diagnosticOperation, } from '../utils/diagnostics.js';
|
|
3
|
+
export const diagnosticsMiddleware = async (c, next) => withDiagnostics(async () => {
|
|
4
|
+
const start = performance.now();
|
|
5
|
+
try {
|
|
6
|
+
await next();
|
|
7
|
+
}
|
|
8
|
+
finally {
|
|
9
|
+
c.header('x-request-id', diagnosticHeaders()['x-request-id']);
|
|
10
|
+
diagnosticEvent({
|
|
11
|
+
stage: 'request',
|
|
12
|
+
operation: diagnosticOperation(c.req.url),
|
|
13
|
+
outcome: c.error || c.res.status >= 400 ? 'error' : 'ok',
|
|
14
|
+
status: c.res.status,
|
|
15
|
+
durationMs: performance.now() - start,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}, resolveRateLimitIdentity(c.req.raw) || undefined);
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CGV 원본 및 대체 전송 경로를 사용할 수 없는 상태
|
|
3
3
|
*/
|
|
4
|
-
import { ZYTE_COST_POLICY_MESSAGE } from '../../core/errors.js';
|
|
5
|
-
export class CgvUpstreamUnavailableError extends
|
|
6
|
-
upstreamStatus;
|
|
4
|
+
import { ServiceError, ZYTE_COST_POLICY_MESSAGE } from '../../core/errors.js';
|
|
5
|
+
export class CgvUpstreamUnavailableError extends ServiceError {
|
|
7
6
|
constructor(upstreamStatus) {
|
|
8
7
|
const statusSuffix = upstreamStatus === undefined ? '' : ` (HTTP ${upstreamStatus})`;
|
|
9
|
-
super(`CGV 원본 서비스에 연결할 수 없습니다. ${ZYTE_COST_POLICY_MESSAGE}${statusSuffix}
|
|
10
|
-
this.upstreamStatus = upstreamStatus;
|
|
8
|
+
super('CGV_UPSTREAM_UNAVAILABLE', `CGV 원본 서비스에 연결할 수 없습니다. ${ZYTE_COST_POLICY_MESSAGE}${statusSuffix}`, 503, false, upstreamStatus);
|
|
11
9
|
this.name = 'CgvUpstreamUnavailableError';
|
|
12
10
|
}
|
|
13
11
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { diagnosticHeaders } from '../../utils/diagnostics.js';
|
|
1
2
|
/** 디트릭스 전용 중계 설정과 인증 전송입니다. */
|
|
2
3
|
import { ServiceError } from '../../core/errors.js';
|
|
3
4
|
import { createTimeoutController } from '../../utils/http.js';
|
|
@@ -38,6 +39,7 @@ export async function requestDtryxRelay(path, body, options, timeout) {
|
|
|
38
39
|
throw new ServiceError('DTRYX_RELAY_CONFIG_ERROR', '디트릭스 릴레이 URL, 토큰 및 Access 설정을 확인하세요.', 503, false);
|
|
39
40
|
}
|
|
40
41
|
const headers = {
|
|
42
|
+
...diagnosticHeaders(),
|
|
41
43
|
'Content-Type': 'application/json',
|
|
42
44
|
Accept: 'application/json',
|
|
43
45
|
Authorization: `Bearer ${relayToken}`,
|
|
@@ -3,7 +3,7 @@ import { ServiceError } from '../../core/errors.js';
|
|
|
3
3
|
import { HttpError, UnexpectedHtmlResponseError } from '../../utils/http.js';
|
|
4
4
|
export function toOliveyoungRelayError(error) {
|
|
5
5
|
if (error instanceof HttpError) {
|
|
6
|
-
return new ServiceError('OLIVEYOUNG_RELAY_HTTP_ERROR', `올리브영 브라우저 릴레이 요청 실패 (HTTP ${error.status})`, [429, 502, 503].includes(error.status) ? error.status : 502, error.status >= 500 || error.status === 408 || error.status === 429, error.status);
|
|
6
|
+
return new ServiceError('OLIVEYOUNG_RELAY_HTTP_ERROR', `올리브영 브라우저 릴레이 요청 실패 (HTTP ${error.status})`, [429, 502, 503].includes(error.status) ? error.status : 502, error.status >= 500 || error.status === 408 || error.status === 429, error.status, error.quota);
|
|
7
7
|
}
|
|
8
8
|
if (error instanceof Error && ['AbortError', 'TimeoutError'].includes(error.name)) {
|
|
9
9
|
return new ServiceError('OLIVEYOUNG_RELAY_TIMEOUT', '올리브영 브라우저 릴레이 요청 시간 초과', 504, true);
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { diagnosticHeaders, relayConsumer } from '../../utils/diagnostics.js';
|
|
1
2
|
/** 올리브영 무료 직접 요청 및 운영자가 설정한 브라우저 릴레이 전송. */
|
|
2
|
-
import {
|
|
3
|
+
import { createRelayCooldown, relayCredentialScope } from '../../utils/relayQuota.js';
|
|
4
|
+
import { HttpError, fetchJson } from '../../utils/http.js';
|
|
3
5
|
import { toOliveyoungRelayError } from './errors.js';
|
|
4
6
|
import { OLIVEYOUNG_API } from './api.js';
|
|
7
|
+
const cooldown = createRelayCooldown();
|
|
5
8
|
/** 설정 진단과 실제 요청에 같은 릴레이 URL 규칙을 적용합니다. */
|
|
6
9
|
export function isValidOliveyoungRelayUrl(value) {
|
|
7
10
|
if (!value?.trim())
|
|
@@ -27,6 +30,8 @@ export async function requestOliveyoung(path, body, options = {}) {
|
|
|
27
30
|
Accept: 'application/json',
|
|
28
31
|
'X-Requested-With': 'XMLHttpRequest',
|
|
29
32
|
};
|
|
33
|
+
let scope = '';
|
|
34
|
+
let consumer = 'legacy';
|
|
30
35
|
let url = `${OLIVEYOUNG_API.BASE_URL}${path}`;
|
|
31
36
|
if (relayUrl) {
|
|
32
37
|
if (!isValidOliveyoungRelayUrl(relayUrl)) {
|
|
@@ -35,7 +40,11 @@ export async function requestOliveyoung(path, body, options = {}) {
|
|
|
35
40
|
if (!relayToken?.trim())
|
|
36
41
|
throw new Error('OY_RELAY_TOKEN이 필요합니다.');
|
|
37
42
|
url = `${relayUrl.replace(/\/$/, '')}/v1/oliveyoung/${path.split('/').pop()}`;
|
|
43
|
+
Object.assign(headers, diagnosticHeaders());
|
|
38
44
|
headers.Authorization = `Bearer ${relayToken}`;
|
|
45
|
+
consumer = (await relayConsumer(relayToken)) || 'legacy';
|
|
46
|
+
if (consumer !== 'legacy')
|
|
47
|
+
headers['x-relay-consumer'] = consumer;
|
|
39
48
|
const { accessClientId, accessClientSecret } = options;
|
|
40
49
|
if (accessClientId !== undefined || accessClientSecret !== undefined) {
|
|
41
50
|
if (!accessClientId?.trim() || !accessClientSecret?.trim()) {
|
|
@@ -53,6 +62,14 @@ export async function requestOliveyoung(path, body, options = {}) {
|
|
|
53
62
|
headers.Referer = `${OLIVEYOUNG_API.BASE_URL}/`;
|
|
54
63
|
headers['Accept-Language'] = 'ko-KR,ko;q=0.9';
|
|
55
64
|
}
|
|
65
|
+
if (relayUrl) {
|
|
66
|
+
scope = await relayCredentialScope([relayUrl.replace(/\/$/, ''), relayToken, options.accessClientId, options.accessClientSecret]);
|
|
67
|
+
const quota = cooldown.get(scope, consumer);
|
|
68
|
+
if (quota)
|
|
69
|
+
throw toOliveyoungRelayError(new HttpError(429, '', '', new Headers({
|
|
70
|
+
'x-relay-quota-reason': quota.quotaReason, 'retry-after': String(quota.retryAfter),
|
|
71
|
+
})));
|
|
72
|
+
}
|
|
56
73
|
let result;
|
|
57
74
|
try {
|
|
58
75
|
result = await fetchJson(url, {
|
|
@@ -67,8 +84,11 @@ export async function requestOliveyoung(path, body, options = {}) {
|
|
|
67
84
|
});
|
|
68
85
|
}
|
|
69
86
|
catch (error) {
|
|
70
|
-
if (relayUrl)
|
|
87
|
+
if (relayUrl) {
|
|
88
|
+
if (error instanceof HttpError && error.quota)
|
|
89
|
+
cooldown.set(scope, consumer, error.quota);
|
|
71
90
|
throw toOliveyoungRelayError(error);
|
|
91
|
+
}
|
|
72
92
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
73
93
|
throw new Error('올리브영 API 요청 시간 초과');
|
|
74
94
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** 비밀값과 원문 없이 요청 경계를 연결하는 진단 컨텍스트입니다. */
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
const context = new AsyncLocalStorage();
|
|
4
|
+
export function withDiagnostics(work, consumerIdentity) {
|
|
5
|
+
if (context.getStore())
|
|
6
|
+
return work();
|
|
7
|
+
return context.run({ requestId: crypto.randomUUID(), sampled: Math.random() < 0.01, events: 0, consumerIdentity }, work);
|
|
8
|
+
}
|
|
9
|
+
export function diagnosticHeaders() {
|
|
10
|
+
const current = context.getStore();
|
|
11
|
+
return current ? { 'x-request-id': current.requestId } : {};
|
|
12
|
+
}
|
|
13
|
+
export function diagnosticEvent(event) {
|
|
14
|
+
const current = context.getStore();
|
|
15
|
+
// 요청당 상한으로 재시도/팬아웃 로그가 무한히 늘어나지 않게 합니다.
|
|
16
|
+
if (!current || (event.outcome === 'ok' && !current.sampled) || current.events >= 32)
|
|
17
|
+
return;
|
|
18
|
+
current.events++;
|
|
19
|
+
console.error(JSON.stringify({
|
|
20
|
+
event: 'diagnostic',
|
|
21
|
+
time: new Date().toISOString(),
|
|
22
|
+
requestId: current.requestId,
|
|
23
|
+
stage: event.stage,
|
|
24
|
+
outcome: event.outcome,
|
|
25
|
+
operation: event.operation,
|
|
26
|
+
status: event.status,
|
|
27
|
+
durationMs: event.durationMs,
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
const services = new Set([
|
|
31
|
+
'daiso',
|
|
32
|
+
'oliveyoung',
|
|
33
|
+
'dtryx',
|
|
34
|
+
'gs25',
|
|
35
|
+
'cu',
|
|
36
|
+
'seveneleven',
|
|
37
|
+
'emart24',
|
|
38
|
+
'lottemart',
|
|
39
|
+
'cgv',
|
|
40
|
+
'megabox',
|
|
41
|
+
'lottecinema',
|
|
42
|
+
'opinet',
|
|
43
|
+
'places',
|
|
44
|
+
'compare',
|
|
45
|
+
'feedback',
|
|
46
|
+
'health',
|
|
47
|
+
'rate-limit',
|
|
48
|
+
'actions',
|
|
49
|
+
]);
|
|
50
|
+
const operations = new Set([
|
|
51
|
+
'products',
|
|
52
|
+
'stores',
|
|
53
|
+
'inventory',
|
|
54
|
+
'movies',
|
|
55
|
+
'theaters',
|
|
56
|
+
'timetable',
|
|
57
|
+
'play-dates',
|
|
58
|
+
'seats',
|
|
59
|
+
'remaining-seats',
|
|
60
|
+
'average',
|
|
61
|
+
'lowest',
|
|
62
|
+
'requests',
|
|
63
|
+
'checks',
|
|
64
|
+
'stats',
|
|
65
|
+
'query',
|
|
66
|
+
'popwords',
|
|
67
|
+
'price',
|
|
68
|
+
]);
|
|
69
|
+
export function diagnosticOperation(url) {
|
|
70
|
+
const parts = new URL(url).pathname.split('/');
|
|
71
|
+
if (parts[1] === 'mcp')
|
|
72
|
+
return 'mcp';
|
|
73
|
+
if (parts[1] === 'api' && services.has(parts[2]) && operations.has(parts[3]))
|
|
74
|
+
return `${parts[2]}.${parts[3]}`;
|
|
75
|
+
return 'other';
|
|
76
|
+
}
|
|
77
|
+
/** 릴레이 전용 일별 익명 식별자이며 로그나 공개 응답에는 포함하지 않습니다. */
|
|
78
|
+
export async function relayConsumer(token) {
|
|
79
|
+
const identity = context.getStore()?.consumerIdentity;
|
|
80
|
+
if (!identity)
|
|
81
|
+
return undefined;
|
|
82
|
+
const encoder = new TextEncoder();
|
|
83
|
+
const key = await crypto.subtle.importKey('raw', encoder.encode(token), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
84
|
+
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(`${Math.floor(Date.now() / 86400000)}:${identity}`));
|
|
85
|
+
return Array.from(new Uint8Array(signature), byte => byte.toString(16).padStart(2, '0')).join('');
|
|
86
|
+
}
|
package/dist/utils/http.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*/
|
|
1
|
+
import { parseRelayQuota } from './relayQuota.js';
|
|
2
|
+
import { diagnosticEvent } from './diagnostics.js';
|
|
4
3
|
export class HttpError extends Error {
|
|
4
|
+
quota;
|
|
5
5
|
status;
|
|
6
6
|
statusText;
|
|
7
7
|
bodyText;
|
|
8
|
-
constructor(status, statusText, bodyText) {
|
|
8
|
+
constructor(status, statusText, bodyText, headers) {
|
|
9
9
|
const normalizedBody = bodyText.trim().replace(/\s+/g, ' ').slice(0, 300);
|
|
10
10
|
const detail = normalizedBody.length > 0 ? ` - ${normalizedBody}` : '';
|
|
11
11
|
super(`API 요청 실패: ${status} ${statusText}${detail}`);
|
|
@@ -13,6 +13,7 @@ export class HttpError extends Error {
|
|
|
13
13
|
this.status = status;
|
|
14
14
|
this.statusText = statusText;
|
|
15
15
|
this.bodyText = bodyText;
|
|
16
|
+
this.quota = parseRelayQuota(status, headers);
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
19
|
/** HTML 원문을 노출하지 않고 예상하지 못한 응답 형식을 구분합니다. */
|
|
@@ -73,11 +74,15 @@ async function requestWithTimeout(url, options, readResponse) {
|
|
|
73
74
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
74
75
|
const { controller, timeoutId } = createTimeoutController(timeout);
|
|
75
76
|
let readingResponse = false;
|
|
77
|
+
const started = Date.now();
|
|
78
|
+
let responseStatus;
|
|
76
79
|
try {
|
|
77
80
|
const response = await fetch(url, {
|
|
78
81
|
...restOptions,
|
|
79
82
|
signal: controller.signal,
|
|
80
83
|
});
|
|
84
|
+
responseStatus = response.status;
|
|
85
|
+
diagnosticEvent({ stage: 'http', outcome: response.ok ? 'ok' : 'error', status: response.status, durationMs: Date.now() - started });
|
|
81
86
|
if (retryAllowed &&
|
|
82
87
|
attempt < maxAttempts &&
|
|
83
88
|
isRetryableStatus(response.status, retryStatusCodes)) {
|
|
@@ -99,6 +104,7 @@ async function requestWithTimeout(url, options, readResponse) {
|
|
|
99
104
|
return await readResponse(response);
|
|
100
105
|
}
|
|
101
106
|
catch (error) {
|
|
107
|
+
diagnosticEvent({ stage: 'http', outcome: 'error', status: responseStatus, durationMs: Date.now() - started });
|
|
102
108
|
if (readingResponse || !retryAllowed || attempt >= maxAttempts) {
|
|
103
109
|
throw error;
|
|
104
110
|
}
|
|
@@ -133,7 +139,7 @@ async function readTextResponse(url, options) {
|
|
|
133
139
|
export async function fetchJson(url, options = {}) {
|
|
134
140
|
const { response, body } = await readTextResponse(url, options);
|
|
135
141
|
if (!response.ok || (options.expectedStatus !== undefined && response.status !== options.expectedStatus)) {
|
|
136
|
-
throw new HttpError(response.status, response.statusText, body);
|
|
142
|
+
throw new HttpError(response.status, response.statusText, body, response.headers);
|
|
137
143
|
}
|
|
138
144
|
if (/^\s*(?:<!doctype\s+html\b|<html\b)/i.test(body)) {
|
|
139
145
|
throw new UnexpectedHtmlResponseError(response.status, response.headers.get('content-type') || '');
|
|
@@ -143,7 +149,7 @@ export async function fetchJson(url, options = {}) {
|
|
|
143
149
|
export async function fetchText(url, options = {}) {
|
|
144
150
|
const { response, body } = await readTextResponse(url, options);
|
|
145
151
|
if (!response.ok) {
|
|
146
|
-
throw new HttpError(response.status, response.statusText, body);
|
|
152
|
+
throw new HttpError(response.status, response.statusText, body, response.headers);
|
|
147
153
|
}
|
|
148
154
|
return body;
|
|
149
155
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** 핸들러가 읽는 query만 캐시 키에 포함하고 동일한 기본값을 합칩니다. */
|
|
2
|
+
const product = { keyword: '', page: '1', size: '20', sort: '01', includeSoldOut: 'false' };
|
|
3
|
+
const location = { lat: '37.5665', lng: '126.978' };
|
|
4
|
+
const defaults = {
|
|
5
|
+
'/api/oliveyoung/products': product,
|
|
6
|
+
'/api/oliveyoung/stores': { keyword: '', ...location, pageIdx: '1', limit: '20' },
|
|
7
|
+
'/api/oliveyoung/inventory': {
|
|
8
|
+
...product,
|
|
9
|
+
...location,
|
|
10
|
+
storeKeyword: '',
|
|
11
|
+
storeLimit: '10',
|
|
12
|
+
stockCheckLimit: '5',
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
export function oliveyoungCacheKey(input) {
|
|
16
|
+
const url = new URL(input);
|
|
17
|
+
const values = defaults[url.pathname];
|
|
18
|
+
if (!values)
|
|
19
|
+
return input;
|
|
20
|
+
const key = new URL(url.origin + url.pathname);
|
|
21
|
+
for (const [name, fallback] of Object.entries({ ...values, timeoutMs: '15000' })) {
|
|
22
|
+
key.searchParams.set(name, url.searchParams.get(name) || fallback);
|
|
23
|
+
}
|
|
24
|
+
const fresh = url.searchParams.get('_healthCheck');
|
|
25
|
+
if (fresh !== null)
|
|
26
|
+
key.searchParams.set('_healthCheck', fresh);
|
|
27
|
+
return key.toString();
|
|
28
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function parseRelayQuota(status, headers) {
|
|
2
|
+
if (status !== 429 || !headers)
|
|
3
|
+
return undefined;
|
|
4
|
+
const reason = headers.get('x-relay-quota-reason');
|
|
5
|
+
const retry = headers.get('retry-after') || '';
|
|
6
|
+
if (reason !== 'minute' && reason !== 'daily' && reason !== 'consumer')
|
|
7
|
+
return undefined;
|
|
8
|
+
if (!/^[1-9][0-9]{0,4}$/.test(retry) || Number(retry) > 86400)
|
|
9
|
+
return undefined;
|
|
10
|
+
return { quotaReason: reason, retryAfter: Number(retry) };
|
|
11
|
+
}
|
|
12
|
+
/** 만료 후 정리하고 고정 크기에서 새 휴지 기록을 생략합니다. */
|
|
13
|
+
export function createRelayCooldown(now = () => Date.now(), maxEntries = 1024) {
|
|
14
|
+
const entries = new Map();
|
|
15
|
+
const prune = () => {
|
|
16
|
+
for (const [key, entry] of entries)
|
|
17
|
+
if (entry.until <= now())
|
|
18
|
+
entries.delete(key);
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
get(scope, consumer) {
|
|
22
|
+
prune();
|
|
23
|
+
const entry = entries.get(scope) || entries.get(`${scope}:${consumer}`);
|
|
24
|
+
if (!entry)
|
|
25
|
+
return undefined;
|
|
26
|
+
return { quotaReason: entry.quotaReason, retryAfter: Math.ceil((entry.until - now()) / 1000) };
|
|
27
|
+
},
|
|
28
|
+
set(scope, consumer, quota) {
|
|
29
|
+
prune();
|
|
30
|
+
const key = quota.quotaReason === 'consumer' ? `${scope}:${consumer}` : scope;
|
|
31
|
+
if (!entries.has(key) && entries.size >= maxEntries)
|
|
32
|
+
return;
|
|
33
|
+
const until = now() + quota.retryAfter * 1000;
|
|
34
|
+
if ((entries.get(key)?.until || 0) > until)
|
|
35
|
+
return;
|
|
36
|
+
entries.set(key, { quotaReason: quota.quotaReason, until });
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** 자격증명 원문을 저장하지 않는 릴레이/Access 구성별 키. */
|
|
41
|
+
export async function relayCredentialScope(values) {
|
|
42
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(values)));
|
|
43
|
+
return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
|
|
44
|
+
}
|
package/package.json
CHANGED