@profplum700/etsy-v3-api-client 3.0.0 → 4.0.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/node.esm.js CHANGED
@@ -247,6 +247,26 @@ class EtsyRateLimitError extends Error {
247
247
  return this.errorType !== 'qpd_exhausted';
248
248
  }
249
249
  }
250
+ const ETSY_WHEN_MADE_VALUES = [
251
+ '2020_2026',
252
+ '2010_2019',
253
+ '2007_2009',
254
+ 'before_2007',
255
+ '2000_2006',
256
+ '1990s',
257
+ '1980s',
258
+ '1970s',
259
+ '1960s',
260
+ '1950s',
261
+ '1940s',
262
+ '1930s',
263
+ '1920s',
264
+ '1910s',
265
+ '1900s',
266
+ '1800s',
267
+ '1700s',
268
+ 'before_1700'
269
+ ];
250
270
 
251
271
  const isBrowser$1 = typeof window !== 'undefined' && typeof window.document !== 'undefined';
252
272
  const isNode$1 = typeof process !== 'undefined' && !!process.versions?.node;
@@ -319,9 +339,12 @@ class MemoryTokenStorage {
319
339
  class TokenManager {
320
340
  constructor(config, storage, rotationConfig) {
321
341
  this.currentTokens = null;
342
+ this.tokenMutationGeneration = 0;
322
343
  this.keystring = config.keystring;
323
- this.refreshCallback = config.refreshSave;
344
+ this.refreshCallback = config.refreshSaveAsync ?? config.refreshSave;
345
+ this.refreshClearRequired = config.refreshSaveAsync !== undefined;
324
346
  this.storage = storage;
347
+ this.clearCallback = config.refreshClearAsync;
325
348
  this.rotationConfig = rotationConfig;
326
349
  this.currentTokens = {
327
350
  access_token: config.accessToken,
@@ -358,21 +381,82 @@ class TokenManager {
358
381
  if (!this.currentTokens) {
359
382
  throw new EtsyAuthError('No tokens available to refresh', 'NO_REFRESH_TOKEN');
360
383
  }
361
- this.refreshPromise = this.performTokenRefresh();
362
- try {
363
- const newTokens = await this.refreshPromise;
364
- this.currentTokens = newTokens;
365
- if (this.storage) {
366
- 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
+ }
423
+ }
424
+ catch (error) {
425
+ if (generation !== this.tokenMutationGeneration)
426
+ continue;
427
+ throw error;
367
428
  }
368
- if (this.refreshCallback) {
369
- this.refreshCallback(newTokens.access_token, newTokens.refresh_token, newTokens.expires_at);
429
+ if (generation === this.tokenMutationGeneration)
430
+ return;
431
+ }
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);
370
440
  }
371
- return newTokens;
372
441
  }
373
- finally {
374
- this.refreshPromise = undefined;
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');
375
458
  }
459
+ return this.persistRefreshedTokens(this.pendingTokens, this.tokenMutationGeneration);
376
460
  }
377
461
  async performTokenRefresh() {
378
462
  if (!this.currentTokens) {
@@ -386,6 +470,7 @@ class TokenManager {
386
470
  try {
387
471
  const response = await this.fetch('https://api.etsy.com/v3/public/oauth/token', {
388
472
  method: 'POST',
473
+ redirect: 'error',
389
474
  headers: {
390
475
  'Content-Type': 'application/x-www-form-urlencoded',
391
476
  'Accept': 'application/json'
@@ -415,7 +500,9 @@ class TokenManager {
415
500
  return this.currentTokens ? { ...this.currentTokens } : null;
416
501
  }
417
502
  updateTokens(tokens) {
503
+ this.tokenMutationGeneration += 1;
418
504
  this.currentTokens = { ...tokens };
505
+ this.pendingTokens = undefined;
419
506
  }
420
507
  isTokenExpired() {
421
508
  if (!this.currentTokens) {
@@ -435,10 +522,19 @@ class TokenManager {
435
522
  return now.getTime() >= (expiresAt.getTime() - bufferTime);
436
523
  }
437
524
  async clearTokens() {
525
+ this.tokenMutationGeneration += 1;
438
526
  this.currentTokens = null;
527
+ this.pendingTokens = undefined;
528
+ if (this.refreshPromise)
529
+ await this.refreshPromise.catch(() => undefined);
439
530
  if (this.storage) {
440
531
  await this.storage.clear();
441
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
+ }
442
538
  }
443
539
  getTimeUntilExpiration() {
444
540
  if (!this.currentTokens) {
@@ -678,13 +774,24 @@ const ETSY_RATE_LIMITS = {
678
774
  MAX_REQUESTS_PER_SECOND: 5,
679
775
  MIN_REQUEST_INTERVAL: 200,
680
776
  };
777
+ const QPD_WINDOW_MS$1 = 24 * 60 * 60 * 1000;
681
778
  class EtsyRateLimiter {
682
779
  constructor(config) {
683
- this.requestCount = 0;
684
- this.dailyReset = new Date();
780
+ this.requestTimestamps = [];
781
+ this.uncertainRequestTimestamps = [];
685
782
  this.lastRequestTime = 0;
686
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();
687
792
  this.currentRetryCount = 0;
793
+ this.reservationQueue = Promise.resolve();
794
+ const overrides = Object.fromEntries(Object.entries(config ?? {}).filter(([, value]) => value !== undefined));
688
795
  this.config = {
689
796
  maxRequestsPerDay: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
690
797
  maxRequestsPerSecond: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
@@ -695,21 +802,155 @@ class EtsyRateLimiter {
695
802
  jitter: 0.1,
696
803
  qpdWarningThreshold: 80,
697
804
  onApproachingLimit: undefined,
698
- ...config
805
+ ...overrides
699
806
  };
700
- this.setNextDailyReset();
701
807
  }
702
- setNextDailyReset() {
703
- const now = new Date();
704
- this.dailyReset = new Date(now);
705
- this.dailyReset.setUTCDate(this.dailyReset.getUTCDate() + 1);
706
- 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();
873
+ }
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
+ }
707
933
  }
708
- updateFromHeaders(headers) {
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;
709
945
  if (!headers) {
946
+ if (matchedReservationId !== undefined)
947
+ this.finishReservation(matchedReservationId, false);
710
948
  return;
711
949
  }
712
950
  const parsed = this.parseRateLimitHeaders(headers);
951
+ if (matchedReservationId !== undefined) {
952
+ this.finishReservation(matchedReservationId, parsed.remainingToday !== undefined);
953
+ }
713
954
  if (parsed.limitPerSecond !== undefined) {
714
955
  this.headerLimitPerSecond = parsed.limitPerSecond;
715
956
  this.isHeaderBasedLimiting = true;
@@ -722,9 +963,39 @@ class EtsyRateLimiter {
722
963
  this.isHeaderBasedLimiting = true;
723
964
  }
724
965
  if (parsed.remainingToday !== undefined) {
725
- 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
+ }
726
990
  this.checkApproachingLimit();
727
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();
728
999
  }
729
1000
  parseRateLimitHeaders(headers) {
730
1001
  const getHeader = (name) => {
@@ -764,18 +1035,27 @@ class EtsyRateLimiter {
764
1035
  this.config.onApproachingLimit(remaining, limit, percentageUsed);
765
1036
  }
766
1037
  }
767
- async handleRateLimitResponse(headers) {
1038
+ async handleRateLimitResponse(headers, reservationId, retryAttempt) {
768
1039
  const parsed = headers ? this.parseRateLimitHeaders(headers) : {};
769
- this.updateFromHeaders(headers);
770
- if (this.headerRemainingToday === 0) {
771
- throw new EtsyRateLimitError('Daily rate limit exhausted. No requests remaining until limit resets.', parsed.retryAfter, 'qpd_exhausted');
772
- }
773
- this.currentRetryCount++;
774
- 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
+ }
775
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;
776
1057
  throw new EtsyRateLimitError(`Max retries (${this.config.maxRetries}) exceeded for rate limit`, parsed.retryAfter, 'qps_exhausted');
777
1058
  }
778
- const delayMs = this.calculateBackoffDelay(this.currentRetryCount, parsed.retryAfter);
779
1059
  return { shouldRetry: true, delayMs };
780
1060
  }
781
1061
  calculateBackoffDelay(attempt, retryAfterSeconds) {
@@ -788,7 +1068,7 @@ class EtsyRateLimiter {
788
1068
  const randomJitter = Math.random() * jitterAmount * 2 - jitterAmount;
789
1069
  delay += randomJitter;
790
1070
  }
791
- return Math.max(0, Math.floor(delay));
1071
+ return Math.max(serverSuggestedMs, 0, Math.floor(delay));
792
1072
  }
793
1073
  resetRetryCount() {
794
1074
  this.currentRetryCount = 0;
@@ -806,41 +1086,26 @@ class EtsyRateLimiter {
806
1086
  return this.config.minRequestInterval;
807
1087
  }
808
1088
  async waitForRateLimit() {
809
- const now = Date.now();
810
- if (now >= this.dailyReset.getTime()) {
811
- this.requestCount = 0;
812
- this.setNextDailyReset();
813
- }
814
- const effectiveDailyLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
815
- const effectiveRemaining = this.headerRemainingToday ??
816
- (effectiveDailyLimit - this.requestCount);
817
- if (effectiveRemaining <= 0) {
818
- const timeUntilReset = this.dailyReset.getTime() - now;
819
- throw new EtsyRateLimitError(`Daily rate limit exhausted (${effectiveDailyLimit} requests). ` +
820
- `Reset in approximately ${Math.ceil(timeUntilReset / 1000 / 60)} minutes.`, Math.ceil(timeUntilReset / 1000), 'qpd_exhausted');
821
- }
822
- const minInterval = this.getEffectiveMinInterval();
823
- const timeSinceLastRequest = now - this.lastRequestTime;
824
- if (timeSinceLastRequest < minInterval) {
825
- const waitTime = minInterval - timeSinceLastRequest;
826
- await new Promise(resolve => setTimeout(resolve, waitTime));
827
- }
828
- this.requestCount++;
829
- this.lastRequestTime = Date.now();
1089
+ const reservationId = await this.acquireRequestSlot();
1090
+ this.finishReservation(reservationId, false);
1091
+ }
1092
+ async waitForRateLimitWithReservation() {
1093
+ return this.acquireRequestSlot();
830
1094
  }
831
1095
  getRateLimitStatus() {
832
1096
  const now = Date.now();
833
- if (now >= this.dailyReset.getTime()) {
834
- this.requestCount = 0;
835
- this.setNextDailyReset();
836
- }
837
- const effectiveRemaining = this.headerRemainingToday ??
838
- 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);
839
1101
  return {
840
1102
  remainingRequests: effectiveRemaining,
841
- resetTime: this.dailyReset,
842
- canMakeRequest: effectiveRemaining > 0 &&
843
- (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,
844
1109
  isFromHeaders: this.isHeaderBasedLimiting,
845
1110
  limitPerSecond: this.headerLimitPerSecond,
846
1111
  remainingThisSecond: this.headerRemainingThisSecond,
@@ -848,41 +1113,47 @@ class EtsyRateLimiter {
848
1113
  };
849
1114
  }
850
1115
  getRemainingRequests() {
851
- return this.headerRemainingToday ??
852
- 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);
853
1120
  }
854
1121
  reset() {
855
- this.requestCount = 0;
1122
+ this.requestTimestamps = [];
856
1123
  this.lastRequestTime = 0;
857
1124
  this.currentRetryCount = 0;
1125
+ this.quotaProbeBackoffMs = 60000;
1126
+ this.headerQuotaAvailableAt = 0;
1127
+ this.retryNotBefore = 0;
858
1128
  this.headerLimitPerSecond = undefined;
859
1129
  this.headerRemainingThisSecond = undefined;
860
1130
  this.headerLimitPerDay = undefined;
861
1131
  this.headerRemainingToday = undefined;
862
1132
  this.isHeaderBasedLimiting = false;
863
- this.setNextDailyReset();
1133
+ this.nextReservationId = 0;
1134
+ this.latestHeaderReservationId = 0;
1135
+ this.inFlightReservations.clear();
1136
+ this.uncertainRequestTimestamps = [];
1137
+ this.quotaProbeInFlight = false;
1138
+ this.notifyStateWaiters();
864
1139
  }
865
1140
  canMakeRequest() {
866
1141
  const now = Date.now();
867
- if (now >= this.dailyReset.getTime()) {
868
- this.requestCount = 0;
869
- this.setNextDailyReset();
870
- }
871
- const effectiveRemaining = this.headerRemainingToday ??
872
- (this.config.maxRequestsPerDay - this.requestCount);
873
- 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) {
874
1147
  return false;
875
1148
  }
876
- return (now - this.lastRequestTime) >= this.getEffectiveMinInterval();
1149
+ return (now - this.lastRequestTime) >= this.getEffectiveMinInterval() &&
1150
+ now >= this.retryNotBefore;
877
1151
  }
878
1152
  getTimeUntilNextRequest() {
879
1153
  const now = Date.now();
880
1154
  const timeSinceLastRequest = now - this.lastRequestTime;
881
1155
  const minInterval = this.getEffectiveMinInterval();
882
- if (timeSinceLastRequest >= minInterval) {
883
- return 0;
884
- }
885
- return minInterval - timeSinceLastRequest;
1156
+ return Math.max(0, minInterval - timeSinceLastRequest, this.retryNotBefore - now);
886
1157
  }
887
1158
  getConfig() {
888
1159
  return { ...this.config };
@@ -1084,10 +1355,10 @@ class FieldValidator {
1084
1355
  const value = data[this.field];
1085
1356
  if (value === undefined || value === null)
1086
1357
  return null;
1087
- if (typeof value !== 'number' || Number.isNaN(value)) {
1358
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
1088
1359
  return {
1089
1360
  field: this.field,
1090
- message: options.message || `${this.field} must be a number`,
1361
+ message: options.message || `${this.field} must be a finite number`,
1091
1362
  value
1092
1363
  };
1093
1364
  }
@@ -1192,28 +1463,10 @@ const CreateListingSchema = new Validator()
1192
1463
  .rule(field('title').string({ min: 1, max: 140, message: 'title must be 1-140 characters' }))
1193
1464
  .rule(field('description').string({ max: 65535, message: 'description must be less than 65535 characters' }))
1194
1465
  .rule(field('price').required())
1195
- .rule(field('price').number({ min: 0.2, max: 50000, message: 'price must be between 0.20 and 50000.00' }))
1466
+ .rule(field('price').number({ positive: true, message: 'price must be a finite positive number' }))
1196
1467
  .rule(field('who_made').enum(['i_did', 'someone_else', 'collective'], 'who_made must be one of: i_did, someone_else, collective'))
1197
- .rule(field('when_made').enum([
1198
- 'made_to_order',
1199
- '2020_2024',
1200
- '2010_2019',
1201
- '2005_2009',
1202
- '2000_2004',
1203
- '1990s',
1204
- '1980s',
1205
- '1970s',
1206
- '1960s',
1207
- '1950s',
1208
- '1940s',
1209
- '1930s',
1210
- '1920s',
1211
- '1910s',
1212
- '1900s',
1213
- '1800s',
1214
- '1700s',
1215
- 'before_1700'
1216
- ]))
1468
+ .rule(field('when_made').required())
1469
+ .rule(field('when_made').enum(['made_to_order', ...ETSY_WHEN_MADE_VALUES]))
1217
1470
  .rule(field('taxonomy_id').required())
1218
1471
  .rule(field('taxonomy_id').number({ integer: true, positive: true, message: 'taxonomy_id must be a positive integer' }));
1219
1472
  const UpdateListingSchema = new Validator()
@@ -1350,7 +1603,8 @@ class EtsyClient {
1350
1603
  this.logger = new DefaultLogger();
1351
1604
  this.keystring = config.keystring;
1352
1605
  this.sharedSecret = config.sharedSecret;
1353
- if (config.rateLimiting?.enabled !== false) {
1606
+ this.rateLimitingEnabled = config.rateLimiting?.enabled !== false;
1607
+ if (this.rateLimitingEnabled) {
1354
1608
  this.rateLimiter = new EtsyRateLimiter({
1355
1609
  maxRequestsPerDay: config.rateLimiting?.maxRequestsPerDay || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
1356
1610
  maxRequestsPerSecond: config.rateLimiting?.maxRequestsPerSecond || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
@@ -1375,11 +1629,39 @@ class EtsyClient {
1375
1629
  stopOnError: false
1376
1630
  });
1377
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
+ }
1378
1659
  async makeRequest(endpoint, options = {}, useCache = true) {
1379
1660
  const url = `${this.baseUrl}${endpoint}`;
1380
1661
  const requestOptions = {
1381
1662
  method: 'GET',
1382
- ...options
1663
+ ...options,
1664
+ redirect: 'error'
1383
1665
  };
1384
1666
  const cacheKey = `${url}:${JSON.stringify(requestOptions)}`;
1385
1667
  if (useCache && this.cache && requestOptions.method === 'GET') {
@@ -1393,7 +1675,6 @@ class EtsyClient {
1393
1675
  }
1394
1676
  }
1395
1677
  }
1396
- await this.rateLimiter.waitForRateLimit();
1397
1678
  const accessToken = await this.tokenManager.getAccessToken();
1398
1679
  const headers = {
1399
1680
  'Authorization': `Bearer ${accessToken}`,
@@ -1405,22 +1686,38 @@ class EtsyClient {
1405
1686
  return this.executeWithRetry(url, { ...requestOptions, headers }, cacheKey, useCache);
1406
1687
  }
1407
1688
  async executeWithRetry(url, options, cacheKey, useCache) {
1689
+ let retryAttempt = 0;
1408
1690
  while (true) {
1409
1691
  try {
1410
- 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
+ }
1411
1702
  if (response.status === 429) {
1412
- const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers);
1703
+ const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers, reservationId, ++retryAttempt);
1413
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
+ }
1414
1711
  this.logger.warn(`Rate limited. Retrying in ${delayMs}ms...`);
1415
1712
  await this.sleep(delayMs);
1416
1713
  continue;
1417
1714
  }
1418
1715
  }
1716
+ this.rateLimiter.updateFromHeaders(response.headers, reservationId);
1419
1717
  if (!response.ok) {
1420
1718
  const errorText = await response.text();
1421
1719
  throw new EtsyApiError(`Etsy API error: ${response.status} ${response.statusText}`, response.status, errorText);
1422
1720
  }
1423
- this.rateLimiter.updateFromHeaders(response.headers);
1424
1721
  this.rateLimiter.resetRetryCount();
1425
1722
  if (response.status === 204) {
1426
1723
  return undefined;
@@ -1723,9 +2020,87 @@ class EtsyClient {
1723
2020
  async updateListingInventory(listingId, params, options) {
1724
2021
  const legacy = options?.legacy;
1725
2022
  const query = legacy !== undefined ? `?legacy=${legacy}` : '';
2023
+ if (!Array.isArray(params.products) || params.products.length === 0) {
2024
+ throw new TypeError('Inventory update must contain at least one product.');
2025
+ }
2026
+ const body = {
2027
+ products: params.products.map((product, productIndex) => {
2028
+ if (product.is_deleted === true) {
2029
+ throw new TypeError(`Inventory product ${productIndex} is deleted and cannot be submitted for replacement.`);
2030
+ }
2031
+ if (!Array.isArray(product.offerings) || product.offerings.length === 0) {
2032
+ throw new TypeError(`Inventory product ${productIndex} must contain at least one offering.`);
2033
+ }
2034
+ if (!Array.isArray(product.property_values)) {
2035
+ throw new TypeError(`Inventory product ${productIndex} must include property_values; pass [] when it has no variation properties.`);
2036
+ }
2037
+ if (product.sku !== undefined && product.sku !== null && typeof product.sku !== 'string') {
2038
+ throw new TypeError(`Inventory product ${productIndex} sku must be a string or null.`);
2039
+ }
2040
+ return {
2041
+ ...(product.sku !== undefined ? { sku: product.sku } : {}),
2042
+ property_values: product.property_values.map((value, valueIndex) => {
2043
+ if (!Number.isInteger(value.property_id) || value.property_id < 1) {
2044
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_id must be a positive integer.`);
2045
+ }
2046
+ if (!Array.isArray(value.value_ids) || value.value_ids.some((id) => !Number.isInteger(id))) {
2047
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].value_ids must be an integer array.`);
2048
+ }
2049
+ if (!Array.isArray(value.values) || value.values.some((entry) => typeof entry !== 'string')) {
2050
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].values must be a string array.`);
2051
+ }
2052
+ if (value.property_name !== undefined && typeof value.property_name !== 'string') {
2053
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_name must be a string.`);
2054
+ }
2055
+ if (value.scale_id !== undefined && value.scale_id !== null &&
2056
+ (!Number.isInteger(value.scale_id) || value.scale_id < 1)) {
2057
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].scale_id must be a positive integer or null.`);
2058
+ }
2059
+ return {
2060
+ property_id: value.property_id,
2061
+ ...(value.property_name !== undefined ? { property_name: value.property_name } : {}),
2062
+ ...(value.scale_id !== undefined ? { scale_id: value.scale_id } : {}),
2063
+ value_ids: value.value_ids,
2064
+ values: value.values,
2065
+ };
2066
+ }),
2067
+ offerings: product.offerings.map((offering, offeringIndex) => {
2068
+ if (offering.is_deleted === true) {
2069
+ throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is deleted and cannot be submitted for replacement.`);
2070
+ }
2071
+ if (typeof offering.price !== 'number' || !Number.isFinite(offering.price)) {
2072
+ throw new TypeError('Inventory offering price must be a finite decimal number in the listing currency.');
2073
+ }
2074
+ if (!Number.isInteger(offering.quantity)) {
2075
+ throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} quantity must be an integer.`);
2076
+ }
2077
+ if (typeof offering.is_enabled !== 'boolean') {
2078
+ throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is_enabled must be a boolean.`);
2079
+ }
2080
+ if (offering.readiness_state_id === undefined ||
2081
+ (offering.readiness_state_id !== null &&
2082
+ (!Number.isInteger(offering.readiness_state_id) || offering.readiness_state_id < 1))) {
2083
+ throw new TypeError('Inventory offering readiness_state_id is required; use null when not set.');
2084
+ }
2085
+ return {
2086
+ price: offering.price,
2087
+ quantity: offering.quantity,
2088
+ is_enabled: offering.is_enabled,
2089
+ readiness_state_id: offering.readiness_state_id,
2090
+ };
2091
+ }),
2092
+ };
2093
+ }),
2094
+ ...(params.price_on_property !== undefined ? { price_on_property: params.price_on_property } : {}),
2095
+ ...(params.quantity_on_property !== undefined ? { quantity_on_property: params.quantity_on_property } : {}),
2096
+ ...(params.sku_on_property !== undefined ? { sku_on_property: params.sku_on_property } : {}),
2097
+ ...(params.readiness_state_on_property !== undefined
2098
+ ? { readiness_state_on_property: params.readiness_state_on_property }
2099
+ : {}),
2100
+ };
1726
2101
  return this.makeRequest(`/listings/${listingId}/inventory${query}`, {
1727
2102
  method: 'PUT',
1728
- body: JSON.stringify(params)
2103
+ body: JSON.stringify(body)
1729
2104
  }, false);
1730
2105
  }
1731
2106
  async uploadListingImage(shopId, listingId, imageData, params) {
@@ -1740,9 +2115,8 @@ class EtsyClient {
1740
2115
  if (params?.alt_text)
1741
2116
  formData.append('alt_text', params.alt_text);
1742
2117
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/images`;
1743
- await this.rateLimiter.waitForRateLimit();
1744
2118
  const accessToken = await this.tokenManager.getAccessToken();
1745
- const response = await this.fetch(url, {
2119
+ const response = await this.fetchOnceWithRateLimit(url, {
1746
2120
  method: 'POST',
1747
2121
  headers: {
1748
2122
  'Authorization': `Bearer ${accessToken}`,
@@ -1937,10 +2311,11 @@ class EtsyClient {
1937
2311
  }
1938
2312
  async getShopPayment(shopId, paymentId) {
1939
2313
  const payments = await this.getPayments(shopId, [Number(paymentId)]);
1940
- if (payments.length === 0) {
2314
+ const payment = payments[0];
2315
+ if (!payment) {
1941
2316
  throw new EtsyApiError('Payment not found', 404);
1942
2317
  }
1943
- return payments[0];
2318
+ return payment;
1944
2319
  }
1945
2320
  async getBuyerTaxonomyNodes() {
1946
2321
  const response = await this.makeRequest('/buyer-taxonomy/nodes');
@@ -1963,9 +2338,8 @@ class EtsyClient {
1963
2338
  body.append('scale_id', scaleId.toString());
1964
2339
  }
1965
2340
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/properties/${propertyId}`;
1966
- await this.rateLimiter.waitForRateLimit();
1967
2341
  const accessToken = await this.tokenManager.getAccessToken();
1968
- const response = await this.fetch(url, {
2342
+ const response = await this.fetchOnceWithRateLimit(url, {
1969
2343
  method: 'PUT',
1970
2344
  headers: {
1971
2345
  'Authorization': `Bearer ${accessToken}`,
@@ -1974,8 +2348,6 @@ class EtsyClient {
1974
2348
  },
1975
2349
  body: body.toString()
1976
2350
  });
1977
- this.rateLimiter.updateFromHeaders(response.headers);
1978
- this.rateLimiter.resetRetryCount();
1979
2351
  if (!response.ok) {
1980
2352
  const errorText = await response.text();
1981
2353
  throw new EtsyApiError(`Failed to update listing property: ${response.status} ${response.statusText}`, response.status, errorText);
@@ -2075,9 +2447,8 @@ class EtsyClient {
2075
2447
  if (params?.listing_file_id !== undefined)
2076
2448
  formData.append('listing_file_id', params.listing_file_id.toString());
2077
2449
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/files`;
2078
- await this.rateLimiter.waitForRateLimit();
2079
2450
  const accessToken = await this.tokenManager.getAccessToken();
2080
- const response = await this.fetch(url, {
2451
+ const response = await this.fetchOnceWithRateLimit(url, {
2081
2452
  method: 'POST',
2082
2453
  headers: {
2083
2454
  'Authorization': `Bearer ${accessToken}`,
@@ -2109,9 +2480,8 @@ class EtsyClient {
2109
2480
  if (params?.video_id !== undefined)
2110
2481
  formData.append('video_id', params.video_id.toString());
2111
2482
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/videos`;
2112
- await this.rateLimiter.waitForRateLimit();
2113
2483
  const accessToken = await this.tokenManager.getAccessToken();
2114
- const response = await this.fetch(url, {
2484
+ const response = await this.fetchOnceWithRateLimit(url, {
2115
2485
  method: 'POST',
2116
2486
  headers: {
2117
2487
  'Authorization': `Bearer ${accessToken}`,
@@ -2533,6 +2903,7 @@ class AuthHelper {
2533
2903
  assertFetchSupport();
2534
2904
  const response = await fetch('https://api.etsy.com/v3/public/oauth/token', {
2535
2905
  method: 'POST',
2906
+ redirect: 'error',
2536
2907
  headers: {
2537
2908
  'Content-Type': 'application/x-www-form-urlencoded',
2538
2909
  'Accept': 'application/json'
@@ -2625,18 +2996,17 @@ const COMMON_SCOPE_COMBINATIONS = {
2625
2996
  ]
2626
2997
  };
2627
2998
 
2999
+ const QPD_WINDOW_MS = 24 * 60 * 60 * 1000;
2628
3000
  class GlobalRequestQueue {
2629
3001
  constructor() {
2630
3002
  this.queue = [];
2631
3003
  this.processing = false;
2632
3004
  this.rateLimits = new Map();
2633
- this.requestCount = 0;
2634
- this.dailyReset = new Date();
3005
+ this.requestTimestamps = [];
2635
3006
  this.lastRequestTime = 0;
2636
3007
  this.maxRequestsPerDay = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY;
2637
3008
  this.maxRequestsPerSecond = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND;
2638
3009
  this.minRequestInterval = ETSY_RATE_LIMITS.MIN_REQUEST_INTERVAL;
2639
- this.setNextDailyReset();
2640
3010
  }
2641
3011
  static getInstance() {
2642
3012
  if (!this.instance) {
@@ -2668,11 +3038,13 @@ class GlobalRequestQueue {
2668
3038
  });
2669
3039
  }
2670
3040
  getStatus() {
3041
+ const now = Date.now();
3042
+ this.pruneRequestTimestamps(now);
2671
3043
  return {
2672
3044
  queueLength: this.queue.length,
2673
3045
  processing: this.processing,
2674
- remainingRequests: Math.max(0, this.maxRequestsPerDay - this.requestCount),
2675
- resetTime: this.dailyReset,
3046
+ remainingRequests: Math.max(0, this.maxRequestsPerDay - this.requestTimestamps.length),
3047
+ resetTime: this.getNextRequestExpiry(now),
2676
3048
  };
2677
3049
  }
2678
3050
  clear() {
@@ -2688,16 +3060,13 @@ class GlobalRequestQueue {
2688
3060
  this.processing = true;
2689
3061
  try {
2690
3062
  while (this.queue.length > 0) {
2691
- if (Date.now() >= this.dailyReset.getTime()) {
2692
- this.requestCount = 0;
2693
- this.setNextDailyReset();
2694
- }
2695
- if (this.requestCount >= this.maxRequestsPerDay) {
2696
- const timeUntilReset = this.dailyReset.getTime() - Date.now();
2697
- console.warn(`Daily rate limit reached. Waiting ${Math.ceil(timeUntilReset / 1000 / 60)} minutes until reset.`);
3063
+ const now = Date.now();
3064
+ this.pruneRequestTimestamps(now);
3065
+ if (this.requestTimestamps.length >= this.maxRequestsPerDay) {
3066
+ const timeUntilReset = Math.max(0, this.getNextRequestExpiry(now).getTime() - now);
3067
+ console.warn(`Daily rate limit reached. Waiting ${Math.ceil(timeUntilReset / 1000 / 60)} minutes for the oldest request to leave the rolling 24-hour window.`);
2698
3068
  await this.delay(timeUntilReset);
2699
- this.requestCount = 0;
2700
- this.setNextDailyReset();
3069
+ continue;
2701
3070
  }
2702
3071
  await this.waitForRateLimit();
2703
3072
  this.queue.sort((a, b) => {
@@ -2732,7 +3101,7 @@ class GlobalRequestQueue {
2732
3101
  }
2733
3102
  });
2734
3103
  try {
2735
- result = await Promise.race([item.request(), timeoutPromise]);
3104
+ result = await Promise.race([this.startRequest(item.request), timeoutPromise]);
2736
3105
  }
2737
3106
  finally {
2738
3107
  if (timeoutId) {
@@ -2741,11 +3110,9 @@ class GlobalRequestQueue {
2741
3110
  }
2742
3111
  }
2743
3112
  else {
2744
- result = await item.request();
3113
+ result = await this.startRequest(item.request);
2745
3114
  }
2746
3115
  item.resolve(result);
2747
- this.requestCount++;
2748
- this.lastRequestTime = Date.now();
2749
3116
  }
2750
3117
  catch (error) {
2751
3118
  if (error instanceof EtsyRateLimitError) {
@@ -2765,7 +3132,7 @@ class GlobalRequestQueue {
2765
3132
  const globalRateLimit = this.rateLimits.get('global');
2766
3133
  if (globalRateLimit && now < globalRateLimit.resetAt) {
2767
3134
  const waitTime = globalRateLimit.resetAt - now;
2768
- console.log(`Global rate limit active. Waiting ${waitTime}ms`);
3135
+ console.warn(`Global rate limit active. Waiting ${waitTime}ms`);
2769
3136
  await this.delay(waitTime);
2770
3137
  }
2771
3138
  const timeSinceLastRequest = now - this.lastRequestTime;
@@ -2783,11 +3150,23 @@ class GlobalRequestQueue {
2783
3150
  });
2784
3151
  }
2785
3152
  }
2786
- setNextDailyReset() {
2787
- const now = new Date();
2788
- this.dailyReset = new Date(now);
2789
- this.dailyReset.setUTCDate(this.dailyReset.getUTCDate() + 1);
2790
- this.dailyReset.setUTCHours(0, 0, 0, 0);
3153
+ pruneRequestTimestamps(now) {
3154
+ const oldestAllowed = now - QPD_WINDOW_MS;
3155
+ while (this.requestTimestamps[0] !== undefined && this.requestTimestamps[0] <= oldestAllowed) {
3156
+ this.requestTimestamps.shift();
3157
+ }
3158
+ }
3159
+ getNextRequestExpiry(now) {
3160
+ this.pruneRequestTimestamps(now);
3161
+ const oldestRequest = this.requestTimestamps[0] ?? now;
3162
+ return new Date(oldestRequest + QPD_WINDOW_MS);
3163
+ }
3164
+ startRequest(request) {
3165
+ const startedAt = Date.now();
3166
+ this.pruneRequestTimestamps(startedAt);
3167
+ this.requestTimestamps.push(startedAt);
3168
+ this.lastRequestTime = startedAt;
3169
+ return request();
2791
3170
  }
2792
3171
  generateId() {
2793
3172
  return `${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
@@ -4298,7 +4677,7 @@ function createTokenManager(config, storage) {
4298
4677
  function createRateLimiter(config) {
4299
4678
  return new EtsyRateLimiter(config);
4300
4679
  }
4301
- const VERSION = '2.2.0';
4680
+ const VERSION = '3.0.1';
4302
4681
  const LIBRARY_NAME = 'etsy-v3-api-client';
4303
4682
  function getLibraryInfo() {
4304
4683
  return {