@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/index.cjs CHANGED
@@ -251,6 +251,26 @@ class EtsyRateLimitError extends Error {
251
251
  return this.errorType !== 'qpd_exhausted';
252
252
  }
253
253
  }
254
+ const ETSY_WHEN_MADE_VALUES = [
255
+ '2020_2026',
256
+ '2010_2019',
257
+ '2007_2009',
258
+ 'before_2007',
259
+ '2000_2006',
260
+ '1990s',
261
+ '1980s',
262
+ '1970s',
263
+ '1960s',
264
+ '1950s',
265
+ '1940s',
266
+ '1930s',
267
+ '1920s',
268
+ '1910s',
269
+ '1900s',
270
+ '1800s',
271
+ '1700s',
272
+ 'before_1700'
273
+ ];
254
274
 
255
275
  const isBrowser$1 = typeof window !== 'undefined' && typeof window.document !== 'undefined';
256
276
  const isNode$1 = typeof process !== 'undefined' && !!process.versions?.node;
@@ -323,9 +343,12 @@ class MemoryTokenStorage {
323
343
  class TokenManager {
324
344
  constructor(config, storage, rotationConfig) {
325
345
  this.currentTokens = null;
346
+ this.tokenMutationGeneration = 0;
326
347
  this.keystring = config.keystring;
327
- this.refreshCallback = config.refreshSave;
348
+ this.refreshCallback = config.refreshSaveAsync ?? config.refreshSave;
349
+ this.refreshClearRequired = config.refreshSaveAsync !== undefined;
328
350
  this.storage = storage;
351
+ this.clearCallback = config.refreshClearAsync;
329
352
  this.rotationConfig = rotationConfig;
330
353
  this.currentTokens = {
331
354
  access_token: config.accessToken,
@@ -362,21 +385,82 @@ class TokenManager {
362
385
  if (!this.currentTokens) {
363
386
  throw new EtsyAuthError('No tokens available to refresh', 'NO_REFRESH_TOKEN');
364
387
  }
365
- this.refreshPromise = this.performTokenRefresh();
366
- try {
367
- const newTokens = await this.refreshPromise;
368
- this.currentTokens = newTokens;
369
- if (this.storage) {
370
- 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
+ }
427
+ }
428
+ catch (error) {
429
+ if (generation !== this.tokenMutationGeneration)
430
+ continue;
431
+ throw error;
371
432
  }
372
- if (this.refreshCallback) {
373
- this.refreshCallback(newTokens.access_token, newTokens.refresh_token, newTokens.expires_at);
433
+ if (generation === this.tokenMutationGeneration)
434
+ return;
435
+ }
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);
374
444
  }
375
- return newTokens;
376
445
  }
377
- finally {
378
- this.refreshPromise = undefined;
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');
379
462
  }
463
+ return this.persistRefreshedTokens(this.pendingTokens, this.tokenMutationGeneration);
380
464
  }
381
465
  async performTokenRefresh() {
382
466
  if (!this.currentTokens) {
@@ -390,6 +474,7 @@ class TokenManager {
390
474
  try {
391
475
  const response = await this.fetch('https://api.etsy.com/v3/public/oauth/token', {
392
476
  method: 'POST',
477
+ redirect: 'error',
393
478
  headers: {
394
479
  'Content-Type': 'application/x-www-form-urlencoded',
395
480
  'Accept': 'application/json'
@@ -419,7 +504,9 @@ class TokenManager {
419
504
  return this.currentTokens ? { ...this.currentTokens } : null;
420
505
  }
421
506
  updateTokens(tokens) {
507
+ this.tokenMutationGeneration += 1;
422
508
  this.currentTokens = { ...tokens };
509
+ this.pendingTokens = undefined;
423
510
  }
424
511
  isTokenExpired() {
425
512
  if (!this.currentTokens) {
@@ -439,10 +526,19 @@ class TokenManager {
439
526
  return now.getTime() >= (expiresAt.getTime() - bufferTime);
440
527
  }
441
528
  async clearTokens() {
529
+ this.tokenMutationGeneration += 1;
442
530
  this.currentTokens = null;
531
+ this.pendingTokens = undefined;
532
+ if (this.refreshPromise)
533
+ await this.refreshPromise.catch(() => undefined);
443
534
  if (this.storage) {
444
535
  await this.storage.clear();
445
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
+ }
446
542
  }
447
543
  getTimeUntilExpiration() {
448
544
  if (!this.currentTokens) {
@@ -682,13 +778,24 @@ const ETSY_RATE_LIMITS = {
682
778
  MAX_REQUESTS_PER_SECOND: 5,
683
779
  MIN_REQUEST_INTERVAL: 200,
684
780
  };
781
+ const QPD_WINDOW_MS$1 = 24 * 60 * 60 * 1000;
685
782
  class EtsyRateLimiter {
686
783
  constructor(config) {
687
- this.requestCount = 0;
688
- this.dailyReset = new Date();
784
+ this.requestTimestamps = [];
785
+ this.uncertainRequestTimestamps = [];
689
786
  this.lastRequestTime = 0;
690
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();
691
796
  this.currentRetryCount = 0;
797
+ this.reservationQueue = Promise.resolve();
798
+ const overrides = Object.fromEntries(Object.entries(config ?? {}).filter(([, value]) => value !== undefined));
692
799
  this.config = {
693
800
  maxRequestsPerDay: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
694
801
  maxRequestsPerSecond: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
@@ -699,21 +806,155 @@ class EtsyRateLimiter {
699
806
  jitter: 0.1,
700
807
  qpdWarningThreshold: 80,
701
808
  onApproachingLimit: undefined,
702
- ...config
809
+ ...overrides
703
810
  };
704
- this.setNextDailyReset();
705
811
  }
706
- setNextDailyReset() {
707
- const now = new Date();
708
- this.dailyReset = new Date(now);
709
- this.dailyReset.setUTCDate(this.dailyReset.getUTCDate() + 1);
710
- 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();
877
+ }
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
+ }
711
937
  }
712
- updateFromHeaders(headers) {
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;
713
949
  if (!headers) {
950
+ if (matchedReservationId !== undefined)
951
+ this.finishReservation(matchedReservationId, false);
714
952
  return;
715
953
  }
716
954
  const parsed = this.parseRateLimitHeaders(headers);
955
+ if (matchedReservationId !== undefined) {
956
+ this.finishReservation(matchedReservationId, parsed.remainingToday !== undefined);
957
+ }
717
958
  if (parsed.limitPerSecond !== undefined) {
718
959
  this.headerLimitPerSecond = parsed.limitPerSecond;
719
960
  this.isHeaderBasedLimiting = true;
@@ -726,9 +967,39 @@ class EtsyRateLimiter {
726
967
  this.isHeaderBasedLimiting = true;
727
968
  }
728
969
  if (parsed.remainingToday !== undefined) {
729
- 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
+ }
730
994
  this.checkApproachingLimit();
731
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();
732
1003
  }
733
1004
  parseRateLimitHeaders(headers) {
734
1005
  const getHeader = (name) => {
@@ -768,18 +1039,27 @@ class EtsyRateLimiter {
768
1039
  this.config.onApproachingLimit(remaining, limit, percentageUsed);
769
1040
  }
770
1041
  }
771
- async handleRateLimitResponse(headers) {
1042
+ async handleRateLimitResponse(headers, reservationId, retryAttempt) {
772
1043
  const parsed = headers ? this.parseRateLimitHeaders(headers) : {};
773
- this.updateFromHeaders(headers);
774
- if (this.headerRemainingToday === 0) {
775
- throw new EtsyRateLimitError('Daily rate limit exhausted. No requests remaining until limit resets.', parsed.retryAfter, 'qpd_exhausted');
776
- }
777
- this.currentRetryCount++;
778
- 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
+ }
779
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;
780
1061
  throw new EtsyRateLimitError(`Max retries (${this.config.maxRetries}) exceeded for rate limit`, parsed.retryAfter, 'qps_exhausted');
781
1062
  }
782
- const delayMs = this.calculateBackoffDelay(this.currentRetryCount, parsed.retryAfter);
783
1063
  return { shouldRetry: true, delayMs };
784
1064
  }
785
1065
  calculateBackoffDelay(attempt, retryAfterSeconds) {
@@ -792,7 +1072,7 @@ class EtsyRateLimiter {
792
1072
  const randomJitter = Math.random() * jitterAmount * 2 - jitterAmount;
793
1073
  delay += randomJitter;
794
1074
  }
795
- return Math.max(0, Math.floor(delay));
1075
+ return Math.max(serverSuggestedMs, 0, Math.floor(delay));
796
1076
  }
797
1077
  resetRetryCount() {
798
1078
  this.currentRetryCount = 0;
@@ -810,41 +1090,26 @@ class EtsyRateLimiter {
810
1090
  return this.config.minRequestInterval;
811
1091
  }
812
1092
  async waitForRateLimit() {
813
- const now = Date.now();
814
- if (now >= this.dailyReset.getTime()) {
815
- this.requestCount = 0;
816
- this.setNextDailyReset();
817
- }
818
- const effectiveDailyLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
819
- const effectiveRemaining = this.headerRemainingToday ??
820
- (effectiveDailyLimit - this.requestCount);
821
- if (effectiveRemaining <= 0) {
822
- const timeUntilReset = this.dailyReset.getTime() - now;
823
- throw new EtsyRateLimitError(`Daily rate limit exhausted (${effectiveDailyLimit} requests). ` +
824
- `Reset in approximately ${Math.ceil(timeUntilReset / 1000 / 60)} minutes.`, Math.ceil(timeUntilReset / 1000), 'qpd_exhausted');
825
- }
826
- const minInterval = this.getEffectiveMinInterval();
827
- const timeSinceLastRequest = now - this.lastRequestTime;
828
- if (timeSinceLastRequest < minInterval) {
829
- const waitTime = minInterval - timeSinceLastRequest;
830
- await new Promise(resolve => setTimeout(resolve, waitTime));
831
- }
832
- this.requestCount++;
833
- this.lastRequestTime = Date.now();
1093
+ const reservationId = await this.acquireRequestSlot();
1094
+ this.finishReservation(reservationId, false);
1095
+ }
1096
+ async waitForRateLimitWithReservation() {
1097
+ return this.acquireRequestSlot();
834
1098
  }
835
1099
  getRateLimitStatus() {
836
1100
  const now = Date.now();
837
- if (now >= this.dailyReset.getTime()) {
838
- this.requestCount = 0;
839
- this.setNextDailyReset();
840
- }
841
- const effectiveRemaining = this.headerRemainingToday ??
842
- 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);
843
1105
  return {
844
1106
  remainingRequests: effectiveRemaining,
845
- resetTime: this.dailyReset,
846
- canMakeRequest: effectiveRemaining > 0 &&
847
- (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,
848
1113
  isFromHeaders: this.isHeaderBasedLimiting,
849
1114
  limitPerSecond: this.headerLimitPerSecond,
850
1115
  remainingThisSecond: this.headerRemainingThisSecond,
@@ -852,41 +1117,47 @@ class EtsyRateLimiter {
852
1117
  };
853
1118
  }
854
1119
  getRemainingRequests() {
855
- return this.headerRemainingToday ??
856
- 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);
857
1124
  }
858
1125
  reset() {
859
- this.requestCount = 0;
1126
+ this.requestTimestamps = [];
860
1127
  this.lastRequestTime = 0;
861
1128
  this.currentRetryCount = 0;
1129
+ this.quotaProbeBackoffMs = 60000;
1130
+ this.headerQuotaAvailableAt = 0;
1131
+ this.retryNotBefore = 0;
862
1132
  this.headerLimitPerSecond = undefined;
863
1133
  this.headerRemainingThisSecond = undefined;
864
1134
  this.headerLimitPerDay = undefined;
865
1135
  this.headerRemainingToday = undefined;
866
1136
  this.isHeaderBasedLimiting = false;
867
- this.setNextDailyReset();
1137
+ this.nextReservationId = 0;
1138
+ this.latestHeaderReservationId = 0;
1139
+ this.inFlightReservations.clear();
1140
+ this.uncertainRequestTimestamps = [];
1141
+ this.quotaProbeInFlight = false;
1142
+ this.notifyStateWaiters();
868
1143
  }
869
1144
  canMakeRequest() {
870
1145
  const now = Date.now();
871
- if (now >= this.dailyReset.getTime()) {
872
- this.requestCount = 0;
873
- this.setNextDailyReset();
874
- }
875
- const effectiveRemaining = this.headerRemainingToday ??
876
- (this.config.maxRequestsPerDay - this.requestCount);
877
- 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) {
878
1151
  return false;
879
1152
  }
880
- return (now - this.lastRequestTime) >= this.getEffectiveMinInterval();
1153
+ return (now - this.lastRequestTime) >= this.getEffectiveMinInterval() &&
1154
+ now >= this.retryNotBefore;
881
1155
  }
882
1156
  getTimeUntilNextRequest() {
883
1157
  const now = Date.now();
884
1158
  const timeSinceLastRequest = now - this.lastRequestTime;
885
1159
  const minInterval = this.getEffectiveMinInterval();
886
- if (timeSinceLastRequest >= minInterval) {
887
- return 0;
888
- }
889
- return minInterval - timeSinceLastRequest;
1160
+ return Math.max(0, minInterval - timeSinceLastRequest, this.retryNotBefore - now);
890
1161
  }
891
1162
  getConfig() {
892
1163
  return { ...this.config };
@@ -1088,10 +1359,10 @@ class FieldValidator {
1088
1359
  const value = data[this.field];
1089
1360
  if (value === undefined || value === null)
1090
1361
  return null;
1091
- if (typeof value !== 'number' || Number.isNaN(value)) {
1362
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
1092
1363
  return {
1093
1364
  field: this.field,
1094
- message: options.message || `${this.field} must be a number`,
1365
+ message: options.message || `${this.field} must be a finite number`,
1095
1366
  value
1096
1367
  };
1097
1368
  }
@@ -1196,28 +1467,10 @@ const CreateListingSchema = new Validator()
1196
1467
  .rule(field('title').string({ min: 1, max: 140, message: 'title must be 1-140 characters' }))
1197
1468
  .rule(field('description').string({ max: 65535, message: 'description must be less than 65535 characters' }))
1198
1469
  .rule(field('price').required())
1199
- .rule(field('price').number({ min: 0.2, max: 50000, message: 'price must be between 0.20 and 50000.00' }))
1470
+ .rule(field('price').number({ positive: true, message: 'price must be a finite positive number' }))
1200
1471
  .rule(field('who_made').enum(['i_did', 'someone_else', 'collective'], 'who_made must be one of: i_did, someone_else, collective'))
1201
- .rule(field('when_made').enum([
1202
- 'made_to_order',
1203
- '2020_2024',
1204
- '2010_2019',
1205
- '2005_2009',
1206
- '2000_2004',
1207
- '1990s',
1208
- '1980s',
1209
- '1970s',
1210
- '1960s',
1211
- '1950s',
1212
- '1940s',
1213
- '1930s',
1214
- '1920s',
1215
- '1910s',
1216
- '1900s',
1217
- '1800s',
1218
- '1700s',
1219
- 'before_1700'
1220
- ]))
1472
+ .rule(field('when_made').required())
1473
+ .rule(field('when_made').enum(['made_to_order', ...ETSY_WHEN_MADE_VALUES]))
1221
1474
  .rule(field('taxonomy_id').required())
1222
1475
  .rule(field('taxonomy_id').number({ integer: true, positive: true, message: 'taxonomy_id must be a positive integer' }));
1223
1476
  const UpdateListingSchema = new Validator()
@@ -1354,7 +1607,8 @@ class EtsyClient {
1354
1607
  this.logger = new DefaultLogger();
1355
1608
  this.keystring = config.keystring;
1356
1609
  this.sharedSecret = config.sharedSecret;
1357
- if (config.rateLimiting?.enabled !== false) {
1610
+ this.rateLimitingEnabled = config.rateLimiting?.enabled !== false;
1611
+ if (this.rateLimitingEnabled) {
1358
1612
  this.rateLimiter = new EtsyRateLimiter({
1359
1613
  maxRequestsPerDay: config.rateLimiting?.maxRequestsPerDay || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
1360
1614
  maxRequestsPerSecond: config.rateLimiting?.maxRequestsPerSecond || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
@@ -1379,11 +1633,39 @@ class EtsyClient {
1379
1633
  stopOnError: false
1380
1634
  });
1381
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
+ }
1382
1663
  async makeRequest(endpoint, options = {}, useCache = true) {
1383
1664
  const url = `${this.baseUrl}${endpoint}`;
1384
1665
  const requestOptions = {
1385
1666
  method: 'GET',
1386
- ...options
1667
+ ...options,
1668
+ redirect: 'error'
1387
1669
  };
1388
1670
  const cacheKey = `${url}:${JSON.stringify(requestOptions)}`;
1389
1671
  if (useCache && this.cache && requestOptions.method === 'GET') {
@@ -1397,7 +1679,6 @@ class EtsyClient {
1397
1679
  }
1398
1680
  }
1399
1681
  }
1400
- await this.rateLimiter.waitForRateLimit();
1401
1682
  const accessToken = await this.tokenManager.getAccessToken();
1402
1683
  const headers = {
1403
1684
  'Authorization': `Bearer ${accessToken}`,
@@ -1409,22 +1690,38 @@ class EtsyClient {
1409
1690
  return this.executeWithRetry(url, { ...requestOptions, headers }, cacheKey, useCache);
1410
1691
  }
1411
1692
  async executeWithRetry(url, options, cacheKey, useCache) {
1693
+ let retryAttempt = 0;
1412
1694
  while (true) {
1413
1695
  try {
1414
- 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
+ }
1415
1706
  if (response.status === 429) {
1416
- const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers);
1707
+ const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers, reservationId, ++retryAttempt);
1417
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
+ }
1418
1715
  this.logger.warn(`Rate limited. Retrying in ${delayMs}ms...`);
1419
1716
  await this.sleep(delayMs);
1420
1717
  continue;
1421
1718
  }
1422
1719
  }
1720
+ this.rateLimiter.updateFromHeaders(response.headers, reservationId);
1423
1721
  if (!response.ok) {
1424
1722
  const errorText = await response.text();
1425
1723
  throw new EtsyApiError(`Etsy API error: ${response.status} ${response.statusText}`, response.status, errorText);
1426
1724
  }
1427
- this.rateLimiter.updateFromHeaders(response.headers);
1428
1725
  this.rateLimiter.resetRetryCount();
1429
1726
  if (response.status === 204) {
1430
1727
  return undefined;
@@ -1727,9 +2024,87 @@ class EtsyClient {
1727
2024
  async updateListingInventory(listingId, params, options) {
1728
2025
  const legacy = options?.legacy;
1729
2026
  const query = legacy !== undefined ? `?legacy=${legacy}` : '';
2027
+ if (!Array.isArray(params.products) || params.products.length === 0) {
2028
+ throw new TypeError('Inventory update must contain at least one product.');
2029
+ }
2030
+ const body = {
2031
+ products: params.products.map((product, productIndex) => {
2032
+ if (product.is_deleted === true) {
2033
+ throw new TypeError(`Inventory product ${productIndex} is deleted and cannot be submitted for replacement.`);
2034
+ }
2035
+ if (!Array.isArray(product.offerings) || product.offerings.length === 0) {
2036
+ throw new TypeError(`Inventory product ${productIndex} must contain at least one offering.`);
2037
+ }
2038
+ if (!Array.isArray(product.property_values)) {
2039
+ throw new TypeError(`Inventory product ${productIndex} must include property_values; pass [] when it has no variation properties.`);
2040
+ }
2041
+ if (product.sku !== undefined && product.sku !== null && typeof product.sku !== 'string') {
2042
+ throw new TypeError(`Inventory product ${productIndex} sku must be a string or null.`);
2043
+ }
2044
+ return {
2045
+ ...(product.sku !== undefined ? { sku: product.sku } : {}),
2046
+ property_values: product.property_values.map((value, valueIndex) => {
2047
+ if (!Number.isInteger(value.property_id) || value.property_id < 1) {
2048
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_id must be a positive integer.`);
2049
+ }
2050
+ if (!Array.isArray(value.value_ids) || value.value_ids.some((id) => !Number.isInteger(id))) {
2051
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].value_ids must be an integer array.`);
2052
+ }
2053
+ if (!Array.isArray(value.values) || value.values.some((entry) => typeof entry !== 'string')) {
2054
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].values must be a string array.`);
2055
+ }
2056
+ if (value.property_name !== undefined && typeof value.property_name !== 'string') {
2057
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_name must be a string.`);
2058
+ }
2059
+ if (value.scale_id !== undefined && value.scale_id !== null &&
2060
+ (!Number.isInteger(value.scale_id) || value.scale_id < 1)) {
2061
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].scale_id must be a positive integer or null.`);
2062
+ }
2063
+ return {
2064
+ property_id: value.property_id,
2065
+ ...(value.property_name !== undefined ? { property_name: value.property_name } : {}),
2066
+ ...(value.scale_id !== undefined ? { scale_id: value.scale_id } : {}),
2067
+ value_ids: value.value_ids,
2068
+ values: value.values,
2069
+ };
2070
+ }),
2071
+ offerings: product.offerings.map((offering, offeringIndex) => {
2072
+ if (offering.is_deleted === true) {
2073
+ throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is deleted and cannot be submitted for replacement.`);
2074
+ }
2075
+ if (typeof offering.price !== 'number' || !Number.isFinite(offering.price)) {
2076
+ throw new TypeError('Inventory offering price must be a finite decimal number in the listing currency.');
2077
+ }
2078
+ if (!Number.isInteger(offering.quantity)) {
2079
+ throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} quantity must be an integer.`);
2080
+ }
2081
+ if (typeof offering.is_enabled !== 'boolean') {
2082
+ throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is_enabled must be a boolean.`);
2083
+ }
2084
+ if (offering.readiness_state_id === undefined ||
2085
+ (offering.readiness_state_id !== null &&
2086
+ (!Number.isInteger(offering.readiness_state_id) || offering.readiness_state_id < 1))) {
2087
+ throw new TypeError('Inventory offering readiness_state_id is required; use null when not set.');
2088
+ }
2089
+ return {
2090
+ price: offering.price,
2091
+ quantity: offering.quantity,
2092
+ is_enabled: offering.is_enabled,
2093
+ readiness_state_id: offering.readiness_state_id,
2094
+ };
2095
+ }),
2096
+ };
2097
+ }),
2098
+ ...(params.price_on_property !== undefined ? { price_on_property: params.price_on_property } : {}),
2099
+ ...(params.quantity_on_property !== undefined ? { quantity_on_property: params.quantity_on_property } : {}),
2100
+ ...(params.sku_on_property !== undefined ? { sku_on_property: params.sku_on_property } : {}),
2101
+ ...(params.readiness_state_on_property !== undefined
2102
+ ? { readiness_state_on_property: params.readiness_state_on_property }
2103
+ : {}),
2104
+ };
1730
2105
  return this.makeRequest(`/listings/${listingId}/inventory${query}`, {
1731
2106
  method: 'PUT',
1732
- body: JSON.stringify(params)
2107
+ body: JSON.stringify(body)
1733
2108
  }, false);
1734
2109
  }
1735
2110
  async uploadListingImage(shopId, listingId, imageData, params) {
@@ -1744,9 +2119,8 @@ class EtsyClient {
1744
2119
  if (params?.alt_text)
1745
2120
  formData.append('alt_text', params.alt_text);
1746
2121
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/images`;
1747
- await this.rateLimiter.waitForRateLimit();
1748
2122
  const accessToken = await this.tokenManager.getAccessToken();
1749
- const response = await this.fetch(url, {
2123
+ const response = await this.fetchOnceWithRateLimit(url, {
1750
2124
  method: 'POST',
1751
2125
  headers: {
1752
2126
  'Authorization': `Bearer ${accessToken}`,
@@ -1941,10 +2315,11 @@ class EtsyClient {
1941
2315
  }
1942
2316
  async getShopPayment(shopId, paymentId) {
1943
2317
  const payments = await this.getPayments(shopId, [Number(paymentId)]);
1944
- if (payments.length === 0) {
2318
+ const payment = payments[0];
2319
+ if (!payment) {
1945
2320
  throw new EtsyApiError('Payment not found', 404);
1946
2321
  }
1947
- return payments[0];
2322
+ return payment;
1948
2323
  }
1949
2324
  async getBuyerTaxonomyNodes() {
1950
2325
  const response = await this.makeRequest('/buyer-taxonomy/nodes');
@@ -1967,9 +2342,8 @@ class EtsyClient {
1967
2342
  body.append('scale_id', scaleId.toString());
1968
2343
  }
1969
2344
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/properties/${propertyId}`;
1970
- await this.rateLimiter.waitForRateLimit();
1971
2345
  const accessToken = await this.tokenManager.getAccessToken();
1972
- const response = await this.fetch(url, {
2346
+ const response = await this.fetchOnceWithRateLimit(url, {
1973
2347
  method: 'PUT',
1974
2348
  headers: {
1975
2349
  'Authorization': `Bearer ${accessToken}`,
@@ -1978,8 +2352,6 @@ class EtsyClient {
1978
2352
  },
1979
2353
  body: body.toString()
1980
2354
  });
1981
- this.rateLimiter.updateFromHeaders(response.headers);
1982
- this.rateLimiter.resetRetryCount();
1983
2355
  if (!response.ok) {
1984
2356
  const errorText = await response.text();
1985
2357
  throw new EtsyApiError(`Failed to update listing property: ${response.status} ${response.statusText}`, response.status, errorText);
@@ -2079,9 +2451,8 @@ class EtsyClient {
2079
2451
  if (params?.listing_file_id !== undefined)
2080
2452
  formData.append('listing_file_id', params.listing_file_id.toString());
2081
2453
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/files`;
2082
- await this.rateLimiter.waitForRateLimit();
2083
2454
  const accessToken = await this.tokenManager.getAccessToken();
2084
- const response = await this.fetch(url, {
2455
+ const response = await this.fetchOnceWithRateLimit(url, {
2085
2456
  method: 'POST',
2086
2457
  headers: {
2087
2458
  'Authorization': `Bearer ${accessToken}`,
@@ -2113,9 +2484,8 @@ class EtsyClient {
2113
2484
  if (params?.video_id !== undefined)
2114
2485
  formData.append('video_id', params.video_id.toString());
2115
2486
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/videos`;
2116
- await this.rateLimiter.waitForRateLimit();
2117
2487
  const accessToken = await this.tokenManager.getAccessToken();
2118
- const response = await this.fetch(url, {
2488
+ const response = await this.fetchOnceWithRateLimit(url, {
2119
2489
  method: 'POST',
2120
2490
  headers: {
2121
2491
  'Authorization': `Bearer ${accessToken}`,
@@ -2537,6 +2907,7 @@ class AuthHelper {
2537
2907
  assertFetchSupport();
2538
2908
  const response = await fetch('https://api.etsy.com/v3/public/oauth/token', {
2539
2909
  method: 'POST',
2910
+ redirect: 'error',
2540
2911
  headers: {
2541
2912
  'Content-Type': 'application/x-www-form-urlencoded',
2542
2913
  'Accept': 'application/json'
@@ -2629,18 +3000,17 @@ const COMMON_SCOPE_COMBINATIONS = {
2629
3000
  ]
2630
3001
  };
2631
3002
 
3003
+ const QPD_WINDOW_MS = 24 * 60 * 60 * 1000;
2632
3004
  class GlobalRequestQueue {
2633
3005
  constructor() {
2634
3006
  this.queue = [];
2635
3007
  this.processing = false;
2636
3008
  this.rateLimits = new Map();
2637
- this.requestCount = 0;
2638
- this.dailyReset = new Date();
3009
+ this.requestTimestamps = [];
2639
3010
  this.lastRequestTime = 0;
2640
3011
  this.maxRequestsPerDay = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY;
2641
3012
  this.maxRequestsPerSecond = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND;
2642
3013
  this.minRequestInterval = ETSY_RATE_LIMITS.MIN_REQUEST_INTERVAL;
2643
- this.setNextDailyReset();
2644
3014
  }
2645
3015
  static getInstance() {
2646
3016
  if (!this.instance) {
@@ -2672,11 +3042,13 @@ class GlobalRequestQueue {
2672
3042
  });
2673
3043
  }
2674
3044
  getStatus() {
3045
+ const now = Date.now();
3046
+ this.pruneRequestTimestamps(now);
2675
3047
  return {
2676
3048
  queueLength: this.queue.length,
2677
3049
  processing: this.processing,
2678
- remainingRequests: Math.max(0, this.maxRequestsPerDay - this.requestCount),
2679
- resetTime: this.dailyReset,
3050
+ remainingRequests: Math.max(0, this.maxRequestsPerDay - this.requestTimestamps.length),
3051
+ resetTime: this.getNextRequestExpiry(now),
2680
3052
  };
2681
3053
  }
2682
3054
  clear() {
@@ -2692,16 +3064,13 @@ class GlobalRequestQueue {
2692
3064
  this.processing = true;
2693
3065
  try {
2694
3066
  while (this.queue.length > 0) {
2695
- if (Date.now() >= this.dailyReset.getTime()) {
2696
- this.requestCount = 0;
2697
- this.setNextDailyReset();
2698
- }
2699
- if (this.requestCount >= this.maxRequestsPerDay) {
2700
- const timeUntilReset = this.dailyReset.getTime() - Date.now();
2701
- console.warn(`Daily rate limit reached. Waiting ${Math.ceil(timeUntilReset / 1000 / 60)} minutes until reset.`);
3067
+ const now = Date.now();
3068
+ this.pruneRequestTimestamps(now);
3069
+ if (this.requestTimestamps.length >= this.maxRequestsPerDay) {
3070
+ const timeUntilReset = Math.max(0, this.getNextRequestExpiry(now).getTime() - now);
3071
+ console.warn(`Daily rate limit reached. Waiting ${Math.ceil(timeUntilReset / 1000 / 60)} minutes for the oldest request to leave the rolling 24-hour window.`);
2702
3072
  await this.delay(timeUntilReset);
2703
- this.requestCount = 0;
2704
- this.setNextDailyReset();
3073
+ continue;
2705
3074
  }
2706
3075
  await this.waitForRateLimit();
2707
3076
  this.queue.sort((a, b) => {
@@ -2736,7 +3105,7 @@ class GlobalRequestQueue {
2736
3105
  }
2737
3106
  });
2738
3107
  try {
2739
- result = await Promise.race([item.request(), timeoutPromise]);
3108
+ result = await Promise.race([this.startRequest(item.request), timeoutPromise]);
2740
3109
  }
2741
3110
  finally {
2742
3111
  if (timeoutId) {
@@ -2745,11 +3114,9 @@ class GlobalRequestQueue {
2745
3114
  }
2746
3115
  }
2747
3116
  else {
2748
- result = await item.request();
3117
+ result = await this.startRequest(item.request);
2749
3118
  }
2750
3119
  item.resolve(result);
2751
- this.requestCount++;
2752
- this.lastRequestTime = Date.now();
2753
3120
  }
2754
3121
  catch (error) {
2755
3122
  if (error instanceof EtsyRateLimitError) {
@@ -2769,7 +3136,7 @@ class GlobalRequestQueue {
2769
3136
  const globalRateLimit = this.rateLimits.get('global');
2770
3137
  if (globalRateLimit && now < globalRateLimit.resetAt) {
2771
3138
  const waitTime = globalRateLimit.resetAt - now;
2772
- console.log(`Global rate limit active. Waiting ${waitTime}ms`);
3139
+ console.warn(`Global rate limit active. Waiting ${waitTime}ms`);
2773
3140
  await this.delay(waitTime);
2774
3141
  }
2775
3142
  const timeSinceLastRequest = now - this.lastRequestTime;
@@ -2787,11 +3154,23 @@ class GlobalRequestQueue {
2787
3154
  });
2788
3155
  }
2789
3156
  }
2790
- setNextDailyReset() {
2791
- const now = new Date();
2792
- this.dailyReset = new Date(now);
2793
- this.dailyReset.setUTCDate(this.dailyReset.getUTCDate() + 1);
2794
- this.dailyReset.setUTCHours(0, 0, 0, 0);
3157
+ pruneRequestTimestamps(now) {
3158
+ const oldestAllowed = now - QPD_WINDOW_MS;
3159
+ while (this.requestTimestamps[0] !== undefined && this.requestTimestamps[0] <= oldestAllowed) {
3160
+ this.requestTimestamps.shift();
3161
+ }
3162
+ }
3163
+ getNextRequestExpiry(now) {
3164
+ this.pruneRequestTimestamps(now);
3165
+ const oldestRequest = this.requestTimestamps[0] ?? now;
3166
+ return new Date(oldestRequest + QPD_WINDOW_MS);
3167
+ }
3168
+ startRequest(request) {
3169
+ const startedAt = Date.now();
3170
+ this.pruneRequestTimestamps(startedAt);
3171
+ this.requestTimestamps.push(startedAt);
3172
+ this.lastRequestTime = startedAt;
3173
+ return request();
2795
3174
  }
2796
3175
  generateId() {
2797
3176
  return `${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
@@ -4302,7 +4681,7 @@ function createTokenManager(config, storage) {
4302
4681
  function createRateLimiter(config) {
4303
4682
  return new EtsyRateLimiter(config);
4304
4683
  }
4305
- const VERSION = '2.2.0';
4684
+ const VERSION = '3.0.1';
4306
4685
  const LIBRARY_NAME = 'etsy-v3-api-client';
4307
4686
  function getLibraryInfo() {
4308
4687
  return {