@profplum700/etsy-v3-api-client 2.3.12 → 2.4.0

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/dist/index.cjs CHANGED
@@ -238,14 +238,18 @@ class EtsyAuthError extends Error {
238
238
  }
239
239
  }
240
240
  class EtsyRateLimitError extends Error {
241
- constructor(message, _retryAfter) {
241
+ constructor(message, _retryAfter, errorType = 'unknown') {
242
242
  super(message);
243
243
  this._retryAfter = _retryAfter;
244
244
  this.name = 'EtsyRateLimitError';
245
+ this.errorType = errorType;
245
246
  }
246
247
  get retryAfter() {
247
248
  return this._retryAfter;
248
249
  }
250
+ isRetryable() {
251
+ return this.errorType !== 'qpd_exhausted';
252
+ }
249
253
  }
250
254
 
251
255
  const isBrowser$1 = typeof window !== 'undefined' && typeof window.document !== 'undefined';
@@ -678,10 +682,18 @@ class EtsyRateLimiter {
678
682
  this.requestCount = 0;
679
683
  this.dailyReset = new Date();
680
684
  this.lastRequestTime = 0;
685
+ this.isHeaderBasedLimiting = false;
686
+ this.currentRetryCount = 0;
681
687
  this.config = {
682
688
  maxRequestsPerDay: 10000,
683
689
  maxRequestsPerSecond: 10,
684
690
  minRequestInterval: 100,
691
+ maxRetries: 3,
692
+ baseDelayMs: 1000,
693
+ maxDelayMs: 30000,
694
+ jitter: 0.1,
695
+ qpdWarningThreshold: 80,
696
+ onApproachingLimit: undefined,
685
697
  ...config
686
698
  };
687
699
  this.setNextDailyReset();
@@ -692,19 +704,124 @@ class EtsyRateLimiter {
692
704
  this.dailyReset.setUTCDate(this.dailyReset.getUTCDate() + 1);
693
705
  this.dailyReset.setUTCHours(0, 0, 0, 0);
694
706
  }
707
+ updateFromHeaders(headers) {
708
+ if (!headers) {
709
+ return;
710
+ }
711
+ const parsed = this.parseRateLimitHeaders(headers);
712
+ if (parsed.limitPerSecond !== undefined) {
713
+ this.headerLimitPerSecond = parsed.limitPerSecond;
714
+ this.isHeaderBasedLimiting = true;
715
+ }
716
+ if (parsed.remainingThisSecond !== undefined) {
717
+ this.headerRemainingThisSecond = parsed.remainingThisSecond;
718
+ }
719
+ if (parsed.limitPerDay !== undefined) {
720
+ this.headerLimitPerDay = parsed.limitPerDay;
721
+ this.isHeaderBasedLimiting = true;
722
+ }
723
+ if (parsed.remainingToday !== undefined) {
724
+ this.headerRemainingToday = parsed.remainingToday;
725
+ this.checkApproachingLimit();
726
+ }
727
+ }
728
+ parseRateLimitHeaders(headers) {
729
+ const getHeader = (name) => {
730
+ if (headers instanceof Headers) {
731
+ return headers.get(name);
732
+ }
733
+ const lowerName = name.toLowerCase();
734
+ for (const key of Object.keys(headers)) {
735
+ if (key.toLowerCase() === lowerName) {
736
+ return headers[key] ?? null;
737
+ }
738
+ }
739
+ return null;
740
+ };
741
+ const parseNumber = (value) => {
742
+ if (value === null)
743
+ return undefined;
744
+ const num = parseInt(value, 10);
745
+ return isNaN(num) ? undefined : num;
746
+ };
747
+ return {
748
+ limitPerSecond: parseNumber(getHeader('x-limit-per-second')),
749
+ remainingThisSecond: parseNumber(getHeader('x-remaining-this-second')),
750
+ limitPerDay: parseNumber(getHeader('x-limit-per-day')),
751
+ remainingToday: parseNumber(getHeader('x-remaining-today')),
752
+ retryAfter: parseNumber(getHeader('retry-after'))
753
+ };
754
+ }
755
+ checkApproachingLimit() {
756
+ if (!this.config.onApproachingLimit)
757
+ return;
758
+ const limit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
759
+ const remaining = this.headerRemainingToday ?? this.getRemainingRequests();
760
+ const used = limit - remaining;
761
+ const percentageUsed = (used / limit) * 100;
762
+ if (percentageUsed >= this.config.qpdWarningThreshold) {
763
+ this.config.onApproachingLimit(remaining, limit, percentageUsed);
764
+ }
765
+ }
766
+ async handleRateLimitResponse(headers) {
767
+ const parsed = headers ? this.parseRateLimitHeaders(headers) : {};
768
+ this.updateFromHeaders(headers);
769
+ if (this.headerRemainingToday === 0) {
770
+ throw new EtsyRateLimitError('Daily rate limit exhausted. No requests remaining until limit resets.', parsed.retryAfter, 'qpd_exhausted');
771
+ }
772
+ this.currentRetryCount++;
773
+ if (this.currentRetryCount > this.config.maxRetries) {
774
+ this.currentRetryCount = 0;
775
+ throw new EtsyRateLimitError(`Max retries (${this.config.maxRetries}) exceeded for rate limit`, parsed.retryAfter, 'qps_exhausted');
776
+ }
777
+ const delayMs = this.calculateBackoffDelay(this.currentRetryCount, parsed.retryAfter);
778
+ return { shouldRetry: true, delayMs };
779
+ }
780
+ calculateBackoffDelay(attempt, retryAfterSeconds) {
781
+ const serverSuggestedMs = retryAfterSeconds ? retryAfterSeconds * 1000 : 0;
782
+ const exponentialDelay = this.config.baseDelayMs * Math.pow(2, attempt - 1);
783
+ let delay = Math.min(exponentialDelay, this.config.maxDelayMs);
784
+ delay = Math.max(delay, serverSuggestedMs);
785
+ if (this.config.jitter > 0) {
786
+ const jitterAmount = delay * this.config.jitter;
787
+ const randomJitter = Math.random() * jitterAmount * 2 - jitterAmount;
788
+ delay += randomJitter;
789
+ }
790
+ return Math.max(0, Math.floor(delay));
791
+ }
792
+ resetRetryCount() {
793
+ this.currentRetryCount = 0;
794
+ }
795
+ setApproachingLimitCallback(callback) {
796
+ this.config.onApproachingLimit = callback;
797
+ }
798
+ setWarningThreshold(threshold) {
799
+ this.config.qpdWarningThreshold = threshold;
800
+ }
801
+ getEffectiveMinInterval() {
802
+ if (this.headerLimitPerSecond !== undefined && this.headerLimitPerSecond > 0) {
803
+ return Math.ceil(1000 / this.headerLimitPerSecond);
804
+ }
805
+ return this.config.minRequestInterval;
806
+ }
695
807
  async waitForRateLimit() {
696
808
  const now = Date.now();
697
809
  if (now >= this.dailyReset.getTime()) {
698
810
  this.requestCount = 0;
699
811
  this.setNextDailyReset();
700
812
  }
701
- if (this.requestCount >= this.config.maxRequestsPerDay) {
813
+ const effectiveDailyLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
814
+ const effectiveRemaining = this.headerRemainingToday ??
815
+ (effectiveDailyLimit - this.requestCount);
816
+ if (effectiveRemaining <= 0) {
702
817
  const timeUntilReset = this.dailyReset.getTime() - now;
703
- throw new EtsyRateLimitError(`Daily rate limit of ${this.config.maxRequestsPerDay} requests exceeded. Reset in ${Math.ceil(timeUntilReset / 1000 / 60)} minutes.`, timeUntilReset);
818
+ throw new EtsyRateLimitError(`Daily rate limit exhausted (${effectiveDailyLimit} requests). ` +
819
+ `Reset in approximately ${Math.ceil(timeUntilReset / 1000 / 60)} minutes.`, Math.ceil(timeUntilReset / 1000), 'qpd_exhausted');
704
820
  }
821
+ const minInterval = this.getEffectiveMinInterval();
705
822
  const timeSinceLastRequest = now - this.lastRequestTime;
706
- if (timeSinceLastRequest < this.config.minRequestInterval) {
707
- const waitTime = this.config.minRequestInterval - timeSinceLastRequest;
823
+ if (timeSinceLastRequest < minInterval) {
824
+ const waitTime = minInterval - timeSinceLastRequest;
708
825
  await new Promise(resolve => setTimeout(resolve, waitTime));
709
826
  }
710
827
  this.requestCount++;
@@ -716,19 +833,32 @@ class EtsyRateLimiter {
716
833
  this.requestCount = 0;
717
834
  this.setNextDailyReset();
718
835
  }
836
+ const effectiveRemaining = this.headerRemainingToday ??
837
+ Math.max(0, this.config.maxRequestsPerDay - this.requestCount);
719
838
  return {
720
- remainingRequests: Math.max(0, this.config.maxRequestsPerDay - this.requestCount),
839
+ remainingRequests: effectiveRemaining,
721
840
  resetTime: this.dailyReset,
722
- canMakeRequest: this.requestCount < this.config.maxRequestsPerDay &&
723
- (now - this.lastRequestTime) >= this.config.minRequestInterval
841
+ canMakeRequest: effectiveRemaining > 0 &&
842
+ (now - this.lastRequestTime) >= this.getEffectiveMinInterval(),
843
+ isFromHeaders: this.isHeaderBasedLimiting,
844
+ limitPerSecond: this.headerLimitPerSecond,
845
+ remainingThisSecond: this.headerRemainingThisSecond,
846
+ limitPerDay: this.headerLimitPerDay
724
847
  };
725
848
  }
726
849
  getRemainingRequests() {
727
- return Math.max(0, this.config.maxRequestsPerDay - this.requestCount);
850
+ return this.headerRemainingToday ??
851
+ Math.max(0, this.config.maxRequestsPerDay - this.requestCount);
728
852
  }
729
853
  reset() {
730
854
  this.requestCount = 0;
731
855
  this.lastRequestTime = 0;
856
+ this.currentRetryCount = 0;
857
+ this.headerLimitPerSecond = undefined;
858
+ this.headerRemainingThisSecond = undefined;
859
+ this.headerLimitPerDay = undefined;
860
+ this.headerRemainingToday = undefined;
861
+ this.isHeaderBasedLimiting = false;
732
862
  this.setNextDailyReset();
733
863
  }
734
864
  canMakeRequest() {
@@ -737,18 +867,21 @@ class EtsyRateLimiter {
737
867
  this.requestCount = 0;
738
868
  this.setNextDailyReset();
739
869
  }
740
- if (this.requestCount >= this.config.maxRequestsPerDay) {
870
+ const effectiveRemaining = this.headerRemainingToday ??
871
+ (this.config.maxRequestsPerDay - this.requestCount);
872
+ if (effectiveRemaining <= 0) {
741
873
  return false;
742
874
  }
743
- return (now - this.lastRequestTime) >= this.config.minRequestInterval;
875
+ return (now - this.lastRequestTime) >= this.getEffectiveMinInterval();
744
876
  }
745
877
  getTimeUntilNextRequest() {
746
878
  const now = Date.now();
747
879
  const timeSinceLastRequest = now - this.lastRequestTime;
748
- if (timeSinceLastRequest >= this.config.minRequestInterval) {
880
+ const minInterval = this.getEffectiveMinInterval();
881
+ if (timeSinceLastRequest >= minInterval) {
749
882
  return 0;
750
883
  }
751
- return this.config.minRequestInterval - timeSinceLastRequest;
884
+ return minInterval - timeSinceLastRequest;
752
885
  }
753
886
  getConfig() {
754
887
  return { ...this.config };
@@ -1207,11 +1340,18 @@ class EtsyClient {
1207
1340
  this.baseUrl = config.baseUrl || 'https://api.etsy.com/v3/application';
1208
1341
  this.logger = new DefaultLogger();
1209
1342
  this.keystring = config.keystring;
1343
+ this.sharedSecret = config.sharedSecret;
1210
1344
  if (config.rateLimiting?.enabled !== false) {
1211
1345
  this.rateLimiter = new EtsyRateLimiter({
1212
1346
  maxRequestsPerDay: config.rateLimiting?.maxRequestsPerDay || 10000,
1213
1347
  maxRequestsPerSecond: config.rateLimiting?.maxRequestsPerSecond || 10,
1214
- minRequestInterval: config.rateLimiting?.minRequestInterval ?? 100
1348
+ minRequestInterval: config.rateLimiting?.minRequestInterval ?? 100,
1349
+ maxRetries: config.rateLimiting?.maxRetries,
1350
+ baseDelayMs: config.rateLimiting?.baseDelayMs,
1351
+ maxDelayMs: config.rateLimiting?.maxDelayMs,
1352
+ jitter: config.rateLimiting?.jitter,
1353
+ qpdWarningThreshold: config.rateLimiting?.qpdWarningThreshold,
1354
+ onApproachingLimit: config.rateLimiting?.onApproachingLimit
1215
1355
  });
1216
1356
  }
1217
1357
  else {
@@ -1248,36 +1388,57 @@ class EtsyClient {
1248
1388
  'Accept': 'application/json',
1249
1389
  ...requestOptions.headers
1250
1390
  };
1251
- try {
1252
- const response = await this.fetch(url, {
1253
- ...requestOptions,
1254
- headers
1255
- });
1256
- if (!response.ok) {
1257
- const errorText = await response.text();
1258
- throw new EtsyApiError(`Etsy API error: ${response.status} ${response.statusText}`, response.status, errorText);
1259
- }
1260
- if (response.status === 204) {
1261
- return undefined;
1262
- }
1263
- const contentLength = response.headers?.get?.('content-length');
1264
- if (contentLength === '0') {
1265
- return undefined;
1266
- }
1267
- const data = await response.json();
1268
- if (useCache && this.cache && requestOptions.method === 'GET') {
1269
- await this.cache.set(cacheKey, JSON.stringify(data), this.cacheTtl);
1391
+ return this.executeWithRetry(url, { ...requestOptions, headers }, cacheKey, useCache);
1392
+ }
1393
+ async executeWithRetry(url, options, cacheKey, useCache) {
1394
+ while (true) {
1395
+ try {
1396
+ const response = await this.fetch(url, options);
1397
+ if (response.status === 429) {
1398
+ const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers);
1399
+ if (shouldRetry) {
1400
+ this.logger.warn(`Rate limited. Retrying in ${delayMs}ms...`);
1401
+ await this.sleep(delayMs);
1402
+ continue;
1403
+ }
1404
+ }
1405
+ if (!response.ok) {
1406
+ const errorText = await response.text();
1407
+ throw new EtsyApiError(`Etsy API error: ${response.status} ${response.statusText}`, response.status, errorText);
1408
+ }
1409
+ this.rateLimiter.updateFromHeaders(response.headers);
1410
+ this.rateLimiter.resetRetryCount();
1411
+ if (response.status === 204) {
1412
+ return undefined;
1413
+ }
1414
+ const contentLength = response.headers?.get?.('content-length');
1415
+ if (contentLength === '0') {
1416
+ return undefined;
1417
+ }
1418
+ const data = await response.json();
1419
+ if (useCache && this.cache && options.method === 'GET') {
1420
+ await this.cache.set(cacheKey, JSON.stringify(data), this.cacheTtl);
1421
+ }
1422
+ return data;
1270
1423
  }
1271
- return data;
1272
- }
1273
- catch (error) {
1274
- if (error instanceof EtsyApiError || error instanceof EtsyAuthError) {
1275
- throw error;
1424
+ catch (error) {
1425
+ if (error instanceof EtsyRateLimitError) {
1426
+ throw error;
1427
+ }
1428
+ if (error instanceof EtsyApiError || error instanceof EtsyAuthError) {
1429
+ throw error;
1430
+ }
1431
+ throw new EtsyApiError(`Request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 0, error);
1276
1432
  }
1277
- throw new EtsyApiError(`Request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 0, error);
1278
1433
  }
1279
1434
  }
1435
+ sleep(ms) {
1436
+ return new Promise(resolve => setTimeout(resolve, ms));
1437
+ }
1280
1438
  getApiKey() {
1439
+ if (this.sharedSecret) {
1440
+ return `${this.keystring}:${this.sharedSecret}`;
1441
+ }
1281
1442
  return this.keystring;
1282
1443
  }
1283
1444
  async getUser() {
@@ -1652,6 +1813,12 @@ class EtsyClient {
1652
1813
  getRateLimitStatus() {
1653
1814
  return this.rateLimiter.getRateLimitStatus();
1654
1815
  }
1816
+ onApproachingRateLimit(callback, threshold) {
1817
+ if (threshold !== undefined) {
1818
+ this.rateLimiter.setWarningThreshold(threshold);
1819
+ }
1820
+ this.rateLimiter.setApproachingLimitCallback(callback);
1821
+ }
1655
1822
  async clearCache() {
1656
1823
  if (this.cache) {
1657
1824
  await this.cache.clear();
@@ -2049,7 +2216,12 @@ class GlobalRequestQueue {
2049
2216
  return `${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
2050
2217
  }
2051
2218
  delay(ms) {
2052
- return new Promise(resolve => setTimeout(resolve, ms));
2219
+ return new Promise(resolve => {
2220
+ const timer = setTimeout(resolve, ms);
2221
+ if (typeof timer.unref === 'function') {
2222
+ timer.unref();
2223
+ }
2224
+ });
2053
2225
  }
2054
2226
  }
2055
2227
  GlobalRequestQueue.instance = null;