@profplum700/etsy-v3-api-client 3.0.1 → 4.1.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
@@ -343,9 +343,12 @@ class MemoryTokenStorage {
343
343
  class TokenManager {
344
344
  constructor(config, storage, rotationConfig) {
345
345
  this.currentTokens = null;
346
+ this.tokenMutationGeneration = 0;
346
347
  this.keystring = config.keystring;
347
- this.refreshCallback = config.refreshSave;
348
+ this.refreshCallback = config.refreshSaveAsync ?? config.refreshSave;
349
+ this.refreshClearRequired = config.refreshSaveAsync !== undefined;
348
350
  this.storage = storage;
351
+ this.clearCallback = config.refreshClearAsync;
349
352
  this.rotationConfig = rotationConfig;
350
353
  this.currentTokens = {
351
354
  access_token: config.accessToken,
@@ -382,21 +385,82 @@ class TokenManager {
382
385
  if (!this.currentTokens) {
383
386
  throw new EtsyAuthError('No tokens available to refresh', 'NO_REFRESH_TOKEN');
384
387
  }
385
- this.refreshPromise = this.performTokenRefresh();
386
- try {
387
- const newTokens = await this.refreshPromise;
388
- this.currentTokens = newTokens;
389
- if (this.storage) {
390
- await this.storage.save(newTokens);
388
+ const generation = this.tokenMutationGeneration;
389
+ const refreshPromise = (this.pendingTokens
390
+ ? this.retryPendingTokenPersistence()
391
+ : this.performTokenRefresh().then(async (newTokens) => {
392
+ this.throwIfSuperseded(generation);
393
+ this.pendingTokens = newTokens;
394
+ return this.persistRefreshedTokens(newTokens, generation);
395
+ })).finally(() => {
396
+ this.refreshPromise = undefined;
397
+ });
398
+ this.refreshPromise = refreshPromise;
399
+ return refreshPromise;
400
+ }
401
+ throwIfSuperseded(generation) {
402
+ if (generation !== this.tokenMutationGeneration) {
403
+ throw new EtsyAuthError('Token refresh was superseded by a token update or clear', 'TOKEN_REFRESH_SUPERSEDED');
404
+ }
405
+ }
406
+ async repairPersistenceToCurrentTokens() {
407
+ while (true) {
408
+ const generation = this.tokenMutationGeneration;
409
+ const current = this.currentTokens ? { ...this.currentTokens } : null;
410
+ try {
411
+ if (current) {
412
+ if (this.storage)
413
+ await this.storage.save(current);
414
+ if (this.refreshCallback) {
415
+ await this.refreshCallback(current.access_token, current.refresh_token, current.expires_at);
416
+ }
417
+ }
418
+ else {
419
+ if (this.storage)
420
+ await this.storage.clear();
421
+ if (this.clearCallback)
422
+ await this.clearCallback();
423
+ else if (this.refreshClearRequired) {
424
+ throw new EtsyAuthError('A refresh persistence callback is configured without refreshClearAsync; durable tokens could not be cleared safely', 'TOKEN_CLEAR_CALLBACK_REQUIRED');
425
+ }
426
+ }
391
427
  }
392
- if (this.refreshCallback) {
393
- this.refreshCallback(newTokens.access_token, newTokens.refresh_token, newTokens.expires_at);
428
+ catch (error) {
429
+ if (generation !== this.tokenMutationGeneration)
430
+ continue;
431
+ throw error;
394
432
  }
395
- return newTokens;
433
+ if (generation === this.tokenMutationGeneration)
434
+ return;
396
435
  }
397
- finally {
398
- this.refreshPromise = undefined;
436
+ }
437
+ async persistRefreshedTokens(newTokens, generation) {
438
+ this.throwIfSuperseded(generation);
439
+ if (this.storage) {
440
+ await this.storage.save(newTokens);
441
+ if (generation !== this.tokenMutationGeneration) {
442
+ await this.repairPersistenceToCurrentTokens();
443
+ this.throwIfSuperseded(generation);
444
+ }
445
+ }
446
+ this.throwIfSuperseded(generation);
447
+ if (this.refreshCallback) {
448
+ await this.refreshCallback(newTokens.access_token, newTokens.refresh_token, newTokens.expires_at);
449
+ if (generation !== this.tokenMutationGeneration) {
450
+ await this.repairPersistenceToCurrentTokens();
451
+ this.throwIfSuperseded(generation);
452
+ }
453
+ }
454
+ this.throwIfSuperseded(generation);
455
+ this.currentTokens = newTokens;
456
+ this.pendingTokens = undefined;
457
+ return newTokens;
458
+ }
459
+ async retryPendingTokenPersistence() {
460
+ if (!this.pendingTokens) {
461
+ throw new EtsyAuthError('No rotated tokens are pending persistence', 'NO_PENDING_TOKENS');
399
462
  }
463
+ return this.persistRefreshedTokens(this.pendingTokens, this.tokenMutationGeneration);
400
464
  }
401
465
  async performTokenRefresh() {
402
466
  if (!this.currentTokens) {
@@ -410,6 +474,7 @@ class TokenManager {
410
474
  try {
411
475
  const response = await this.fetch('https://api.etsy.com/v3/public/oauth/token', {
412
476
  method: 'POST',
477
+ redirect: 'error',
413
478
  headers: {
414
479
  'Content-Type': 'application/x-www-form-urlencoded',
415
480
  'Accept': 'application/json'
@@ -439,7 +504,9 @@ class TokenManager {
439
504
  return this.currentTokens ? { ...this.currentTokens } : null;
440
505
  }
441
506
  updateTokens(tokens) {
507
+ this.tokenMutationGeneration += 1;
442
508
  this.currentTokens = { ...tokens };
509
+ this.pendingTokens = undefined;
443
510
  }
444
511
  isTokenExpired() {
445
512
  if (!this.currentTokens) {
@@ -459,10 +526,19 @@ class TokenManager {
459
526
  return now.getTime() >= (expiresAt.getTime() - bufferTime);
460
527
  }
461
528
  async clearTokens() {
529
+ this.tokenMutationGeneration += 1;
462
530
  this.currentTokens = null;
531
+ this.pendingTokens = undefined;
532
+ if (this.refreshPromise)
533
+ await this.refreshPromise.catch(() => undefined);
463
534
  if (this.storage) {
464
535
  await this.storage.clear();
465
536
  }
537
+ if (this.clearCallback)
538
+ await this.clearCallback();
539
+ else if (this.refreshClearRequired) {
540
+ throw new EtsyAuthError('A refresh persistence callback is configured without refreshClearAsync; durable tokens could not be cleared safely', 'TOKEN_CLEAR_CALLBACK_REQUIRED');
541
+ }
466
542
  }
467
543
  getTimeUntilExpiration() {
468
544
  if (!this.currentTokens) {
@@ -702,13 +778,24 @@ const ETSY_RATE_LIMITS = {
702
778
  MAX_REQUESTS_PER_SECOND: 5,
703
779
  MIN_REQUEST_INTERVAL: 200,
704
780
  };
781
+ const QPD_WINDOW_MS$1 = 24 * 60 * 60 * 1000;
705
782
  class EtsyRateLimiter {
706
783
  constructor(config) {
707
- this.requestCount = 0;
708
- this.dailyReset = new Date();
784
+ this.requestTimestamps = [];
785
+ this.uncertainRequestTimestamps = [];
709
786
  this.lastRequestTime = 0;
710
787
  this.isHeaderBasedLimiting = false;
788
+ this.headerQuotaAvailableAt = 0;
789
+ this.retryNotBefore = 0;
790
+ this.quotaProbeBackoffMs = 60000;
791
+ this.nextReservationId = 0;
792
+ this.latestHeaderReservationId = 0;
793
+ this.inFlightReservations = new Map();
794
+ this.quotaProbeInFlight = false;
795
+ this.stateWaiters = new Set();
711
796
  this.currentRetryCount = 0;
797
+ this.reservationQueue = Promise.resolve();
798
+ const overrides = Object.fromEntries(Object.entries(config ?? {}).filter(([, value]) => value !== undefined));
712
799
  this.config = {
713
800
  maxRequestsPerDay: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
714
801
  maxRequestsPerSecond: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
@@ -719,21 +806,155 @@ class EtsyRateLimiter {
719
806
  jitter: 0.1,
720
807
  qpdWarningThreshold: 80,
721
808
  onApproachingLimit: undefined,
722
- ...config
809
+ ...overrides
723
810
  };
724
- this.setNextDailyReset();
725
811
  }
726
- setNextDailyReset() {
727
- const now = new Date();
728
- this.dailyReset = new Date(now);
729
- this.dailyReset.setUTCDate(this.dailyReset.getUTCDate() + 1);
730
- this.dailyReset.setUTCHours(0, 0, 0, 0);
812
+ pruneRequestTimestamps(now) {
813
+ const oldestAllowed = now - QPD_WINDOW_MS$1;
814
+ while (this.requestTimestamps[0] !== undefined && this.requestTimestamps[0] <= oldestAllowed) {
815
+ this.requestTimestamps.shift();
816
+ }
817
+ while (this.uncertainRequestTimestamps[0] !== undefined && this.uncertainRequestTimestamps[0] <= oldestAllowed) {
818
+ this.uncertainRequestTimestamps.shift();
819
+ }
820
+ }
821
+ getLocalResetTime(now) {
822
+ this.pruneRequestTimestamps(now);
823
+ const oldestRequest = this.requestTimestamps[0] ?? now;
824
+ return new Date(oldestRequest + QPD_WINDOW_MS$1);
825
+ }
826
+ getLocalRemainingRequests(now) {
827
+ this.pruneRequestTimestamps(now);
828
+ const localLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
829
+ return Math.max(0, localLimit - this.requestTimestamps.length);
830
+ }
831
+ getEffectiveRemainingRequests(now) {
832
+ this.pruneRequestTimestamps(now);
833
+ if (this.headerRemainingToday !== undefined) {
834
+ return Math.max(0, this.headerRemainingToday - this.inFlightReservations.size - this.uncertainRequestTimestamps.length);
835
+ }
836
+ return this.getLocalRemainingRequests(now);
837
+ }
838
+ isQuotaProbeReady(now) {
839
+ const exhausted = this.headerRemainingToday === 0;
840
+ const uncertainBudgetExhausted = this.inFlightReservations.size === 0 &&
841
+ this.uncertainRequestTimestamps.length > 0 &&
842
+ this.getEffectiveRemainingRequests(now) === 0;
843
+ return (exhausted || uncertainBudgetExhausted) &&
844
+ !this.quotaProbeInFlight &&
845
+ this.inFlightReservations.size === 0 &&
846
+ this.headerQuotaAvailableAt <= now;
847
+ }
848
+ refreshExpiredHeaderQuota(now) {
849
+ if (this.headerRemainingToday === 0 && this.headerQuotaAvailableAt > 0 && now >= this.headerQuotaAvailableAt) {
850
+ this.headerQuotaAvailableAt = 0;
851
+ }
852
+ }
853
+ notifyStateWaiters() {
854
+ for (const resolve of this.stateWaiters)
855
+ resolve();
856
+ this.stateWaiters.clear();
857
+ }
858
+ scheduleQuotaProbe() {
859
+ this.headerQuotaAvailableAt = Date.now() + this.quotaProbeBackoffMs;
860
+ this.quotaProbeBackoffMs = Math.min(this.quotaProbeBackoffMs * 2, 60 * 60 * 1000);
861
+ }
862
+ waitForStateChange() {
863
+ return new Promise((resolve) => this.stateWaiters.add(resolve));
864
+ }
865
+ finishReservation(reservationId, receivedQuotaHeader) {
866
+ const wasQuotaProbe = this.inFlightReservations.get(reservationId);
867
+ if (wasQuotaProbe === undefined)
868
+ return;
869
+ this.inFlightReservations.delete(reservationId);
870
+ if (wasQuotaProbe.isQuotaProbe) {
871
+ this.quotaProbeInFlight = false;
872
+ if (this.headerRemainingToday === 0 && !receivedQuotaHeader) {
873
+ this.scheduleQuotaProbe();
874
+ }
875
+ }
876
+ this.notifyStateWaiters();
731
877
  }
732
- updateFromHeaders(headers) {
878
+ async acquireRequestSlot() {
879
+ const previousReservation = this.reservationQueue;
880
+ let releaseReservation = () => undefined;
881
+ this.reservationQueue = new Promise((resolve) => {
882
+ releaseReservation = resolve;
883
+ });
884
+ await previousReservation;
885
+ try {
886
+ while (true) {
887
+ const now = Date.now();
888
+ this.pruneRequestTimestamps(now);
889
+ this.refreshExpiredHeaderQuota(now);
890
+ let isQuotaProbe = false;
891
+ if (this.headerRemainingToday === 0 || this.isQuotaProbeReady(now)) {
892
+ if (this.quotaProbeInFlight || this.inFlightReservations.size > 0) {
893
+ await this.waitForStateChange();
894
+ continue;
895
+ }
896
+ const probeWait = this.headerQuotaAvailableAt - now;
897
+ if (probeWait > 0) {
898
+ await new Promise(resolve => setTimeout(resolve, probeWait));
899
+ continue;
900
+ }
901
+ isQuotaProbe = true;
902
+ }
903
+ else {
904
+ const effectiveRemaining = this.getEffectiveRemainingRequests(now);
905
+ if (effectiveRemaining <= 0) {
906
+ if (this.inFlightReservations.size > 0 && this.headerRemainingToday !== undefined) {
907
+ await this.waitForStateChange();
908
+ continue;
909
+ }
910
+ const effectiveDailyLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
911
+ const localResetTime = this.getLocalResetTime(now);
912
+ const timeUntilReset = Math.max(0, localResetTime.getTime() - now);
913
+ throw new EtsyRateLimitError(`Daily rate limit exhausted (${effectiveDailyLimit} requests in the local rolling 24-hour window). ` +
914
+ `The oldest local request expires in approximately ${Math.ceil(timeUntilReset / 1000 / 60)} minutes.`, Math.ceil(timeUntilReset / 1000), 'qpd_exhausted');
915
+ }
916
+ }
917
+ const intervalWait = Math.max(0, this.getEffectiveMinInterval() - (now - this.lastRequestTime));
918
+ const cooldownWait = Math.max(0, this.retryNotBefore - now);
919
+ const waitTime = Math.max(intervalWait, cooldownWait);
920
+ if (waitTime > 0) {
921
+ await new Promise(resolve => setTimeout(resolve, waitTime));
922
+ continue;
923
+ }
924
+ const requestStartedAt = Date.now();
925
+ const reservationId = ++this.nextReservationId;
926
+ this.requestTimestamps.push(requestStartedAt);
927
+ this.lastRequestTime = requestStartedAt;
928
+ this.inFlightReservations.set(reservationId, { isQuotaProbe, startedAt: requestStartedAt });
929
+ if (isQuotaProbe)
930
+ this.quotaProbeInFlight = true;
931
+ return reservationId;
932
+ }
933
+ }
934
+ finally {
935
+ releaseReservation();
936
+ }
937
+ }
938
+ releaseRequestSlot(reservationId) {
939
+ const reservation = this.inFlightReservations.get(reservationId);
940
+ if (!reservation)
941
+ return;
942
+ this.uncertainRequestTimestamps.push(reservation.startedAt);
943
+ this.finishReservation(reservationId, false);
944
+ }
945
+ updateFromHeaders(headers, reservationId) {
946
+ const matchedReservationId = reservationId;
947
+ const isQuotaProbe = matchedReservationId !== undefined &&
948
+ this.inFlightReservations.get(matchedReservationId)?.isQuotaProbe === true;
733
949
  if (!headers) {
950
+ if (matchedReservationId !== undefined)
951
+ this.finishReservation(matchedReservationId, false);
734
952
  return;
735
953
  }
736
954
  const parsed = this.parseRateLimitHeaders(headers);
955
+ if (matchedReservationId !== undefined) {
956
+ this.finishReservation(matchedReservationId, parsed.remainingToday !== undefined);
957
+ }
737
958
  if (parsed.limitPerSecond !== undefined) {
738
959
  this.headerLimitPerSecond = parsed.limitPerSecond;
739
960
  this.isHeaderBasedLimiting = true;
@@ -746,9 +967,39 @@ class EtsyRateLimiter {
746
967
  this.isHeaderBasedLimiting = true;
747
968
  }
748
969
  if (parsed.remainingToday !== undefined) {
749
- this.headerRemainingToday = parsed.remainingToday;
970
+ const observationId = matchedReservationId ?? this.nextReservationId;
971
+ if (isQuotaProbe && parsed.remainingToday > 0) {
972
+ this.uncertainRequestTimestamps = [];
973
+ }
974
+ const exhaustedByEarlierObservation = this.headerRemainingToday === 0 &&
975
+ parsed.remainingToday > 0 && matchedReservationId !== undefined && !isQuotaProbe;
976
+ const exhaustedByUncorrelatedObservation = this.headerRemainingToday === 0 &&
977
+ parsed.remainingToday > 0 && matchedReservationId === undefined;
978
+ if (exhaustedByEarlierObservation || exhaustedByUncorrelatedObservation) ;
979
+ else if (isQuotaProbe || this.headerRemainingToday === undefined) {
980
+ this.headerRemainingToday = parsed.remainingToday;
981
+ this.latestHeaderReservationId = Math.max(this.latestHeaderReservationId, observationId);
982
+ }
983
+ else if (parsed.remainingToday < this.headerRemainingToday) {
984
+ this.headerRemainingToday = parsed.remainingToday;
985
+ this.latestHeaderReservationId = Math.max(this.latestHeaderReservationId, observationId);
986
+ }
987
+ if (this.headerRemainingToday !== undefined && this.headerRemainingToday > 0) {
988
+ this.headerQuotaAvailableAt = 0;
989
+ this.quotaProbeBackoffMs = 60000;
990
+ }
991
+ else if (this.headerQuotaAvailableAt === 0) {
992
+ this.scheduleQuotaProbe();
993
+ }
750
994
  this.checkApproachingLimit();
751
995
  }
996
+ else if (matchedReservationId !== undefined && this.headerRemainingToday !== undefined) {
997
+ this.headerRemainingToday = Math.max(0, this.headerRemainingToday - 1);
998
+ if (this.headerRemainingToday === 0 && this.headerQuotaAvailableAt === 0) {
999
+ this.scheduleQuotaProbe();
1000
+ }
1001
+ }
1002
+ this.notifyStateWaiters();
752
1003
  }
753
1004
  parseRateLimitHeaders(headers) {
754
1005
  const getHeader = (name) => {
@@ -788,18 +1039,27 @@ class EtsyRateLimiter {
788
1039
  this.config.onApproachingLimit(remaining, limit, percentageUsed);
789
1040
  }
790
1041
  }
791
- async handleRateLimitResponse(headers) {
1042
+ async handleRateLimitResponse(headers, reservationId, retryAttempt) {
792
1043
  const parsed = headers ? this.parseRateLimitHeaders(headers) : {};
793
- this.updateFromHeaders(headers);
794
- if (this.headerRemainingToday === 0) {
795
- throw new EtsyRateLimitError('Daily rate limit exhausted. No requests remaining until limit resets.', parsed.retryAfter, 'qpd_exhausted');
796
- }
797
- this.currentRetryCount++;
798
- if (this.currentRetryCount > this.config.maxRetries) {
1044
+ this.updateFromHeaders(headers, reservationId);
1045
+ if (parsed.remainingToday === 0) {
1046
+ if (parsed.retryAfter !== undefined) {
1047
+ this.headerQuotaAvailableAt = Date.now() + parsed.retryAfter * 1000;
1048
+ this.retryNotBefore = Math.max(this.retryNotBefore, this.headerQuotaAvailableAt);
1049
+ }
799
1050
  this.currentRetryCount = 0;
1051
+ throw new EtsyRateLimitError('Daily rate limit exhausted according to Etsy response headers.', parsed.retryAfter, 'qpd_exhausted');
1052
+ }
1053
+ const attempt = retryAttempt ?? this.currentRetryCount + 1;
1054
+ if (retryAttempt === undefined)
1055
+ this.currentRetryCount = attempt;
1056
+ const delayMs = this.calculateBackoffDelay(attempt, parsed.retryAfter);
1057
+ this.retryNotBefore = Math.max(this.retryNotBefore, Date.now() + delayMs);
1058
+ if (attempt > this.config.maxRetries) {
1059
+ if (retryAttempt === undefined)
1060
+ this.currentRetryCount = 0;
800
1061
  throw new EtsyRateLimitError(`Max retries (${this.config.maxRetries}) exceeded for rate limit`, parsed.retryAfter, 'qps_exhausted');
801
1062
  }
802
- const delayMs = this.calculateBackoffDelay(this.currentRetryCount, parsed.retryAfter);
803
1063
  return { shouldRetry: true, delayMs };
804
1064
  }
805
1065
  calculateBackoffDelay(attempt, retryAfterSeconds) {
@@ -812,7 +1072,7 @@ class EtsyRateLimiter {
812
1072
  const randomJitter = Math.random() * jitterAmount * 2 - jitterAmount;
813
1073
  delay += randomJitter;
814
1074
  }
815
- return Math.max(0, Math.floor(delay));
1075
+ return Math.max(serverSuggestedMs, 0, Math.floor(delay));
816
1076
  }
817
1077
  resetRetryCount() {
818
1078
  this.currentRetryCount = 0;
@@ -830,41 +1090,26 @@ class EtsyRateLimiter {
830
1090
  return this.config.minRequestInterval;
831
1091
  }
832
1092
  async waitForRateLimit() {
833
- const now = Date.now();
834
- if (now >= this.dailyReset.getTime()) {
835
- this.requestCount = 0;
836
- this.setNextDailyReset();
837
- }
838
- const effectiveDailyLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
839
- const effectiveRemaining = this.headerRemainingToday ??
840
- (effectiveDailyLimit - this.requestCount);
841
- if (effectiveRemaining <= 0) {
842
- const timeUntilReset = this.dailyReset.getTime() - now;
843
- throw new EtsyRateLimitError(`Daily rate limit exhausted (${effectiveDailyLimit} requests). ` +
844
- `Reset in approximately ${Math.ceil(timeUntilReset / 1000 / 60)} minutes.`, Math.ceil(timeUntilReset / 1000), 'qpd_exhausted');
845
- }
846
- const minInterval = this.getEffectiveMinInterval();
847
- const timeSinceLastRequest = now - this.lastRequestTime;
848
- if (timeSinceLastRequest < minInterval) {
849
- const waitTime = minInterval - timeSinceLastRequest;
850
- await new Promise(resolve => setTimeout(resolve, waitTime));
851
- }
852
- this.requestCount++;
853
- this.lastRequestTime = Date.now();
1093
+ const reservationId = await this.acquireRequestSlot();
1094
+ this.finishReservation(reservationId, false);
1095
+ }
1096
+ async waitForRateLimitWithReservation() {
1097
+ return this.acquireRequestSlot();
854
1098
  }
855
1099
  getRateLimitStatus() {
856
1100
  const now = Date.now();
857
- if (now >= this.dailyReset.getTime()) {
858
- this.requestCount = 0;
859
- this.setNextDailyReset();
860
- }
861
- const effectiveRemaining = this.headerRemainingToday ??
862
- Math.max(0, this.config.maxRequestsPerDay - this.requestCount);
1101
+ this.pruneRequestTimestamps(now);
1102
+ this.refreshExpiredHeaderQuota(now);
1103
+ const effectiveRemaining = this.getEffectiveRemainingRequests(now);
1104
+ const qpdProbeReady = this.isQuotaProbeReady(now);
863
1105
  return {
864
1106
  remainingRequests: effectiveRemaining,
865
- resetTime: this.dailyReset,
866
- canMakeRequest: effectiveRemaining > 0 &&
867
- (now - this.lastRequestTime) >= this.getEffectiveMinInterval(),
1107
+ resetTime: this.headerRemainingToday === 0
1108
+ ? new Date(this.headerQuotaAvailableAt || now)
1109
+ : this.getLocalResetTime(now),
1110
+ canMakeRequest: (effectiveRemaining > 0 || qpdProbeReady) &&
1111
+ (now - this.lastRequestTime) >= this.getEffectiveMinInterval() &&
1112
+ now >= this.retryNotBefore,
868
1113
  isFromHeaders: this.isHeaderBasedLimiting,
869
1114
  limitPerSecond: this.headerLimitPerSecond,
870
1115
  remainingThisSecond: this.headerRemainingThisSecond,
@@ -872,41 +1117,47 @@ class EtsyRateLimiter {
872
1117
  };
873
1118
  }
874
1119
  getRemainingRequests() {
875
- return this.headerRemainingToday ??
876
- Math.max(0, this.config.maxRequestsPerDay - this.requestCount);
1120
+ const now = Date.now();
1121
+ this.pruneRequestTimestamps(now);
1122
+ this.refreshExpiredHeaderQuota(now);
1123
+ return this.getEffectiveRemainingRequests(now);
877
1124
  }
878
1125
  reset() {
879
- this.requestCount = 0;
1126
+ this.requestTimestamps = [];
880
1127
  this.lastRequestTime = 0;
881
1128
  this.currentRetryCount = 0;
1129
+ this.quotaProbeBackoffMs = 60000;
1130
+ this.headerQuotaAvailableAt = 0;
1131
+ this.retryNotBefore = 0;
882
1132
  this.headerLimitPerSecond = undefined;
883
1133
  this.headerRemainingThisSecond = undefined;
884
1134
  this.headerLimitPerDay = undefined;
885
1135
  this.headerRemainingToday = undefined;
886
1136
  this.isHeaderBasedLimiting = false;
887
- this.setNextDailyReset();
1137
+ this.nextReservationId = 0;
1138
+ this.latestHeaderReservationId = 0;
1139
+ this.inFlightReservations.clear();
1140
+ this.uncertainRequestTimestamps = [];
1141
+ this.quotaProbeInFlight = false;
1142
+ this.notifyStateWaiters();
888
1143
  }
889
1144
  canMakeRequest() {
890
1145
  const now = Date.now();
891
- if (now >= this.dailyReset.getTime()) {
892
- this.requestCount = 0;
893
- this.setNextDailyReset();
894
- }
895
- const effectiveRemaining = this.headerRemainingToday ??
896
- (this.config.maxRequestsPerDay - this.requestCount);
897
- if (effectiveRemaining <= 0) {
1146
+ this.pruneRequestTimestamps(now);
1147
+ this.refreshExpiredHeaderQuota(now);
1148
+ const effectiveRemaining = this.getEffectiveRemainingRequests(now);
1149
+ const qpdProbeReady = this.isQuotaProbeReady(now);
1150
+ if (effectiveRemaining <= 0 && !qpdProbeReady) {
898
1151
  return false;
899
1152
  }
900
- return (now - this.lastRequestTime) >= this.getEffectiveMinInterval();
1153
+ return (now - this.lastRequestTime) >= this.getEffectiveMinInterval() &&
1154
+ now >= this.retryNotBefore;
901
1155
  }
902
1156
  getTimeUntilNextRequest() {
903
1157
  const now = Date.now();
904
1158
  const timeSinceLastRequest = now - this.lastRequestTime;
905
1159
  const minInterval = this.getEffectiveMinInterval();
906
- if (timeSinceLastRequest >= minInterval) {
907
- return 0;
908
- }
909
- return minInterval - timeSinceLastRequest;
1160
+ return Math.max(0, minInterval - timeSinceLastRequest, this.retryNotBefore - now);
910
1161
  }
911
1162
  getConfig() {
912
1163
  return { ...this.config };
@@ -1356,7 +1607,8 @@ class EtsyClient {
1356
1607
  this.logger = new DefaultLogger();
1357
1608
  this.keystring = config.keystring;
1358
1609
  this.sharedSecret = config.sharedSecret;
1359
- if (config.rateLimiting?.enabled !== false) {
1610
+ this.rateLimitingEnabled = config.rateLimiting?.enabled !== false;
1611
+ if (this.rateLimitingEnabled) {
1360
1612
  this.rateLimiter = new EtsyRateLimiter({
1361
1613
  maxRequestsPerDay: config.rateLimiting?.maxRequestsPerDay || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
1362
1614
  maxRequestsPerSecond: config.rateLimiting?.maxRequestsPerSecond || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
@@ -1381,11 +1633,39 @@ class EtsyClient {
1381
1633
  stopOnError: false
1382
1634
  });
1383
1635
  }
1636
+ async acquireRateLimitSlot() {
1637
+ return this.rateLimitingEnabled
1638
+ ? this.rateLimiter.acquireRequestSlot()
1639
+ : undefined;
1640
+ }
1641
+ async fetchOnceWithRateLimit(url, options) {
1642
+ const reservationId = await this.acquireRateLimitSlot();
1643
+ let response;
1644
+ try {
1645
+ response = await this.fetch(url, options);
1646
+ }
1647
+ catch (error) {
1648
+ if (reservationId !== undefined)
1649
+ this.rateLimiter.releaseRequestSlot(reservationId);
1650
+ throw error;
1651
+ }
1652
+ if (response.status === 429) {
1653
+ await this.rateLimiter.handleRateLimitResponse(response.headers, reservationId);
1654
+ this.rateLimiter.resetRetryCount();
1655
+ }
1656
+ else {
1657
+ this.rateLimiter.updateFromHeaders(response.headers, reservationId);
1658
+ if (response.ok)
1659
+ this.rateLimiter.resetRetryCount();
1660
+ }
1661
+ return response;
1662
+ }
1384
1663
  async makeRequest(endpoint, options = {}, useCache = true) {
1385
1664
  const url = `${this.baseUrl}${endpoint}`;
1386
1665
  const requestOptions = {
1387
1666
  method: 'GET',
1388
- ...options
1667
+ ...options,
1668
+ redirect: 'error'
1389
1669
  };
1390
1670
  const cacheKey = `${url}:${JSON.stringify(requestOptions)}`;
1391
1671
  if (useCache && this.cache && requestOptions.method === 'GET') {
@@ -1399,7 +1679,6 @@ class EtsyClient {
1399
1679
  }
1400
1680
  }
1401
1681
  }
1402
- await this.rateLimiter.waitForRateLimit();
1403
1682
  const accessToken = await this.tokenManager.getAccessToken();
1404
1683
  const headers = {
1405
1684
  'Authorization': `Bearer ${accessToken}`,
@@ -1411,22 +1690,38 @@ class EtsyClient {
1411
1690
  return this.executeWithRetry(url, { ...requestOptions, headers }, cacheKey, useCache);
1412
1691
  }
1413
1692
  async executeWithRetry(url, options, cacheKey, useCache) {
1693
+ let retryAttempt = 0;
1414
1694
  while (true) {
1415
1695
  try {
1416
- const response = await this.fetch(url, options);
1696
+ const reservationId = await this.acquireRateLimitSlot();
1697
+ let response;
1698
+ try {
1699
+ response = await this.fetch(url, options);
1700
+ }
1701
+ catch (error) {
1702
+ if (reservationId !== undefined)
1703
+ this.rateLimiter.releaseRequestSlot(reservationId);
1704
+ throw error;
1705
+ }
1417
1706
  if (response.status === 429) {
1418
- const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers);
1707
+ const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers, reservationId, ++retryAttempt);
1419
1708
  if (shouldRetry) {
1709
+ const method = (options.method ?? 'GET').toUpperCase();
1710
+ if (method !== 'GET') {
1711
+ this.rateLimiter.resetRetryCount();
1712
+ const errorText = await response.text();
1713
+ throw new EtsyApiError(`Etsy API error: 429 ${response.statusText}; automatic retry skipped for ${method} requests`, 429, errorText);
1714
+ }
1420
1715
  this.logger.warn(`Rate limited. Retrying in ${delayMs}ms...`);
1421
1716
  await this.sleep(delayMs);
1422
1717
  continue;
1423
1718
  }
1424
1719
  }
1720
+ this.rateLimiter.updateFromHeaders(response.headers, reservationId);
1425
1721
  if (!response.ok) {
1426
1722
  const errorText = await response.text();
1427
1723
  throw new EtsyApiError(`Etsy API error: ${response.status} ${response.statusText}`, response.status, errorText);
1428
1724
  }
1429
- this.rateLimiter.updateFromHeaders(response.headers);
1430
1725
  this.rateLimiter.resetRetryCount();
1431
1726
  if (response.status === 204) {
1432
1727
  return undefined;
@@ -1727,11 +2022,96 @@ class EtsyClient {
1727
2022
  await this.makeRequest(`/listings/${listingId}`, { method: 'DELETE' }, false);
1728
2023
  }
1729
2024
  async updateListingInventory(listingId, params, options) {
1730
- const legacy = options?.legacy;
1731
- const query = legacy !== undefined ? `?legacy=${legacy}` : '';
2025
+ const searchParams = new URLSearchParams();
2026
+ if (options?.legacy !== undefined) {
2027
+ searchParams.set('legacy', options.legacy.toString());
2028
+ }
2029
+ if (options?.enableScaleValidation !== undefined) {
2030
+ searchParams.set('enable_scale_validation', options.enableScaleValidation.toString());
2031
+ }
2032
+ const queryString = searchParams.toString();
2033
+ const query = queryString ? `?${queryString}` : '';
2034
+ if (!Array.isArray(params.products) || params.products.length === 0) {
2035
+ throw new TypeError('Inventory update must contain at least one product.');
2036
+ }
2037
+ const body = {
2038
+ products: params.products.map((product, productIndex) => {
2039
+ if (product.is_deleted === true) {
2040
+ throw new TypeError(`Inventory product ${productIndex} is deleted and cannot be submitted for replacement.`);
2041
+ }
2042
+ if (!Array.isArray(product.offerings) || product.offerings.length === 0) {
2043
+ throw new TypeError(`Inventory product ${productIndex} must contain at least one offering.`);
2044
+ }
2045
+ if (!Array.isArray(product.property_values)) {
2046
+ throw new TypeError(`Inventory product ${productIndex} must include property_values; pass [] when it has no variation properties.`);
2047
+ }
2048
+ if (product.sku !== undefined && product.sku !== null && typeof product.sku !== 'string') {
2049
+ throw new TypeError(`Inventory product ${productIndex} sku must be a string or null.`);
2050
+ }
2051
+ return {
2052
+ ...(product.sku !== undefined ? { sku: product.sku } : {}),
2053
+ property_values: product.property_values.map((value, valueIndex) => {
2054
+ if (!Number.isInteger(value.property_id) || value.property_id < 1) {
2055
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_id must be a positive integer.`);
2056
+ }
2057
+ if (!Array.isArray(value.value_ids) || value.value_ids.some((id) => !Number.isInteger(id))) {
2058
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].value_ids must be an integer array.`);
2059
+ }
2060
+ if (!Array.isArray(value.values) || value.values.some((entry) => typeof entry !== 'string')) {
2061
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].values must be a string array.`);
2062
+ }
2063
+ if (value.property_name !== undefined && typeof value.property_name !== 'string') {
2064
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_name must be a string.`);
2065
+ }
2066
+ if (value.scale_id !== undefined && value.scale_id !== null &&
2067
+ (!Number.isInteger(value.scale_id) || value.scale_id < 1)) {
2068
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].scale_id must be a positive integer or null.`);
2069
+ }
2070
+ return {
2071
+ property_id: value.property_id,
2072
+ ...(value.property_name !== undefined ? { property_name: value.property_name } : {}),
2073
+ ...(value.scale_id !== undefined ? { scale_id: value.scale_id } : {}),
2074
+ value_ids: value.value_ids,
2075
+ values: value.values,
2076
+ };
2077
+ }),
2078
+ offerings: product.offerings.map((offering, offeringIndex) => {
2079
+ if (offering.is_deleted === true) {
2080
+ throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is deleted and cannot be submitted for replacement.`);
2081
+ }
2082
+ if (typeof offering.price !== 'number' || !Number.isFinite(offering.price)) {
2083
+ throw new TypeError('Inventory offering price must be a finite decimal number in the listing currency.');
2084
+ }
2085
+ if (!Number.isInteger(offering.quantity)) {
2086
+ throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} quantity must be an integer.`);
2087
+ }
2088
+ if (typeof offering.is_enabled !== 'boolean') {
2089
+ throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is_enabled must be a boolean.`);
2090
+ }
2091
+ if (offering.readiness_state_id === undefined ||
2092
+ (offering.readiness_state_id !== null &&
2093
+ (!Number.isInteger(offering.readiness_state_id) || offering.readiness_state_id < 1))) {
2094
+ throw new TypeError('Inventory offering readiness_state_id is required; use null when not set.');
2095
+ }
2096
+ return {
2097
+ price: offering.price,
2098
+ quantity: offering.quantity,
2099
+ is_enabled: offering.is_enabled,
2100
+ readiness_state_id: offering.readiness_state_id,
2101
+ };
2102
+ }),
2103
+ };
2104
+ }),
2105
+ ...(params.price_on_property !== undefined ? { price_on_property: params.price_on_property } : {}),
2106
+ ...(params.quantity_on_property !== undefined ? { quantity_on_property: params.quantity_on_property } : {}),
2107
+ ...(params.sku_on_property !== undefined ? { sku_on_property: params.sku_on_property } : {}),
2108
+ ...(params.readiness_state_on_property !== undefined
2109
+ ? { readiness_state_on_property: params.readiness_state_on_property }
2110
+ : {}),
2111
+ };
1732
2112
  return this.makeRequest(`/listings/${listingId}/inventory${query}`, {
1733
2113
  method: 'PUT',
1734
- body: JSON.stringify(params)
2114
+ body: JSON.stringify(body)
1735
2115
  }, false);
1736
2116
  }
1737
2117
  async uploadListingImage(shopId, listingId, imageData, params) {
@@ -1746,9 +2126,8 @@ class EtsyClient {
1746
2126
  if (params?.alt_text)
1747
2127
  formData.append('alt_text', params.alt_text);
1748
2128
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/images`;
1749
- await this.rateLimiter.waitForRateLimit();
1750
2129
  const accessToken = await this.tokenManager.getAccessToken();
1751
- const response = await this.fetch(url, {
2130
+ const response = await this.fetchOnceWithRateLimit(url, {
1752
2131
  method: 'POST',
1753
2132
  headers: {
1754
2133
  'Authorization': `Bearer ${accessToken}`,
@@ -1943,10 +2322,11 @@ class EtsyClient {
1943
2322
  }
1944
2323
  async getShopPayment(shopId, paymentId) {
1945
2324
  const payments = await this.getPayments(shopId, [Number(paymentId)]);
1946
- if (payments.length === 0) {
2325
+ const payment = payments[0];
2326
+ if (!payment) {
1947
2327
  throw new EtsyApiError('Payment not found', 404);
1948
2328
  }
1949
- return payments[0];
2329
+ return payment;
1950
2330
  }
1951
2331
  async getBuyerTaxonomyNodes() {
1952
2332
  const response = await this.makeRequest('/buyer-taxonomy/nodes');
@@ -1969,9 +2349,8 @@ class EtsyClient {
1969
2349
  body.append('scale_id', scaleId.toString());
1970
2350
  }
1971
2351
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/properties/${propertyId}`;
1972
- await this.rateLimiter.waitForRateLimit();
1973
2352
  const accessToken = await this.tokenManager.getAccessToken();
1974
- const response = await this.fetch(url, {
2353
+ const response = await this.fetchOnceWithRateLimit(url, {
1975
2354
  method: 'PUT',
1976
2355
  headers: {
1977
2356
  'Authorization': `Bearer ${accessToken}`,
@@ -1980,8 +2359,6 @@ class EtsyClient {
1980
2359
  },
1981
2360
  body: body.toString()
1982
2361
  });
1983
- this.rateLimiter.updateFromHeaders(response.headers);
1984
- this.rateLimiter.resetRetryCount();
1985
2362
  if (!response.ok) {
1986
2363
  const errorText = await response.text();
1987
2364
  throw new EtsyApiError(`Failed to update listing property: ${response.status} ${response.statusText}`, response.status, errorText);
@@ -2081,9 +2458,8 @@ class EtsyClient {
2081
2458
  if (params?.listing_file_id !== undefined)
2082
2459
  formData.append('listing_file_id', params.listing_file_id.toString());
2083
2460
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/files`;
2084
- await this.rateLimiter.waitForRateLimit();
2085
2461
  const accessToken = await this.tokenManager.getAccessToken();
2086
- const response = await this.fetch(url, {
2462
+ const response = await this.fetchOnceWithRateLimit(url, {
2087
2463
  method: 'POST',
2088
2464
  headers: {
2089
2465
  'Authorization': `Bearer ${accessToken}`,
@@ -2115,9 +2491,8 @@ class EtsyClient {
2115
2491
  if (params?.video_id !== undefined)
2116
2492
  formData.append('video_id', params.video_id.toString());
2117
2493
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/videos`;
2118
- await this.rateLimiter.waitForRateLimit();
2119
2494
  const accessToken = await this.tokenManager.getAccessToken();
2120
- const response = await this.fetch(url, {
2495
+ const response = await this.fetchOnceWithRateLimit(url, {
2121
2496
  method: 'POST',
2122
2497
  headers: {
2123
2498
  'Authorization': `Bearer ${accessToken}`,
@@ -2439,7 +2814,7 @@ async function sha256(data) {
2439
2814
  const encoder = new TextEncoder();
2440
2815
  const dataBytes = typeof data === 'string' ? encoder.encode(data) : data;
2441
2816
  if (isBrowser) {
2442
- const hashBuffer = await crypto.subtle.digest('SHA-256', dataBytes);
2817
+ const hashBuffer = await crypto.subtle.digest('SHA-256', new Uint8Array(dataBytes));
2443
2818
  return new Uint8Array(hashBuffer);
2444
2819
  }
2445
2820
  else if (isNode) {
@@ -2539,6 +2914,7 @@ class AuthHelper {
2539
2914
  assertFetchSupport();
2540
2915
  const response = await fetch('https://api.etsy.com/v3/public/oauth/token', {
2541
2916
  method: 'POST',
2917
+ redirect: 'error',
2542
2918
  headers: {
2543
2919
  'Content-Type': 'application/x-www-form-urlencoded',
2544
2920
  'Accept': 'application/json'
@@ -2631,18 +3007,17 @@ const COMMON_SCOPE_COMBINATIONS = {
2631
3007
  ]
2632
3008
  };
2633
3009
 
3010
+ const QPD_WINDOW_MS = 24 * 60 * 60 * 1000;
2634
3011
  class GlobalRequestQueue {
2635
3012
  constructor() {
2636
3013
  this.queue = [];
2637
3014
  this.processing = false;
2638
3015
  this.rateLimits = new Map();
2639
- this.requestCount = 0;
2640
- this.dailyReset = new Date();
3016
+ this.requestTimestamps = [];
2641
3017
  this.lastRequestTime = 0;
2642
3018
  this.maxRequestsPerDay = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY;
2643
3019
  this.maxRequestsPerSecond = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND;
2644
3020
  this.minRequestInterval = ETSY_RATE_LIMITS.MIN_REQUEST_INTERVAL;
2645
- this.setNextDailyReset();
2646
3021
  }
2647
3022
  static getInstance() {
2648
3023
  if (!this.instance) {
@@ -2674,11 +3049,13 @@ class GlobalRequestQueue {
2674
3049
  });
2675
3050
  }
2676
3051
  getStatus() {
3052
+ const now = Date.now();
3053
+ this.pruneRequestTimestamps(now);
2677
3054
  return {
2678
3055
  queueLength: this.queue.length,
2679
3056
  processing: this.processing,
2680
- remainingRequests: Math.max(0, this.maxRequestsPerDay - this.requestCount),
2681
- resetTime: this.dailyReset,
3057
+ remainingRequests: Math.max(0, this.maxRequestsPerDay - this.requestTimestamps.length),
3058
+ resetTime: this.getNextRequestExpiry(now),
2682
3059
  };
2683
3060
  }
2684
3061
  clear() {
@@ -2694,16 +3071,13 @@ class GlobalRequestQueue {
2694
3071
  this.processing = true;
2695
3072
  try {
2696
3073
  while (this.queue.length > 0) {
2697
- if (Date.now() >= this.dailyReset.getTime()) {
2698
- this.requestCount = 0;
2699
- this.setNextDailyReset();
2700
- }
2701
- if (this.requestCount >= this.maxRequestsPerDay) {
2702
- const timeUntilReset = this.dailyReset.getTime() - Date.now();
2703
- console.warn(`Daily rate limit reached. Waiting ${Math.ceil(timeUntilReset / 1000 / 60)} minutes until reset.`);
3074
+ const now = Date.now();
3075
+ this.pruneRequestTimestamps(now);
3076
+ if (this.requestTimestamps.length >= this.maxRequestsPerDay) {
3077
+ const timeUntilReset = Math.max(0, this.getNextRequestExpiry(now).getTime() - now);
3078
+ console.warn(`Daily rate limit reached. Waiting ${Math.ceil(timeUntilReset / 1000 / 60)} minutes for the oldest request to leave the rolling 24-hour window.`);
2704
3079
  await this.delay(timeUntilReset);
2705
- this.requestCount = 0;
2706
- this.setNextDailyReset();
3080
+ continue;
2707
3081
  }
2708
3082
  await this.waitForRateLimit();
2709
3083
  this.queue.sort((a, b) => {
@@ -2738,7 +3112,7 @@ class GlobalRequestQueue {
2738
3112
  }
2739
3113
  });
2740
3114
  try {
2741
- result = await Promise.race([item.request(), timeoutPromise]);
3115
+ result = await Promise.race([this.startRequest(item.request), timeoutPromise]);
2742
3116
  }
2743
3117
  finally {
2744
3118
  if (timeoutId) {
@@ -2747,11 +3121,9 @@ class GlobalRequestQueue {
2747
3121
  }
2748
3122
  }
2749
3123
  else {
2750
- result = await item.request();
3124
+ result = await this.startRequest(item.request);
2751
3125
  }
2752
3126
  item.resolve(result);
2753
- this.requestCount++;
2754
- this.lastRequestTime = Date.now();
2755
3127
  }
2756
3128
  catch (error) {
2757
3129
  if (error instanceof EtsyRateLimitError) {
@@ -2771,7 +3143,7 @@ class GlobalRequestQueue {
2771
3143
  const globalRateLimit = this.rateLimits.get('global');
2772
3144
  if (globalRateLimit && now < globalRateLimit.resetAt) {
2773
3145
  const waitTime = globalRateLimit.resetAt - now;
2774
- console.log(`Global rate limit active. Waiting ${waitTime}ms`);
3146
+ console.warn(`Global rate limit active. Waiting ${waitTime}ms`);
2775
3147
  await this.delay(waitTime);
2776
3148
  }
2777
3149
  const timeSinceLastRequest = now - this.lastRequestTime;
@@ -2789,11 +3161,23 @@ class GlobalRequestQueue {
2789
3161
  });
2790
3162
  }
2791
3163
  }
2792
- setNextDailyReset() {
2793
- const now = new Date();
2794
- this.dailyReset = new Date(now);
2795
- this.dailyReset.setUTCDate(this.dailyReset.getUTCDate() + 1);
2796
- this.dailyReset.setUTCHours(0, 0, 0, 0);
3164
+ pruneRequestTimestamps(now) {
3165
+ const oldestAllowed = now - QPD_WINDOW_MS;
3166
+ while (this.requestTimestamps[0] !== undefined && this.requestTimestamps[0] <= oldestAllowed) {
3167
+ this.requestTimestamps.shift();
3168
+ }
3169
+ }
3170
+ getNextRequestExpiry(now) {
3171
+ this.pruneRequestTimestamps(now);
3172
+ const oldestRequest = this.requestTimestamps[0] ?? now;
3173
+ return new Date(oldestRequest + QPD_WINDOW_MS);
3174
+ }
3175
+ startRequest(request) {
3176
+ const startedAt = Date.now();
3177
+ this.pruneRequestTimestamps(startedAt);
3178
+ this.requestTimestamps.push(startedAt);
3179
+ this.lastRequestTime = startedAt;
3180
+ return request();
2797
3181
  }
2798
3182
  generateId() {
2799
3183
  return `${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;