daiso 1.2.6 → 1.2.8

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.
@@ -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 errorResponse(c, 'CGV_UPSTREAM_UNAVAILABLE', error.message, 503);
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 errorResponse(c, 'CGV_UPSTREAM_UNAVAILABLE', error.message, 503);
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 errorResponse(c, 'CGV_UPSTREAM_UNAVAILABLE', error.message, 503);
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);
@@ -32,8 +32,9 @@ export async function handleOliveyoungSearchProducts(c) {
32
32
  const size = parseInt(c.req.query('size') || '20');
33
33
  const sort = c.req.query('sort') || '01';
34
34
  const includeSoldOut = c.req.query('includeSoldOut') === 'true';
35
- const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10);
36
- const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000;
35
+ const defaultTimeoutMs = c.env?.OY_RELAY_URL ? 60000 : 15000;
36
+ const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '', 10);
37
+ const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : defaultTimeoutMs;
37
38
  if (keyword.trim().length === 0) {
38
39
  return errorResponse(c, 'MISSING_QUERY', '검색어(keyword)를 입력해주세요.');
39
40
  }
@@ -76,8 +77,9 @@ export async function handleOliveyoungFindStores(c) {
76
77
  const lng = parseFloat(c.req.query('lng') || '126.978');
77
78
  const pageIdx = parseInt(c.req.query('pageIdx') || '1');
78
79
  const limit = parseInt(c.req.query('limit') || '20');
79
- const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10);
80
- const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000;
80
+ const defaultTimeoutMs = c.env?.OY_RELAY_URL ? 60000 : 15000;
81
+ const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '', 10);
82
+ const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : defaultTimeoutMs;
81
83
  try {
82
84
  const result = await fetchOliveyoungStores({
83
85
  latitude: lat,
@@ -116,8 +118,9 @@ export async function handleOliveyoungCheckInventory(c) {
116
118
  const sort = c.req.query('sort') || '01';
117
119
  const includeSoldOut = c.req.query('includeSoldOut') === 'true';
118
120
  const storeLimit = parseInt(c.req.query('storeLimit') || '10');
119
- const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '15000', 10);
120
- const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : 15000;
121
+ const defaultTimeoutMs = c.env?.OY_RELAY_URL ? 60000 : 15000;
122
+ const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || '', 10);
123
+ const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : defaultTimeoutMs;
121
124
  const parsedStockCheckLimit = parseInt(c.req.query('stockCheckLimit') || '5', 10);
122
125
  const stockCheckLimit = Number.isFinite(parsedStockCheckLimit) && parsedStockCheckLimit >= 0 ? parsedStockCheckLimit : 5;
123
126
  if (!keyword || keyword.trim().length === 0) {
@@ -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,
@@ -0,0 +1,6 @@
1
+ export function isKnownCgvBlock(path, status, body) {
2
+ return (path.startsWith('/api/cgv/') &&
3
+ status === 503 &&
4
+ body.error?.code === 'CGV_UPSTREAM_UNAVAILABLE' &&
5
+ body.diagnostics?.upstreamStatus === 403);
6
+ }
@@ -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 },
@@ -8,13 +8,17 @@ export class ServiceError extends Error {
8
8
  status;
9
9
  retryable;
10
10
  upstreamStatus;
11
- constructor(code, message, status, retryable, upstreamStatus) {
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
  }
@@ -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;
@@ -1,3 +1,4 @@
1
+ import { resolveRateLimitIdentity } from './dailyRateLimit.js';
1
2
  import { withDiagnostics, diagnosticEvent, diagnosticHeaders, diagnosticOperation, } from '../utils/diagnostics.js';
2
3
  export const diagnosticsMiddleware = async (c, next) => withDiagnostics(async () => {
3
4
  const start = performance.now();
@@ -14,4 +15,4 @@ export const diagnosticsMiddleware = async (c, next) => withDiagnostics(async ()
14
15
  durationMs: performance.now() - start,
15
16
  });
16
17
  }
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 Error {
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
  }
@@ -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);
@@ -4,7 +4,7 @@
4
4
  import * as z from 'zod';
5
5
  import { enrichOliveyoungProductsWithNearbyStoreInventory, fetchOliveyoungProducts, fetchOliveyoungStores, } from '../client.js';
6
6
  async function checkInventory(args, transport) {
7
- const { keyword, latitude = 37.5665, longitude = 126.978, storeKeyword = '', page = 1, size = 20, sort = '01', includeSoldOut = false, storeLimit = 10, stockCheckLimit = 5, timeoutMs = 15000, zyteApiKey, } = args;
7
+ const { keyword, latitude = 37.5665, longitude = 126.978, storeKeyword = '', page = 1, size = 20, sort = '01', includeSoldOut = false, storeLimit = 10, stockCheckLimit = 5, timeoutMs = transport.timeout ?? (transport.relayUrl ? 60000 : 15000), zyteApiKey, } = args;
8
8
  if (!keyword || keyword.trim().length === 0) {
9
9
  throw new Error('상품 검색어(keyword)를 입력해주세요.');
10
10
  }
@@ -15,9 +15,9 @@ async function checkInventory(args, transport) {
15
15
  pageIdx: 1,
16
16
  searchWords: storeKeyword,
17
17
  }, {
18
- timeout: timeoutMs,
19
18
  apiKey: zyteApiKey,
20
19
  ...transport,
20
+ timeout: timeoutMs,
21
21
  }),
22
22
  fetchOliveyoungProducts({
23
23
  keyword,
@@ -26,9 +26,9 @@ async function checkInventory(args, transport) {
26
26
  sort,
27
27
  includeSoldOut,
28
28
  }, {
29
- timeout: timeoutMs,
30
29
  apiKey: zyteApiKey,
31
30
  ...transport,
31
+ timeout: timeoutMs,
32
32
  }),
33
33
  ]);
34
34
  const enrichedInventory = await enrichOliveyoungProductsWithNearbyStoreInventory(productResult.products, {
@@ -37,9 +37,9 @@ async function checkInventory(args, transport) {
37
37
  storeKeyword,
38
38
  maxProducts: Math.min(productResult.products.length, Math.max(0, stockCheckLimit)),
39
39
  }, {
40
- timeout: timeoutMs,
41
40
  apiKey: zyteApiKey,
42
41
  ...transport,
42
+ timeout: timeoutMs,
43
43
  });
44
44
  const inStockProducts = enrichedInventory.products.filter((product) => product.inStock);
45
45
  const result = {
@@ -98,7 +98,7 @@ export function createCheckInventoryTool(apiKey, transport = {}) {
98
98
  .optional()
99
99
  .default(5)
100
100
  .describe('주변 매장별 재고를 보강할 상품 수 (기본값: 5, 최대: 5, 낮출수록 빠름)'),
101
- timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
101
+ timeoutMs: z.number().optional().default(transport.timeout ?? (transport.relayUrl ? 60000 : 15000)).describe('개별 조회 제한 시간(ms, 기본값: 무료 릴레이 60000 / 직접 15000)'),
102
102
  },
103
103
  },
104
104
  handler: ((args) => checkInventory({ ...args, zyteApiKey: apiKey }, transport)),
@@ -4,16 +4,16 @@
4
4
  import * as z from 'zod';
5
5
  import { fetchOliveyoungStores } from '../client.js';
6
6
  async function findNearbyStores(args, transport) {
7
- const { latitude = 37.5665, longitude = 126.978, keyword = '', pageIdx = 1, limit = 20, timeoutMs = 15000, zyteApiKey, } = args;
7
+ const { latitude = 37.5665, longitude = 126.978, keyword = '', pageIdx = 1, limit = 20, timeoutMs = transport.timeout ?? (transport.relayUrl ? 60000 : 15000), zyteApiKey, } = args;
8
8
  const { totalCount, stores } = await fetchOliveyoungStores({
9
9
  latitude,
10
10
  longitude,
11
11
  pageIdx,
12
12
  searchWords: keyword,
13
13
  }, {
14
- timeout: timeoutMs,
15
14
  apiKey: zyteApiKey,
16
15
  ...transport,
16
+ timeout: timeoutMs,
17
17
  });
18
18
  const limitedStores = stores.slice(0, limit);
19
19
  const result = {
@@ -46,7 +46,7 @@ export function createFindNearbyStoresTool(apiKey, transport = {}) {
46
46
  .describe('매장 검색어 (예: 강남, 명동, 신촌). 비우면 주변 매장 전체 조회'),
47
47
  pageIdx: z.number().optional().default(1).describe('매장 결과 페이지 번호 (기본값: 1)'),
48
48
  limit: z.number().optional().default(20).describe('반환할 최대 매장 수 (기본값: 20)'),
49
- timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
49
+ timeoutMs: z.number().optional().default(transport.timeout ?? (transport.relayUrl ? 60000 : 15000)).describe('개별 조회 제한 시간(ms, 기본값: 무료 릴레이 60000 / 직접 15000)'),
50
50
  },
51
51
  },
52
52
  handler: ((args) => findNearbyStores({ ...args, zyteApiKey: apiKey }, transport)),
@@ -4,7 +4,7 @@
4
4
  import * as z from 'zod';
5
5
  import { fetchOliveyoungProducts } from '../client.js';
6
6
  async function searchProducts(args, transport) {
7
- const { keyword, page = 1, size = 20, sort = '01', includeSoldOut = false, timeoutMs = 15000, zyteApiKey, } = args;
7
+ const { keyword, page = 1, size = 20, sort = '01', includeSoldOut = false, timeoutMs = transport.timeout ?? (transport.relayUrl ? 60000 : 15000), zyteApiKey, } = args;
8
8
  if (!keyword || keyword.trim().length === 0) {
9
9
  throw new Error('상품 검색어(keyword)를 입력해주세요.');
10
10
  }
@@ -15,9 +15,9 @@ async function searchProducts(args, transport) {
15
15
  sort,
16
16
  includeSoldOut,
17
17
  }, {
18
- timeout: timeoutMs,
19
18
  apiKey: zyteApiKey,
20
19
  ...transport,
20
+ timeout: timeoutMs,
21
21
  });
22
22
  return {
23
23
  content: [
@@ -50,7 +50,7 @@ export function createSearchProductsTool(apiKey, transport = {}) {
50
50
  size: z.number().optional().default(20).describe('페이지당 상품 수 (기본값: 20)'),
51
51
  sort: z.string().optional().default('01').describe('정렬 코드 (기본값: 01)'),
52
52
  includeSoldOut: z.boolean().optional().default(false).describe('품절 상품 포함 여부 (기본값: false)'),
53
- timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'),
53
+ timeoutMs: z.number().optional().default(transport.timeout ?? (transport.relayUrl ? 60000 : 15000)).describe('개별 조회 제한 시간(ms, 기본값: 무료 릴레이 60000 / 직접 15000)'),
54
54
  },
55
55
  },
56
56
  handler: ((args) => searchProducts({ ...args, zyteApiKey: apiKey }, transport)),
@@ -1,8 +1,10 @@
1
- import { diagnosticHeaders } from '../../utils/diagnostics.js';
1
+ import { diagnosticHeaders, relayConsumer } from '../../utils/diagnostics.js';
2
2
  /** 올리브영 무료 직접 요청 및 운영자가 설정한 브라우저 릴레이 전송. */
3
- import { fetchJson } from '../../utils/http.js';
3
+ import { createRelayCooldown, relayCredentialScope } from '../../utils/relayQuota.js';
4
+ import { HttpError, fetchJson } from '../../utils/http.js';
4
5
  import { toOliveyoungRelayError } from './errors.js';
5
6
  import { OLIVEYOUNG_API } from './api.js';
7
+ const cooldown = createRelayCooldown();
6
8
  /** 설정 진단과 실제 요청에 같은 릴레이 URL 규칙을 적용합니다. */
7
9
  export function isValidOliveyoungRelayUrl(value) {
8
10
  if (!value?.trim())
@@ -22,12 +24,15 @@ export function isValidOliveyoungRelayUrl(value) {
22
24
  !(relay.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(relay.hostname))));
23
25
  }
24
26
  export async function requestOliveyoung(path, body, options = {}) {
25
- const { timeout = 15000, relayUrl, relayToken } = options;
27
+ const { relayUrl, relayToken, timeout = relayUrl ? 60000 : 15000 } = options;
28
+ const deadline = Date.now() + timeout;
26
29
  const headers = {
27
30
  'Content-Type': 'application/json',
28
31
  Accept: 'application/json',
29
32
  'X-Requested-With': 'XMLHttpRequest',
30
33
  };
34
+ let scope = '';
35
+ let consumer = 'legacy';
31
36
  let url = `${OLIVEYOUNG_API.BASE_URL}${path}`;
32
37
  if (relayUrl) {
33
38
  if (!isValidOliveyoungRelayUrl(relayUrl)) {
@@ -38,6 +43,9 @@ export async function requestOliveyoung(path, body, options = {}) {
38
43
  url = `${relayUrl.replace(/\/$/, '')}/v1/oliveyoung/${path.split('/').pop()}`;
39
44
  Object.assign(headers, diagnosticHeaders());
40
45
  headers.Authorization = `Bearer ${relayToken}`;
46
+ consumer = (await relayConsumer(relayToken)) || 'legacy';
47
+ if (consumer !== 'legacy')
48
+ headers['x-relay-consumer'] = consumer;
41
49
  const { accessClientId, accessClientSecret } = options;
42
50
  if (accessClientId !== undefined || accessClientSecret !== undefined) {
43
51
  if (!accessClientId?.trim() || !accessClientSecret?.trim()) {
@@ -55,31 +63,57 @@ export async function requestOliveyoung(path, body, options = {}) {
55
63
  headers.Referer = `${OLIVEYOUNG_API.BASE_URL}/`;
56
64
  headers['Accept-Language'] = 'ko-KR,ko;q=0.9';
57
65
  }
58
- let result;
59
- try {
60
- result = await fetchJson(url, {
61
- method: 'POST',
62
- // Workers는 error 모드를 지원하지 않으므로 따라가지 않고 아래 200 검사로 거절합니다.
63
- redirect: 'manual',
64
- headers,
65
- body: JSON.stringify(body),
66
- timeout,
67
- retries: 0,
68
- expectedStatus: 200,
69
- });
66
+ if (relayUrl) {
67
+ scope = await relayCredentialScope([relayUrl.replace(/\/$/, ''), relayToken, options.accessClientId, options.accessClientSecret]);
70
68
  }
71
- catch (error) {
72
- if (relayUrl)
73
- throw toOliveyoungRelayError(error);
74
- if (error instanceof Error && error.name === 'AbortError') {
75
- throw new Error('올리브영 API 요청 시간 초과');
69
+ let retried = false;
70
+ while (true) {
71
+ let result;
72
+ const cachedQuota = relayUrl ? cooldown.get(scope, consumer) : undefined;
73
+ try {
74
+ const remaining = deadline - Date.now();
75
+ if (remaining <= 0)
76
+ throw new DOMException('Request deadline exceeded', 'AbortError');
77
+ if (cachedQuota)
78
+ throw new HttpError(429, '', '', new Headers({
79
+ 'x-relay-quota-reason': cachedQuota.quotaReason,
80
+ 'retry-after': String(cachedQuota.retryAfter),
81
+ }));
82
+ result = await fetchJson(url, {
83
+ method: 'POST',
84
+ // Workers는 error 모드를 지원하지 않으므로 따라가지 않고 아래 200 검사로 거절합니다.
85
+ redirect: 'manual',
86
+ headers,
87
+ body: JSON.stringify(body),
88
+ timeout: remaining,
89
+ retries: 0,
90
+ expectedStatus: 200,
91
+ });
76
92
  }
77
- throw new Error('올리브영 직접 요청 실패. 운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN으로 브라우저 릴레이를 설정해주세요.', { cause: error });
78
- }
79
- if (result?.status !== 'SUCCESS') {
80
- if (relayUrl)
81
- throw toOliveyoungRelayError(new SyntaxError());
82
- throw new Error(`올리브영 API 상태 오류: ${result?.status || 'UNKNOWN'}`);
93
+ catch (error) {
94
+ if (relayUrl) {
95
+ if (error instanceof HttpError && error.quota) {
96
+ if (!cachedQuota)
97
+ cooldown.set(scope, consumer, error.quota);
98
+ if (!retried && error.quota.quotaReason === 'consumer-busy' &&
99
+ deadline - Date.now() > 1000) {
100
+ retried = true;
101
+ await new Promise(resolve => setTimeout(resolve, 1000));
102
+ continue;
103
+ }
104
+ }
105
+ throw toOliveyoungRelayError(error);
106
+ }
107
+ if (error instanceof Error && error.name === 'AbortError') {
108
+ throw new Error('올리브영 API 요청 시간 초과');
109
+ }
110
+ throw new Error('올리브영 직접 요청 실패. 운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN으로 브라우저 릴레이를 설정해주세요.', { cause: error });
111
+ }
112
+ if (result?.status !== 'SUCCESS') {
113
+ if (relayUrl)
114
+ throw toOliveyoungRelayError(new SyntaxError());
115
+ throw new Error(`올리브영 API 상태 오류: ${result?.status || 'UNKNOWN'}`);
116
+ }
117
+ return result;
83
118
  }
84
- return result;
85
119
  }
@@ -1,10 +1,10 @@
1
1
  /** 비밀값과 원문 없이 요청 경계를 연결하는 진단 컨텍스트입니다. */
2
2
  import { AsyncLocalStorage } from 'node:async_hooks';
3
3
  const context = new AsyncLocalStorage();
4
- export function withDiagnostics(work) {
4
+ export function withDiagnostics(work, consumerIdentity) {
5
5
  if (context.getStore())
6
6
  return work();
7
- return context.run({ requestId: crypto.randomUUID(), sampled: Math.random() < 0.01, events: 0 }, work);
7
+ return context.run({ requestId: crypto.randomUUID(), sampled: Math.random() < 0.01, events: 0, consumerIdentity }, work);
8
8
  }
9
9
  export function diagnosticHeaders() {
10
10
  const current = context.getStore();
@@ -74,3 +74,13 @@ export function diagnosticOperation(url) {
74
74
  return `${parts[2]}.${parts[3]}`;
75
75
  return 'other';
76
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
+ }
@@ -1,9 +1,11 @@
1
+ import { parseRelayQuota } from './relayQuota.js';
1
2
  import { diagnosticEvent } from './diagnostics.js';
2
3
  export class HttpError extends Error {
4
+ quota;
3
5
  status;
4
6
  statusText;
5
7
  bodyText;
6
- constructor(status, statusText, bodyText) {
8
+ constructor(status, statusText, bodyText, headers) {
7
9
  const normalizedBody = bodyText.trim().replace(/\s+/g, ' ').slice(0, 300);
8
10
  const detail = normalizedBody.length > 0 ? ` - ${normalizedBody}` : '';
9
11
  super(`API 요청 실패: ${status} ${statusText}${detail}`);
@@ -11,6 +13,7 @@ export class HttpError extends Error {
11
13
  this.status = status;
12
14
  this.statusText = statusText;
13
15
  this.bodyText = bodyText;
16
+ this.quota = parseRelayQuota(status, headers);
14
17
  }
15
18
  }
16
19
  /** HTML 원문을 노출하지 않고 예상하지 못한 응답 형식을 구분합니다. */
@@ -136,7 +139,7 @@ async function readTextResponse(url, options) {
136
139
  export async function fetchJson(url, options = {}) {
137
140
  const { response, body } = await readTextResponse(url, options);
138
141
  if (!response.ok || (options.expectedStatus !== undefined && response.status !== options.expectedStatus)) {
139
- throw new HttpError(response.status, response.statusText, body);
142
+ throw new HttpError(response.status, response.statusText, body, response.headers);
140
143
  }
141
144
  if (/^\s*(?:<!doctype\s+html\b|<html\b)/i.test(body)) {
142
145
  throw new UnexpectedHtmlResponseError(response.status, response.headers.get('content-type') || '');
@@ -146,7 +149,7 @@ export async function fetchJson(url, options = {}) {
146
149
  export async function fetchText(url, options = {}) {
147
150
  const { response, body } = await readTextResponse(url, options);
148
151
  if (!response.ok) {
149
- throw new HttpError(response.status, response.statusText, body);
152
+ throw new HttpError(response.status, response.statusText, body, response.headers);
150
153
  }
151
154
  return body;
152
155
  }
@@ -0,0 +1,46 @@
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' && reason !== 'consumer-busy')
7
+ return undefined;
8
+ if (reason === 'consumer-busy' && retry !== '1')
9
+ return undefined;
10
+ if (!/^[1-9][0-9]{0,4}$/.test(retry) || Number(retry) > 86400)
11
+ return undefined;
12
+ return { quotaReason: reason, retryAfter: Number(retry) };
13
+ }
14
+ /** 만료 후 정리하고 고정 크기에서 새 휴지 기록을 생략합니다. */
15
+ export function createRelayCooldown(now = () => Date.now(), maxEntries = 1024) {
16
+ const entries = new Map();
17
+ const prune = () => {
18
+ for (const [key, entry] of entries)
19
+ if (entry.until <= now())
20
+ entries.delete(key);
21
+ };
22
+ return {
23
+ get(scope, consumer) {
24
+ prune();
25
+ const entry = entries.get(scope) || entries.get(`${scope}:${consumer}`);
26
+ if (!entry)
27
+ return undefined;
28
+ return { quotaReason: entry.quotaReason, retryAfter: Math.ceil((entry.until - now()) / 1000) };
29
+ },
30
+ set(scope, consumer, quota) {
31
+ prune();
32
+ const key = (quota.quotaReason === 'consumer' || quota.quotaReason === 'consumer-busy') ? `${scope}:${consumer}` : scope;
33
+ if (!entries.has(key) && entries.size >= maxEntries)
34
+ return;
35
+ const until = now() + quota.retryAfter * 1000;
36
+ if ((entries.get(key)?.until || 0) > until)
37
+ return;
38
+ entries.set(key, { quotaReason: quota.quotaReason, until });
39
+ },
40
+ };
41
+ }
42
+ /** 자격증명 원문을 저장하지 않는 릴레이/Access 구성별 키. */
43
+ export async function relayCredentialScope(values) {
44
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(values)));
45
+ return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "daiso",
3
- "version": "1.2.6",
3
+ "version": "1.2.8",
4
4
  "description": "Korean retail and cinema MCP server, CLI, and Codex Skill for Daiso, convenience stores, marts, Olive Young, and theaters",
5
5
  "main": "dist/index.js",
6
6
  "bin": {