@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.esm.js CHANGED
@@ -339,9 +339,12 @@ class MemoryTokenStorage {
339
339
  class TokenManager {
340
340
  constructor(config, storage, rotationConfig) {
341
341
  this.currentTokens = null;
342
+ this.tokenMutationGeneration = 0;
342
343
  this.keystring = config.keystring;
343
- this.refreshCallback = config.refreshSave;
344
+ this.refreshCallback = config.refreshSaveAsync ?? config.refreshSave;
345
+ this.refreshClearRequired = config.refreshSaveAsync !== undefined;
344
346
  this.storage = storage;
347
+ this.clearCallback = config.refreshClearAsync;
345
348
  this.rotationConfig = rotationConfig;
346
349
  this.currentTokens = {
347
350
  access_token: config.accessToken,
@@ -378,21 +381,82 @@ class TokenManager {
378
381
  if (!this.currentTokens) {
379
382
  throw new EtsyAuthError('No tokens available to refresh', 'NO_REFRESH_TOKEN');
380
383
  }
381
- this.refreshPromise = this.performTokenRefresh();
382
- try {
383
- const newTokens = await this.refreshPromise;
384
- this.currentTokens = newTokens;
385
- if (this.storage) {
386
- await this.storage.save(newTokens);
384
+ const generation = this.tokenMutationGeneration;
385
+ const refreshPromise = (this.pendingTokens
386
+ ? this.retryPendingTokenPersistence()
387
+ : this.performTokenRefresh().then(async (newTokens) => {
388
+ this.throwIfSuperseded(generation);
389
+ this.pendingTokens = newTokens;
390
+ return this.persistRefreshedTokens(newTokens, generation);
391
+ })).finally(() => {
392
+ this.refreshPromise = undefined;
393
+ });
394
+ this.refreshPromise = refreshPromise;
395
+ return refreshPromise;
396
+ }
397
+ throwIfSuperseded(generation) {
398
+ if (generation !== this.tokenMutationGeneration) {
399
+ throw new EtsyAuthError('Token refresh was superseded by a token update or clear', 'TOKEN_REFRESH_SUPERSEDED');
400
+ }
401
+ }
402
+ async repairPersistenceToCurrentTokens() {
403
+ while (true) {
404
+ const generation = this.tokenMutationGeneration;
405
+ const current = this.currentTokens ? { ...this.currentTokens } : null;
406
+ try {
407
+ if (current) {
408
+ if (this.storage)
409
+ await this.storage.save(current);
410
+ if (this.refreshCallback) {
411
+ await this.refreshCallback(current.access_token, current.refresh_token, current.expires_at);
412
+ }
413
+ }
414
+ else {
415
+ if (this.storage)
416
+ await this.storage.clear();
417
+ if (this.clearCallback)
418
+ await this.clearCallback();
419
+ else if (this.refreshClearRequired) {
420
+ throw new EtsyAuthError('A refresh persistence callback is configured without refreshClearAsync; durable tokens could not be cleared safely', 'TOKEN_CLEAR_CALLBACK_REQUIRED');
421
+ }
422
+ }
387
423
  }
388
- if (this.refreshCallback) {
389
- this.refreshCallback(newTokens.access_token, newTokens.refresh_token, newTokens.expires_at);
424
+ catch (error) {
425
+ if (generation !== this.tokenMutationGeneration)
426
+ continue;
427
+ throw error;
390
428
  }
391
- return newTokens;
429
+ if (generation === this.tokenMutationGeneration)
430
+ return;
392
431
  }
393
- finally {
394
- this.refreshPromise = undefined;
432
+ }
433
+ async persistRefreshedTokens(newTokens, generation) {
434
+ this.throwIfSuperseded(generation);
435
+ if (this.storage) {
436
+ await this.storage.save(newTokens);
437
+ if (generation !== this.tokenMutationGeneration) {
438
+ await this.repairPersistenceToCurrentTokens();
439
+ this.throwIfSuperseded(generation);
440
+ }
441
+ }
442
+ this.throwIfSuperseded(generation);
443
+ if (this.refreshCallback) {
444
+ await this.refreshCallback(newTokens.access_token, newTokens.refresh_token, newTokens.expires_at);
445
+ if (generation !== this.tokenMutationGeneration) {
446
+ await this.repairPersistenceToCurrentTokens();
447
+ this.throwIfSuperseded(generation);
448
+ }
449
+ }
450
+ this.throwIfSuperseded(generation);
451
+ this.currentTokens = newTokens;
452
+ this.pendingTokens = undefined;
453
+ return newTokens;
454
+ }
455
+ async retryPendingTokenPersistence() {
456
+ if (!this.pendingTokens) {
457
+ throw new EtsyAuthError('No rotated tokens are pending persistence', 'NO_PENDING_TOKENS');
395
458
  }
459
+ return this.persistRefreshedTokens(this.pendingTokens, this.tokenMutationGeneration);
396
460
  }
397
461
  async performTokenRefresh() {
398
462
  if (!this.currentTokens) {
@@ -406,6 +470,7 @@ class TokenManager {
406
470
  try {
407
471
  const response = await this.fetch('https://api.etsy.com/v3/public/oauth/token', {
408
472
  method: 'POST',
473
+ redirect: 'error',
409
474
  headers: {
410
475
  'Content-Type': 'application/x-www-form-urlencoded',
411
476
  'Accept': 'application/json'
@@ -435,7 +500,9 @@ class TokenManager {
435
500
  return this.currentTokens ? { ...this.currentTokens } : null;
436
501
  }
437
502
  updateTokens(tokens) {
503
+ this.tokenMutationGeneration += 1;
438
504
  this.currentTokens = { ...tokens };
505
+ this.pendingTokens = undefined;
439
506
  }
440
507
  isTokenExpired() {
441
508
  if (!this.currentTokens) {
@@ -455,10 +522,19 @@ class TokenManager {
455
522
  return now.getTime() >= (expiresAt.getTime() - bufferTime);
456
523
  }
457
524
  async clearTokens() {
525
+ this.tokenMutationGeneration += 1;
458
526
  this.currentTokens = null;
527
+ this.pendingTokens = undefined;
528
+ if (this.refreshPromise)
529
+ await this.refreshPromise.catch(() => undefined);
459
530
  if (this.storage) {
460
531
  await this.storage.clear();
461
532
  }
533
+ if (this.clearCallback)
534
+ await this.clearCallback();
535
+ else if (this.refreshClearRequired) {
536
+ throw new EtsyAuthError('A refresh persistence callback is configured without refreshClearAsync; durable tokens could not be cleared safely', 'TOKEN_CLEAR_CALLBACK_REQUIRED');
537
+ }
462
538
  }
463
539
  getTimeUntilExpiration() {
464
540
  if (!this.currentTokens) {
@@ -698,13 +774,24 @@ const ETSY_RATE_LIMITS = {
698
774
  MAX_REQUESTS_PER_SECOND: 5,
699
775
  MIN_REQUEST_INTERVAL: 200,
700
776
  };
777
+ const QPD_WINDOW_MS$1 = 24 * 60 * 60 * 1000;
701
778
  class EtsyRateLimiter {
702
779
  constructor(config) {
703
- this.requestCount = 0;
704
- this.dailyReset = new Date();
780
+ this.requestTimestamps = [];
781
+ this.uncertainRequestTimestamps = [];
705
782
  this.lastRequestTime = 0;
706
783
  this.isHeaderBasedLimiting = false;
784
+ this.headerQuotaAvailableAt = 0;
785
+ this.retryNotBefore = 0;
786
+ this.quotaProbeBackoffMs = 60000;
787
+ this.nextReservationId = 0;
788
+ this.latestHeaderReservationId = 0;
789
+ this.inFlightReservations = new Map();
790
+ this.quotaProbeInFlight = false;
791
+ this.stateWaiters = new Set();
707
792
  this.currentRetryCount = 0;
793
+ this.reservationQueue = Promise.resolve();
794
+ const overrides = Object.fromEntries(Object.entries(config ?? {}).filter(([, value]) => value !== undefined));
708
795
  this.config = {
709
796
  maxRequestsPerDay: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
710
797
  maxRequestsPerSecond: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
@@ -715,21 +802,155 @@ class EtsyRateLimiter {
715
802
  jitter: 0.1,
716
803
  qpdWarningThreshold: 80,
717
804
  onApproachingLimit: undefined,
718
- ...config
805
+ ...overrides
719
806
  };
720
- this.setNextDailyReset();
721
807
  }
722
- setNextDailyReset() {
723
- const now = new Date();
724
- this.dailyReset = new Date(now);
725
- this.dailyReset.setUTCDate(this.dailyReset.getUTCDate() + 1);
726
- this.dailyReset.setUTCHours(0, 0, 0, 0);
808
+ pruneRequestTimestamps(now) {
809
+ const oldestAllowed = now - QPD_WINDOW_MS$1;
810
+ while (this.requestTimestamps[0] !== undefined && this.requestTimestamps[0] <= oldestAllowed) {
811
+ this.requestTimestamps.shift();
812
+ }
813
+ while (this.uncertainRequestTimestamps[0] !== undefined && this.uncertainRequestTimestamps[0] <= oldestAllowed) {
814
+ this.uncertainRequestTimestamps.shift();
815
+ }
816
+ }
817
+ getLocalResetTime(now) {
818
+ this.pruneRequestTimestamps(now);
819
+ const oldestRequest = this.requestTimestamps[0] ?? now;
820
+ return new Date(oldestRequest + QPD_WINDOW_MS$1);
821
+ }
822
+ getLocalRemainingRequests(now) {
823
+ this.pruneRequestTimestamps(now);
824
+ const localLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
825
+ return Math.max(0, localLimit - this.requestTimestamps.length);
826
+ }
827
+ getEffectiveRemainingRequests(now) {
828
+ this.pruneRequestTimestamps(now);
829
+ if (this.headerRemainingToday !== undefined) {
830
+ return Math.max(0, this.headerRemainingToday - this.inFlightReservations.size - this.uncertainRequestTimestamps.length);
831
+ }
832
+ return this.getLocalRemainingRequests(now);
833
+ }
834
+ isQuotaProbeReady(now) {
835
+ const exhausted = this.headerRemainingToday === 0;
836
+ const uncertainBudgetExhausted = this.inFlightReservations.size === 0 &&
837
+ this.uncertainRequestTimestamps.length > 0 &&
838
+ this.getEffectiveRemainingRequests(now) === 0;
839
+ return (exhausted || uncertainBudgetExhausted) &&
840
+ !this.quotaProbeInFlight &&
841
+ this.inFlightReservations.size === 0 &&
842
+ this.headerQuotaAvailableAt <= now;
843
+ }
844
+ refreshExpiredHeaderQuota(now) {
845
+ if (this.headerRemainingToday === 0 && this.headerQuotaAvailableAt > 0 && now >= this.headerQuotaAvailableAt) {
846
+ this.headerQuotaAvailableAt = 0;
847
+ }
848
+ }
849
+ notifyStateWaiters() {
850
+ for (const resolve of this.stateWaiters)
851
+ resolve();
852
+ this.stateWaiters.clear();
853
+ }
854
+ scheduleQuotaProbe() {
855
+ this.headerQuotaAvailableAt = Date.now() + this.quotaProbeBackoffMs;
856
+ this.quotaProbeBackoffMs = Math.min(this.quotaProbeBackoffMs * 2, 60 * 60 * 1000);
857
+ }
858
+ waitForStateChange() {
859
+ return new Promise((resolve) => this.stateWaiters.add(resolve));
860
+ }
861
+ finishReservation(reservationId, receivedQuotaHeader) {
862
+ const wasQuotaProbe = this.inFlightReservations.get(reservationId);
863
+ if (wasQuotaProbe === undefined)
864
+ return;
865
+ this.inFlightReservations.delete(reservationId);
866
+ if (wasQuotaProbe.isQuotaProbe) {
867
+ this.quotaProbeInFlight = false;
868
+ if (this.headerRemainingToday === 0 && !receivedQuotaHeader) {
869
+ this.scheduleQuotaProbe();
870
+ }
871
+ }
872
+ this.notifyStateWaiters();
727
873
  }
728
- updateFromHeaders(headers) {
874
+ async acquireRequestSlot() {
875
+ const previousReservation = this.reservationQueue;
876
+ let releaseReservation = () => undefined;
877
+ this.reservationQueue = new Promise((resolve) => {
878
+ releaseReservation = resolve;
879
+ });
880
+ await previousReservation;
881
+ try {
882
+ while (true) {
883
+ const now = Date.now();
884
+ this.pruneRequestTimestamps(now);
885
+ this.refreshExpiredHeaderQuota(now);
886
+ let isQuotaProbe = false;
887
+ if (this.headerRemainingToday === 0 || this.isQuotaProbeReady(now)) {
888
+ if (this.quotaProbeInFlight || this.inFlightReservations.size > 0) {
889
+ await this.waitForStateChange();
890
+ continue;
891
+ }
892
+ const probeWait = this.headerQuotaAvailableAt - now;
893
+ if (probeWait > 0) {
894
+ await new Promise(resolve => setTimeout(resolve, probeWait));
895
+ continue;
896
+ }
897
+ isQuotaProbe = true;
898
+ }
899
+ else {
900
+ const effectiveRemaining = this.getEffectiveRemainingRequests(now);
901
+ if (effectiveRemaining <= 0) {
902
+ if (this.inFlightReservations.size > 0 && this.headerRemainingToday !== undefined) {
903
+ await this.waitForStateChange();
904
+ continue;
905
+ }
906
+ const effectiveDailyLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
907
+ const localResetTime = this.getLocalResetTime(now);
908
+ const timeUntilReset = Math.max(0, localResetTime.getTime() - now);
909
+ throw new EtsyRateLimitError(`Daily rate limit exhausted (${effectiveDailyLimit} requests in the local rolling 24-hour window). ` +
910
+ `The oldest local request expires in approximately ${Math.ceil(timeUntilReset / 1000 / 60)} minutes.`, Math.ceil(timeUntilReset / 1000), 'qpd_exhausted');
911
+ }
912
+ }
913
+ const intervalWait = Math.max(0, this.getEffectiveMinInterval() - (now - this.lastRequestTime));
914
+ const cooldownWait = Math.max(0, this.retryNotBefore - now);
915
+ const waitTime = Math.max(intervalWait, cooldownWait);
916
+ if (waitTime > 0) {
917
+ await new Promise(resolve => setTimeout(resolve, waitTime));
918
+ continue;
919
+ }
920
+ const requestStartedAt = Date.now();
921
+ const reservationId = ++this.nextReservationId;
922
+ this.requestTimestamps.push(requestStartedAt);
923
+ this.lastRequestTime = requestStartedAt;
924
+ this.inFlightReservations.set(reservationId, { isQuotaProbe, startedAt: requestStartedAt });
925
+ if (isQuotaProbe)
926
+ this.quotaProbeInFlight = true;
927
+ return reservationId;
928
+ }
929
+ }
930
+ finally {
931
+ releaseReservation();
932
+ }
933
+ }
934
+ releaseRequestSlot(reservationId) {
935
+ const reservation = this.inFlightReservations.get(reservationId);
936
+ if (!reservation)
937
+ return;
938
+ this.uncertainRequestTimestamps.push(reservation.startedAt);
939
+ this.finishReservation(reservationId, false);
940
+ }
941
+ updateFromHeaders(headers, reservationId) {
942
+ const matchedReservationId = reservationId;
943
+ const isQuotaProbe = matchedReservationId !== undefined &&
944
+ this.inFlightReservations.get(matchedReservationId)?.isQuotaProbe === true;
729
945
  if (!headers) {
946
+ if (matchedReservationId !== undefined)
947
+ this.finishReservation(matchedReservationId, false);
730
948
  return;
731
949
  }
732
950
  const parsed = this.parseRateLimitHeaders(headers);
951
+ if (matchedReservationId !== undefined) {
952
+ this.finishReservation(matchedReservationId, parsed.remainingToday !== undefined);
953
+ }
733
954
  if (parsed.limitPerSecond !== undefined) {
734
955
  this.headerLimitPerSecond = parsed.limitPerSecond;
735
956
  this.isHeaderBasedLimiting = true;
@@ -742,9 +963,39 @@ class EtsyRateLimiter {
742
963
  this.isHeaderBasedLimiting = true;
743
964
  }
744
965
  if (parsed.remainingToday !== undefined) {
745
- this.headerRemainingToday = parsed.remainingToday;
966
+ const observationId = matchedReservationId ?? this.nextReservationId;
967
+ if (isQuotaProbe && parsed.remainingToday > 0) {
968
+ this.uncertainRequestTimestamps = [];
969
+ }
970
+ const exhaustedByEarlierObservation = this.headerRemainingToday === 0 &&
971
+ parsed.remainingToday > 0 && matchedReservationId !== undefined && !isQuotaProbe;
972
+ const exhaustedByUncorrelatedObservation = this.headerRemainingToday === 0 &&
973
+ parsed.remainingToday > 0 && matchedReservationId === undefined;
974
+ if (exhaustedByEarlierObservation || exhaustedByUncorrelatedObservation) ;
975
+ else if (isQuotaProbe || this.headerRemainingToday === undefined) {
976
+ this.headerRemainingToday = parsed.remainingToday;
977
+ this.latestHeaderReservationId = Math.max(this.latestHeaderReservationId, observationId);
978
+ }
979
+ else if (parsed.remainingToday < this.headerRemainingToday) {
980
+ this.headerRemainingToday = parsed.remainingToday;
981
+ this.latestHeaderReservationId = Math.max(this.latestHeaderReservationId, observationId);
982
+ }
983
+ if (this.headerRemainingToday !== undefined && this.headerRemainingToday > 0) {
984
+ this.headerQuotaAvailableAt = 0;
985
+ this.quotaProbeBackoffMs = 60000;
986
+ }
987
+ else if (this.headerQuotaAvailableAt === 0) {
988
+ this.scheduleQuotaProbe();
989
+ }
746
990
  this.checkApproachingLimit();
747
991
  }
992
+ else if (matchedReservationId !== undefined && this.headerRemainingToday !== undefined) {
993
+ this.headerRemainingToday = Math.max(0, this.headerRemainingToday - 1);
994
+ if (this.headerRemainingToday === 0 && this.headerQuotaAvailableAt === 0) {
995
+ this.scheduleQuotaProbe();
996
+ }
997
+ }
998
+ this.notifyStateWaiters();
748
999
  }
749
1000
  parseRateLimitHeaders(headers) {
750
1001
  const getHeader = (name) => {
@@ -784,18 +1035,27 @@ class EtsyRateLimiter {
784
1035
  this.config.onApproachingLimit(remaining, limit, percentageUsed);
785
1036
  }
786
1037
  }
787
- async handleRateLimitResponse(headers) {
1038
+ async handleRateLimitResponse(headers, reservationId, retryAttempt) {
788
1039
  const parsed = headers ? this.parseRateLimitHeaders(headers) : {};
789
- this.updateFromHeaders(headers);
790
- if (this.headerRemainingToday === 0) {
791
- throw new EtsyRateLimitError('Daily rate limit exhausted. No requests remaining until limit resets.', parsed.retryAfter, 'qpd_exhausted');
792
- }
793
- this.currentRetryCount++;
794
- if (this.currentRetryCount > this.config.maxRetries) {
1040
+ this.updateFromHeaders(headers, reservationId);
1041
+ if (parsed.remainingToday === 0) {
1042
+ if (parsed.retryAfter !== undefined) {
1043
+ this.headerQuotaAvailableAt = Date.now() + parsed.retryAfter * 1000;
1044
+ this.retryNotBefore = Math.max(this.retryNotBefore, this.headerQuotaAvailableAt);
1045
+ }
795
1046
  this.currentRetryCount = 0;
1047
+ throw new EtsyRateLimitError('Daily rate limit exhausted according to Etsy response headers.', parsed.retryAfter, 'qpd_exhausted');
1048
+ }
1049
+ const attempt = retryAttempt ?? this.currentRetryCount + 1;
1050
+ if (retryAttempt === undefined)
1051
+ this.currentRetryCount = attempt;
1052
+ const delayMs = this.calculateBackoffDelay(attempt, parsed.retryAfter);
1053
+ this.retryNotBefore = Math.max(this.retryNotBefore, Date.now() + delayMs);
1054
+ if (attempt > this.config.maxRetries) {
1055
+ if (retryAttempt === undefined)
1056
+ this.currentRetryCount = 0;
796
1057
  throw new EtsyRateLimitError(`Max retries (${this.config.maxRetries}) exceeded for rate limit`, parsed.retryAfter, 'qps_exhausted');
797
1058
  }
798
- const delayMs = this.calculateBackoffDelay(this.currentRetryCount, parsed.retryAfter);
799
1059
  return { shouldRetry: true, delayMs };
800
1060
  }
801
1061
  calculateBackoffDelay(attempt, retryAfterSeconds) {
@@ -808,7 +1068,7 @@ class EtsyRateLimiter {
808
1068
  const randomJitter = Math.random() * jitterAmount * 2 - jitterAmount;
809
1069
  delay += randomJitter;
810
1070
  }
811
- return Math.max(0, Math.floor(delay));
1071
+ return Math.max(serverSuggestedMs, 0, Math.floor(delay));
812
1072
  }
813
1073
  resetRetryCount() {
814
1074
  this.currentRetryCount = 0;
@@ -826,41 +1086,26 @@ class EtsyRateLimiter {
826
1086
  return this.config.minRequestInterval;
827
1087
  }
828
1088
  async waitForRateLimit() {
829
- const now = Date.now();
830
- if (now >= this.dailyReset.getTime()) {
831
- this.requestCount = 0;
832
- this.setNextDailyReset();
833
- }
834
- const effectiveDailyLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
835
- const effectiveRemaining = this.headerRemainingToday ??
836
- (effectiveDailyLimit - this.requestCount);
837
- if (effectiveRemaining <= 0) {
838
- const timeUntilReset = this.dailyReset.getTime() - now;
839
- throw new EtsyRateLimitError(`Daily rate limit exhausted (${effectiveDailyLimit} requests). ` +
840
- `Reset in approximately ${Math.ceil(timeUntilReset / 1000 / 60)} minutes.`, Math.ceil(timeUntilReset / 1000), 'qpd_exhausted');
841
- }
842
- const minInterval = this.getEffectiveMinInterval();
843
- const timeSinceLastRequest = now - this.lastRequestTime;
844
- if (timeSinceLastRequest < minInterval) {
845
- const waitTime = minInterval - timeSinceLastRequest;
846
- await new Promise(resolve => setTimeout(resolve, waitTime));
847
- }
848
- this.requestCount++;
849
- this.lastRequestTime = Date.now();
1089
+ const reservationId = await this.acquireRequestSlot();
1090
+ this.finishReservation(reservationId, false);
1091
+ }
1092
+ async waitForRateLimitWithReservation() {
1093
+ return this.acquireRequestSlot();
850
1094
  }
851
1095
  getRateLimitStatus() {
852
1096
  const now = Date.now();
853
- if (now >= this.dailyReset.getTime()) {
854
- this.requestCount = 0;
855
- this.setNextDailyReset();
856
- }
857
- const effectiveRemaining = this.headerRemainingToday ??
858
- Math.max(0, this.config.maxRequestsPerDay - this.requestCount);
1097
+ this.pruneRequestTimestamps(now);
1098
+ this.refreshExpiredHeaderQuota(now);
1099
+ const effectiveRemaining = this.getEffectiveRemainingRequests(now);
1100
+ const qpdProbeReady = this.isQuotaProbeReady(now);
859
1101
  return {
860
1102
  remainingRequests: effectiveRemaining,
861
- resetTime: this.dailyReset,
862
- canMakeRequest: effectiveRemaining > 0 &&
863
- (now - this.lastRequestTime) >= this.getEffectiveMinInterval(),
1103
+ resetTime: this.headerRemainingToday === 0
1104
+ ? new Date(this.headerQuotaAvailableAt || now)
1105
+ : this.getLocalResetTime(now),
1106
+ canMakeRequest: (effectiveRemaining > 0 || qpdProbeReady) &&
1107
+ (now - this.lastRequestTime) >= this.getEffectiveMinInterval() &&
1108
+ now >= this.retryNotBefore,
864
1109
  isFromHeaders: this.isHeaderBasedLimiting,
865
1110
  limitPerSecond: this.headerLimitPerSecond,
866
1111
  remainingThisSecond: this.headerRemainingThisSecond,
@@ -868,41 +1113,47 @@ class EtsyRateLimiter {
868
1113
  };
869
1114
  }
870
1115
  getRemainingRequests() {
871
- return this.headerRemainingToday ??
872
- Math.max(0, this.config.maxRequestsPerDay - this.requestCount);
1116
+ const now = Date.now();
1117
+ this.pruneRequestTimestamps(now);
1118
+ this.refreshExpiredHeaderQuota(now);
1119
+ return this.getEffectiveRemainingRequests(now);
873
1120
  }
874
1121
  reset() {
875
- this.requestCount = 0;
1122
+ this.requestTimestamps = [];
876
1123
  this.lastRequestTime = 0;
877
1124
  this.currentRetryCount = 0;
1125
+ this.quotaProbeBackoffMs = 60000;
1126
+ this.headerQuotaAvailableAt = 0;
1127
+ this.retryNotBefore = 0;
878
1128
  this.headerLimitPerSecond = undefined;
879
1129
  this.headerRemainingThisSecond = undefined;
880
1130
  this.headerLimitPerDay = undefined;
881
1131
  this.headerRemainingToday = undefined;
882
1132
  this.isHeaderBasedLimiting = false;
883
- this.setNextDailyReset();
1133
+ this.nextReservationId = 0;
1134
+ this.latestHeaderReservationId = 0;
1135
+ this.inFlightReservations.clear();
1136
+ this.uncertainRequestTimestamps = [];
1137
+ this.quotaProbeInFlight = false;
1138
+ this.notifyStateWaiters();
884
1139
  }
885
1140
  canMakeRequest() {
886
1141
  const now = Date.now();
887
- if (now >= this.dailyReset.getTime()) {
888
- this.requestCount = 0;
889
- this.setNextDailyReset();
890
- }
891
- const effectiveRemaining = this.headerRemainingToday ??
892
- (this.config.maxRequestsPerDay - this.requestCount);
893
- if (effectiveRemaining <= 0) {
1142
+ this.pruneRequestTimestamps(now);
1143
+ this.refreshExpiredHeaderQuota(now);
1144
+ const effectiveRemaining = this.getEffectiveRemainingRequests(now);
1145
+ const qpdProbeReady = this.isQuotaProbeReady(now);
1146
+ if (effectiveRemaining <= 0 && !qpdProbeReady) {
894
1147
  return false;
895
1148
  }
896
- return (now - this.lastRequestTime) >= this.getEffectiveMinInterval();
1149
+ return (now - this.lastRequestTime) >= this.getEffectiveMinInterval() &&
1150
+ now >= this.retryNotBefore;
897
1151
  }
898
1152
  getTimeUntilNextRequest() {
899
1153
  const now = Date.now();
900
1154
  const timeSinceLastRequest = now - this.lastRequestTime;
901
1155
  const minInterval = this.getEffectiveMinInterval();
902
- if (timeSinceLastRequest >= minInterval) {
903
- return 0;
904
- }
905
- return minInterval - timeSinceLastRequest;
1156
+ return Math.max(0, minInterval - timeSinceLastRequest, this.retryNotBefore - now);
906
1157
  }
907
1158
  getConfig() {
908
1159
  return { ...this.config };
@@ -1352,7 +1603,8 @@ class EtsyClient {
1352
1603
  this.logger = new DefaultLogger();
1353
1604
  this.keystring = config.keystring;
1354
1605
  this.sharedSecret = config.sharedSecret;
1355
- if (config.rateLimiting?.enabled !== false) {
1606
+ this.rateLimitingEnabled = config.rateLimiting?.enabled !== false;
1607
+ if (this.rateLimitingEnabled) {
1356
1608
  this.rateLimiter = new EtsyRateLimiter({
1357
1609
  maxRequestsPerDay: config.rateLimiting?.maxRequestsPerDay || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
1358
1610
  maxRequestsPerSecond: config.rateLimiting?.maxRequestsPerSecond || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
@@ -1377,11 +1629,39 @@ class EtsyClient {
1377
1629
  stopOnError: false
1378
1630
  });
1379
1631
  }
1632
+ async acquireRateLimitSlot() {
1633
+ return this.rateLimitingEnabled
1634
+ ? this.rateLimiter.acquireRequestSlot()
1635
+ : undefined;
1636
+ }
1637
+ async fetchOnceWithRateLimit(url, options) {
1638
+ const reservationId = await this.acquireRateLimitSlot();
1639
+ let response;
1640
+ try {
1641
+ response = await this.fetch(url, options);
1642
+ }
1643
+ catch (error) {
1644
+ if (reservationId !== undefined)
1645
+ this.rateLimiter.releaseRequestSlot(reservationId);
1646
+ throw error;
1647
+ }
1648
+ if (response.status === 429) {
1649
+ await this.rateLimiter.handleRateLimitResponse(response.headers, reservationId);
1650
+ this.rateLimiter.resetRetryCount();
1651
+ }
1652
+ else {
1653
+ this.rateLimiter.updateFromHeaders(response.headers, reservationId);
1654
+ if (response.ok)
1655
+ this.rateLimiter.resetRetryCount();
1656
+ }
1657
+ return response;
1658
+ }
1380
1659
  async makeRequest(endpoint, options = {}, useCache = true) {
1381
1660
  const url = `${this.baseUrl}${endpoint}`;
1382
1661
  const requestOptions = {
1383
1662
  method: 'GET',
1384
- ...options
1663
+ ...options,
1664
+ redirect: 'error'
1385
1665
  };
1386
1666
  const cacheKey = `${url}:${JSON.stringify(requestOptions)}`;
1387
1667
  if (useCache && this.cache && requestOptions.method === 'GET') {
@@ -1395,7 +1675,6 @@ class EtsyClient {
1395
1675
  }
1396
1676
  }
1397
1677
  }
1398
- await this.rateLimiter.waitForRateLimit();
1399
1678
  const accessToken = await this.tokenManager.getAccessToken();
1400
1679
  const headers = {
1401
1680
  'Authorization': `Bearer ${accessToken}`,
@@ -1407,22 +1686,38 @@ class EtsyClient {
1407
1686
  return this.executeWithRetry(url, { ...requestOptions, headers }, cacheKey, useCache);
1408
1687
  }
1409
1688
  async executeWithRetry(url, options, cacheKey, useCache) {
1689
+ let retryAttempt = 0;
1410
1690
  while (true) {
1411
1691
  try {
1412
- const response = await this.fetch(url, options);
1692
+ const reservationId = await this.acquireRateLimitSlot();
1693
+ let response;
1694
+ try {
1695
+ response = await this.fetch(url, options);
1696
+ }
1697
+ catch (error) {
1698
+ if (reservationId !== undefined)
1699
+ this.rateLimiter.releaseRequestSlot(reservationId);
1700
+ throw error;
1701
+ }
1413
1702
  if (response.status === 429) {
1414
- const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers);
1703
+ const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers, reservationId, ++retryAttempt);
1415
1704
  if (shouldRetry) {
1705
+ const method = (options.method ?? 'GET').toUpperCase();
1706
+ if (method !== 'GET') {
1707
+ this.rateLimiter.resetRetryCount();
1708
+ const errorText = await response.text();
1709
+ throw new EtsyApiError(`Etsy API error: 429 ${response.statusText}; automatic retry skipped for ${method} requests`, 429, errorText);
1710
+ }
1416
1711
  this.logger.warn(`Rate limited. Retrying in ${delayMs}ms...`);
1417
1712
  await this.sleep(delayMs);
1418
1713
  continue;
1419
1714
  }
1420
1715
  }
1716
+ this.rateLimiter.updateFromHeaders(response.headers, reservationId);
1421
1717
  if (!response.ok) {
1422
1718
  const errorText = await response.text();
1423
1719
  throw new EtsyApiError(`Etsy API error: ${response.status} ${response.statusText}`, response.status, errorText);
1424
1720
  }
1425
- this.rateLimiter.updateFromHeaders(response.headers);
1426
1721
  this.rateLimiter.resetRetryCount();
1427
1722
  if (response.status === 204) {
1428
1723
  return undefined;
@@ -1723,11 +2018,96 @@ class EtsyClient {
1723
2018
  await this.makeRequest(`/listings/${listingId}`, { method: 'DELETE' }, false);
1724
2019
  }
1725
2020
  async updateListingInventory(listingId, params, options) {
1726
- const legacy = options?.legacy;
1727
- const query = legacy !== undefined ? `?legacy=${legacy}` : '';
2021
+ const searchParams = new URLSearchParams();
2022
+ if (options?.legacy !== undefined) {
2023
+ searchParams.set('legacy', options.legacy.toString());
2024
+ }
2025
+ if (options?.enableScaleValidation !== undefined) {
2026
+ searchParams.set('enable_scale_validation', options.enableScaleValidation.toString());
2027
+ }
2028
+ const queryString = searchParams.toString();
2029
+ const query = queryString ? `?${queryString}` : '';
2030
+ if (!Array.isArray(params.products) || params.products.length === 0) {
2031
+ throw new TypeError('Inventory update must contain at least one product.');
2032
+ }
2033
+ const body = {
2034
+ products: params.products.map((product, productIndex) => {
2035
+ if (product.is_deleted === true) {
2036
+ throw new TypeError(`Inventory product ${productIndex} is deleted and cannot be submitted for replacement.`);
2037
+ }
2038
+ if (!Array.isArray(product.offerings) || product.offerings.length === 0) {
2039
+ throw new TypeError(`Inventory product ${productIndex} must contain at least one offering.`);
2040
+ }
2041
+ if (!Array.isArray(product.property_values)) {
2042
+ throw new TypeError(`Inventory product ${productIndex} must include property_values; pass [] when it has no variation properties.`);
2043
+ }
2044
+ if (product.sku !== undefined && product.sku !== null && typeof product.sku !== 'string') {
2045
+ throw new TypeError(`Inventory product ${productIndex} sku must be a string or null.`);
2046
+ }
2047
+ return {
2048
+ ...(product.sku !== undefined ? { sku: product.sku } : {}),
2049
+ property_values: product.property_values.map((value, valueIndex) => {
2050
+ if (!Number.isInteger(value.property_id) || value.property_id < 1) {
2051
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_id must be a positive integer.`);
2052
+ }
2053
+ if (!Array.isArray(value.value_ids) || value.value_ids.some((id) => !Number.isInteger(id))) {
2054
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].value_ids must be an integer array.`);
2055
+ }
2056
+ if (!Array.isArray(value.values) || value.values.some((entry) => typeof entry !== 'string')) {
2057
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].values must be a string array.`);
2058
+ }
2059
+ if (value.property_name !== undefined && typeof value.property_name !== 'string') {
2060
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_name must be a string.`);
2061
+ }
2062
+ if (value.scale_id !== undefined && value.scale_id !== null &&
2063
+ (!Number.isInteger(value.scale_id) || value.scale_id < 1)) {
2064
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].scale_id must be a positive integer or null.`);
2065
+ }
2066
+ return {
2067
+ property_id: value.property_id,
2068
+ ...(value.property_name !== undefined ? { property_name: value.property_name } : {}),
2069
+ ...(value.scale_id !== undefined ? { scale_id: value.scale_id } : {}),
2070
+ value_ids: value.value_ids,
2071
+ values: value.values,
2072
+ };
2073
+ }),
2074
+ offerings: product.offerings.map((offering, offeringIndex) => {
2075
+ if (offering.is_deleted === true) {
2076
+ throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is deleted and cannot be submitted for replacement.`);
2077
+ }
2078
+ if (typeof offering.price !== 'number' || !Number.isFinite(offering.price)) {
2079
+ throw new TypeError('Inventory offering price must be a finite decimal number in the listing currency.');
2080
+ }
2081
+ if (!Number.isInteger(offering.quantity)) {
2082
+ throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} quantity must be an integer.`);
2083
+ }
2084
+ if (typeof offering.is_enabled !== 'boolean') {
2085
+ throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is_enabled must be a boolean.`);
2086
+ }
2087
+ if (offering.readiness_state_id === undefined ||
2088
+ (offering.readiness_state_id !== null &&
2089
+ (!Number.isInteger(offering.readiness_state_id) || offering.readiness_state_id < 1))) {
2090
+ throw new TypeError('Inventory offering readiness_state_id is required; use null when not set.');
2091
+ }
2092
+ return {
2093
+ price: offering.price,
2094
+ quantity: offering.quantity,
2095
+ is_enabled: offering.is_enabled,
2096
+ readiness_state_id: offering.readiness_state_id,
2097
+ };
2098
+ }),
2099
+ };
2100
+ }),
2101
+ ...(params.price_on_property !== undefined ? { price_on_property: params.price_on_property } : {}),
2102
+ ...(params.quantity_on_property !== undefined ? { quantity_on_property: params.quantity_on_property } : {}),
2103
+ ...(params.sku_on_property !== undefined ? { sku_on_property: params.sku_on_property } : {}),
2104
+ ...(params.readiness_state_on_property !== undefined
2105
+ ? { readiness_state_on_property: params.readiness_state_on_property }
2106
+ : {}),
2107
+ };
1728
2108
  return this.makeRequest(`/listings/${listingId}/inventory${query}`, {
1729
2109
  method: 'PUT',
1730
- body: JSON.stringify(params)
2110
+ body: JSON.stringify(body)
1731
2111
  }, false);
1732
2112
  }
1733
2113
  async uploadListingImage(shopId, listingId, imageData, params) {
@@ -1742,9 +2122,8 @@ class EtsyClient {
1742
2122
  if (params?.alt_text)
1743
2123
  formData.append('alt_text', params.alt_text);
1744
2124
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/images`;
1745
- await this.rateLimiter.waitForRateLimit();
1746
2125
  const accessToken = await this.tokenManager.getAccessToken();
1747
- const response = await this.fetch(url, {
2126
+ const response = await this.fetchOnceWithRateLimit(url, {
1748
2127
  method: 'POST',
1749
2128
  headers: {
1750
2129
  'Authorization': `Bearer ${accessToken}`,
@@ -1939,10 +2318,11 @@ class EtsyClient {
1939
2318
  }
1940
2319
  async getShopPayment(shopId, paymentId) {
1941
2320
  const payments = await this.getPayments(shopId, [Number(paymentId)]);
1942
- if (payments.length === 0) {
2321
+ const payment = payments[0];
2322
+ if (!payment) {
1943
2323
  throw new EtsyApiError('Payment not found', 404);
1944
2324
  }
1945
- return payments[0];
2325
+ return payment;
1946
2326
  }
1947
2327
  async getBuyerTaxonomyNodes() {
1948
2328
  const response = await this.makeRequest('/buyer-taxonomy/nodes');
@@ -1965,9 +2345,8 @@ class EtsyClient {
1965
2345
  body.append('scale_id', scaleId.toString());
1966
2346
  }
1967
2347
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/properties/${propertyId}`;
1968
- await this.rateLimiter.waitForRateLimit();
1969
2348
  const accessToken = await this.tokenManager.getAccessToken();
1970
- const response = await this.fetch(url, {
2349
+ const response = await this.fetchOnceWithRateLimit(url, {
1971
2350
  method: 'PUT',
1972
2351
  headers: {
1973
2352
  'Authorization': `Bearer ${accessToken}`,
@@ -1976,8 +2355,6 @@ class EtsyClient {
1976
2355
  },
1977
2356
  body: body.toString()
1978
2357
  });
1979
- this.rateLimiter.updateFromHeaders(response.headers);
1980
- this.rateLimiter.resetRetryCount();
1981
2358
  if (!response.ok) {
1982
2359
  const errorText = await response.text();
1983
2360
  throw new EtsyApiError(`Failed to update listing property: ${response.status} ${response.statusText}`, response.status, errorText);
@@ -2077,9 +2454,8 @@ class EtsyClient {
2077
2454
  if (params?.listing_file_id !== undefined)
2078
2455
  formData.append('listing_file_id', params.listing_file_id.toString());
2079
2456
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/files`;
2080
- await this.rateLimiter.waitForRateLimit();
2081
2457
  const accessToken = await this.tokenManager.getAccessToken();
2082
- const response = await this.fetch(url, {
2458
+ const response = await this.fetchOnceWithRateLimit(url, {
2083
2459
  method: 'POST',
2084
2460
  headers: {
2085
2461
  'Authorization': `Bearer ${accessToken}`,
@@ -2111,9 +2487,8 @@ class EtsyClient {
2111
2487
  if (params?.video_id !== undefined)
2112
2488
  formData.append('video_id', params.video_id.toString());
2113
2489
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/videos`;
2114
- await this.rateLimiter.waitForRateLimit();
2115
2490
  const accessToken = await this.tokenManager.getAccessToken();
2116
- const response = await this.fetch(url, {
2491
+ const response = await this.fetchOnceWithRateLimit(url, {
2117
2492
  method: 'POST',
2118
2493
  headers: {
2119
2494
  'Authorization': `Bearer ${accessToken}`,
@@ -2435,7 +2810,7 @@ async function sha256(data) {
2435
2810
  const encoder = new TextEncoder();
2436
2811
  const dataBytes = typeof data === 'string' ? encoder.encode(data) : data;
2437
2812
  if (isBrowser) {
2438
- const hashBuffer = await crypto.subtle.digest('SHA-256', dataBytes);
2813
+ const hashBuffer = await crypto.subtle.digest('SHA-256', new Uint8Array(dataBytes));
2439
2814
  return new Uint8Array(hashBuffer);
2440
2815
  }
2441
2816
  else if (isNode) {
@@ -2535,6 +2910,7 @@ class AuthHelper {
2535
2910
  assertFetchSupport();
2536
2911
  const response = await fetch('https://api.etsy.com/v3/public/oauth/token', {
2537
2912
  method: 'POST',
2913
+ redirect: 'error',
2538
2914
  headers: {
2539
2915
  'Content-Type': 'application/x-www-form-urlencoded',
2540
2916
  'Accept': 'application/json'
@@ -2627,18 +3003,17 @@ const COMMON_SCOPE_COMBINATIONS = {
2627
3003
  ]
2628
3004
  };
2629
3005
 
3006
+ const QPD_WINDOW_MS = 24 * 60 * 60 * 1000;
2630
3007
  class GlobalRequestQueue {
2631
3008
  constructor() {
2632
3009
  this.queue = [];
2633
3010
  this.processing = false;
2634
3011
  this.rateLimits = new Map();
2635
- this.requestCount = 0;
2636
- this.dailyReset = new Date();
3012
+ this.requestTimestamps = [];
2637
3013
  this.lastRequestTime = 0;
2638
3014
  this.maxRequestsPerDay = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY;
2639
3015
  this.maxRequestsPerSecond = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND;
2640
3016
  this.minRequestInterval = ETSY_RATE_LIMITS.MIN_REQUEST_INTERVAL;
2641
- this.setNextDailyReset();
2642
3017
  }
2643
3018
  static getInstance() {
2644
3019
  if (!this.instance) {
@@ -2670,11 +3045,13 @@ class GlobalRequestQueue {
2670
3045
  });
2671
3046
  }
2672
3047
  getStatus() {
3048
+ const now = Date.now();
3049
+ this.pruneRequestTimestamps(now);
2673
3050
  return {
2674
3051
  queueLength: this.queue.length,
2675
3052
  processing: this.processing,
2676
- remainingRequests: Math.max(0, this.maxRequestsPerDay - this.requestCount),
2677
- resetTime: this.dailyReset,
3053
+ remainingRequests: Math.max(0, this.maxRequestsPerDay - this.requestTimestamps.length),
3054
+ resetTime: this.getNextRequestExpiry(now),
2678
3055
  };
2679
3056
  }
2680
3057
  clear() {
@@ -2690,16 +3067,13 @@ class GlobalRequestQueue {
2690
3067
  this.processing = true;
2691
3068
  try {
2692
3069
  while (this.queue.length > 0) {
2693
- if (Date.now() >= this.dailyReset.getTime()) {
2694
- this.requestCount = 0;
2695
- this.setNextDailyReset();
2696
- }
2697
- if (this.requestCount >= this.maxRequestsPerDay) {
2698
- const timeUntilReset = this.dailyReset.getTime() - Date.now();
2699
- console.warn(`Daily rate limit reached. Waiting ${Math.ceil(timeUntilReset / 1000 / 60)} minutes until reset.`);
3070
+ const now = Date.now();
3071
+ this.pruneRequestTimestamps(now);
3072
+ if (this.requestTimestamps.length >= this.maxRequestsPerDay) {
3073
+ const timeUntilReset = Math.max(0, this.getNextRequestExpiry(now).getTime() - now);
3074
+ console.warn(`Daily rate limit reached. Waiting ${Math.ceil(timeUntilReset / 1000 / 60)} minutes for the oldest request to leave the rolling 24-hour window.`);
2700
3075
  await this.delay(timeUntilReset);
2701
- this.requestCount = 0;
2702
- this.setNextDailyReset();
3076
+ continue;
2703
3077
  }
2704
3078
  await this.waitForRateLimit();
2705
3079
  this.queue.sort((a, b) => {
@@ -2734,7 +3108,7 @@ class GlobalRequestQueue {
2734
3108
  }
2735
3109
  });
2736
3110
  try {
2737
- result = await Promise.race([item.request(), timeoutPromise]);
3111
+ result = await Promise.race([this.startRequest(item.request), timeoutPromise]);
2738
3112
  }
2739
3113
  finally {
2740
3114
  if (timeoutId) {
@@ -2743,11 +3117,9 @@ class GlobalRequestQueue {
2743
3117
  }
2744
3118
  }
2745
3119
  else {
2746
- result = await item.request();
3120
+ result = await this.startRequest(item.request);
2747
3121
  }
2748
3122
  item.resolve(result);
2749
- this.requestCount++;
2750
- this.lastRequestTime = Date.now();
2751
3123
  }
2752
3124
  catch (error) {
2753
3125
  if (error instanceof EtsyRateLimitError) {
@@ -2767,7 +3139,7 @@ class GlobalRequestQueue {
2767
3139
  const globalRateLimit = this.rateLimits.get('global');
2768
3140
  if (globalRateLimit && now < globalRateLimit.resetAt) {
2769
3141
  const waitTime = globalRateLimit.resetAt - now;
2770
- console.log(`Global rate limit active. Waiting ${waitTime}ms`);
3142
+ console.warn(`Global rate limit active. Waiting ${waitTime}ms`);
2771
3143
  await this.delay(waitTime);
2772
3144
  }
2773
3145
  const timeSinceLastRequest = now - this.lastRequestTime;
@@ -2785,11 +3157,23 @@ class GlobalRequestQueue {
2785
3157
  });
2786
3158
  }
2787
3159
  }
2788
- setNextDailyReset() {
2789
- const now = new Date();
2790
- this.dailyReset = new Date(now);
2791
- this.dailyReset.setUTCDate(this.dailyReset.getUTCDate() + 1);
2792
- this.dailyReset.setUTCHours(0, 0, 0, 0);
3160
+ pruneRequestTimestamps(now) {
3161
+ const oldestAllowed = now - QPD_WINDOW_MS;
3162
+ while (this.requestTimestamps[0] !== undefined && this.requestTimestamps[0] <= oldestAllowed) {
3163
+ this.requestTimestamps.shift();
3164
+ }
3165
+ }
3166
+ getNextRequestExpiry(now) {
3167
+ this.pruneRequestTimestamps(now);
3168
+ const oldestRequest = this.requestTimestamps[0] ?? now;
3169
+ return new Date(oldestRequest + QPD_WINDOW_MS);
3170
+ }
3171
+ startRequest(request) {
3172
+ const startedAt = Date.now();
3173
+ this.pruneRequestTimestamps(startedAt);
3174
+ this.requestTimestamps.push(startedAt);
3175
+ this.lastRequestTime = startedAt;
3176
+ return request();
2793
3177
  }
2794
3178
  generateId() {
2795
3179
  return `${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;