@profplum700/etsy-v3-api-client 3.0.1 → 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.
@@ -336,9 +336,12 @@ class MemoryTokenStorage {
336
336
  class TokenManager {
337
337
  constructor(config, storage, rotationConfig) {
338
338
  this.currentTokens = null;
339
+ this.tokenMutationGeneration = 0;
339
340
  this.keystring = config.keystring;
340
- this.refreshCallback = config.refreshSave;
341
+ this.refreshCallback = config.refreshSaveAsync ?? config.refreshSave;
342
+ this.refreshClearRequired = config.refreshSaveAsync !== undefined;
341
343
  this.storage = storage;
344
+ this.clearCallback = config.refreshClearAsync;
342
345
  this.rotationConfig = rotationConfig;
343
346
  this.currentTokens = {
344
347
  access_token: config.accessToken,
@@ -375,21 +378,82 @@ class TokenManager {
375
378
  if (!this.currentTokens) {
376
379
  throw new EtsyAuthError('No tokens available to refresh', 'NO_REFRESH_TOKEN');
377
380
  }
378
- this.refreshPromise = this.performTokenRefresh();
379
- try {
380
- const newTokens = await this.refreshPromise;
381
- this.currentTokens = newTokens;
382
- if (this.storage) {
383
- await this.storage.save(newTokens);
381
+ const generation = this.tokenMutationGeneration;
382
+ const refreshPromise = (this.pendingTokens
383
+ ? this.retryPendingTokenPersistence()
384
+ : this.performTokenRefresh().then(async (newTokens) => {
385
+ this.throwIfSuperseded(generation);
386
+ this.pendingTokens = newTokens;
387
+ return this.persistRefreshedTokens(newTokens, generation);
388
+ })).finally(() => {
389
+ this.refreshPromise = undefined;
390
+ });
391
+ this.refreshPromise = refreshPromise;
392
+ return refreshPromise;
393
+ }
394
+ throwIfSuperseded(generation) {
395
+ if (generation !== this.tokenMutationGeneration) {
396
+ throw new EtsyAuthError('Token refresh was superseded by a token update or clear', 'TOKEN_REFRESH_SUPERSEDED');
397
+ }
398
+ }
399
+ async repairPersistenceToCurrentTokens() {
400
+ while (true) {
401
+ const generation = this.tokenMutationGeneration;
402
+ const current = this.currentTokens ? { ...this.currentTokens } : null;
403
+ try {
404
+ if (current) {
405
+ if (this.storage)
406
+ await this.storage.save(current);
407
+ if (this.refreshCallback) {
408
+ await this.refreshCallback(current.access_token, current.refresh_token, current.expires_at);
409
+ }
410
+ }
411
+ else {
412
+ if (this.storage)
413
+ await this.storage.clear();
414
+ if (this.clearCallback)
415
+ await this.clearCallback();
416
+ else if (this.refreshClearRequired) {
417
+ throw new EtsyAuthError('A refresh persistence callback is configured without refreshClearAsync; durable tokens could not be cleared safely', 'TOKEN_CLEAR_CALLBACK_REQUIRED');
418
+ }
419
+ }
420
+ }
421
+ catch (error) {
422
+ if (generation !== this.tokenMutationGeneration)
423
+ continue;
424
+ throw error;
384
425
  }
385
- if (this.refreshCallback) {
386
- this.refreshCallback(newTokens.access_token, newTokens.refresh_token, newTokens.expires_at);
426
+ if (generation === this.tokenMutationGeneration)
427
+ return;
428
+ }
429
+ }
430
+ async persistRefreshedTokens(newTokens, generation) {
431
+ this.throwIfSuperseded(generation);
432
+ if (this.storage) {
433
+ await this.storage.save(newTokens);
434
+ if (generation !== this.tokenMutationGeneration) {
435
+ await this.repairPersistenceToCurrentTokens();
436
+ this.throwIfSuperseded(generation);
387
437
  }
388
- return newTokens;
389
438
  }
390
- finally {
391
- this.refreshPromise = undefined;
439
+ this.throwIfSuperseded(generation);
440
+ if (this.refreshCallback) {
441
+ await this.refreshCallback(newTokens.access_token, newTokens.refresh_token, newTokens.expires_at);
442
+ if (generation !== this.tokenMutationGeneration) {
443
+ await this.repairPersistenceToCurrentTokens();
444
+ this.throwIfSuperseded(generation);
445
+ }
392
446
  }
447
+ this.throwIfSuperseded(generation);
448
+ this.currentTokens = newTokens;
449
+ this.pendingTokens = undefined;
450
+ return newTokens;
451
+ }
452
+ async retryPendingTokenPersistence() {
453
+ if (!this.pendingTokens) {
454
+ throw new EtsyAuthError('No rotated tokens are pending persistence', 'NO_PENDING_TOKENS');
455
+ }
456
+ return this.persistRefreshedTokens(this.pendingTokens, this.tokenMutationGeneration);
393
457
  }
394
458
  async performTokenRefresh() {
395
459
  if (!this.currentTokens) {
@@ -403,6 +467,7 @@ class TokenManager {
403
467
  try {
404
468
  const response = await this.fetch('https://api.etsy.com/v3/public/oauth/token', {
405
469
  method: 'POST',
470
+ redirect: 'error',
406
471
  headers: {
407
472
  'Content-Type': 'application/x-www-form-urlencoded',
408
473
  'Accept': 'application/json'
@@ -432,7 +497,9 @@ class TokenManager {
432
497
  return this.currentTokens ? { ...this.currentTokens } : null;
433
498
  }
434
499
  updateTokens(tokens) {
500
+ this.tokenMutationGeneration += 1;
435
501
  this.currentTokens = { ...tokens };
502
+ this.pendingTokens = undefined;
436
503
  }
437
504
  isTokenExpired() {
438
505
  if (!this.currentTokens) {
@@ -452,10 +519,19 @@ class TokenManager {
452
519
  return now.getTime() >= (expiresAt.getTime() - bufferTime);
453
520
  }
454
521
  async clearTokens() {
522
+ this.tokenMutationGeneration += 1;
455
523
  this.currentTokens = null;
524
+ this.pendingTokens = undefined;
525
+ if (this.refreshPromise)
526
+ await this.refreshPromise.catch(() => undefined);
456
527
  if (this.storage) {
457
528
  await this.storage.clear();
458
529
  }
530
+ if (this.clearCallback)
531
+ await this.clearCallback();
532
+ else if (this.refreshClearRequired) {
533
+ throw new EtsyAuthError('A refresh persistence callback is configured without refreshClearAsync; durable tokens could not be cleared safely', 'TOKEN_CLEAR_CALLBACK_REQUIRED');
534
+ }
459
535
  }
460
536
  getTimeUntilExpiration() {
461
537
  if (!this.currentTokens) {
@@ -664,13 +740,24 @@ const ETSY_RATE_LIMITS = {
664
740
  MAX_REQUESTS_PER_SECOND: 5,
665
741
  MIN_REQUEST_INTERVAL: 200,
666
742
  };
743
+ const QPD_WINDOW_MS$1 = 24 * 60 * 60 * 1000;
667
744
  class EtsyRateLimiter {
668
745
  constructor(config) {
669
- this.requestCount = 0;
670
- this.dailyReset = new Date();
746
+ this.requestTimestamps = [];
747
+ this.uncertainRequestTimestamps = [];
671
748
  this.lastRequestTime = 0;
672
749
  this.isHeaderBasedLimiting = false;
750
+ this.headerQuotaAvailableAt = 0;
751
+ this.retryNotBefore = 0;
752
+ this.quotaProbeBackoffMs = 60000;
753
+ this.nextReservationId = 0;
754
+ this.latestHeaderReservationId = 0;
755
+ this.inFlightReservations = new Map();
756
+ this.quotaProbeInFlight = false;
757
+ this.stateWaiters = new Set();
673
758
  this.currentRetryCount = 0;
759
+ this.reservationQueue = Promise.resolve();
760
+ const overrides = Object.fromEntries(Object.entries(config ?? {}).filter(([, value]) => value !== undefined));
674
761
  this.config = {
675
762
  maxRequestsPerDay: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
676
763
  maxRequestsPerSecond: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
@@ -681,21 +768,155 @@ class EtsyRateLimiter {
681
768
  jitter: 0.1,
682
769
  qpdWarningThreshold: 80,
683
770
  onApproachingLimit: undefined,
684
- ...config
771
+ ...overrides
685
772
  };
686
- this.setNextDailyReset();
687
773
  }
688
- setNextDailyReset() {
689
- const now = new Date();
690
- this.dailyReset = new Date(now);
691
- this.dailyReset.setUTCDate(this.dailyReset.getUTCDate() + 1);
692
- this.dailyReset.setUTCHours(0, 0, 0, 0);
774
+ pruneRequestTimestamps(now) {
775
+ const oldestAllowed = now - QPD_WINDOW_MS$1;
776
+ while (this.requestTimestamps[0] !== undefined && this.requestTimestamps[0] <= oldestAllowed) {
777
+ this.requestTimestamps.shift();
778
+ }
779
+ while (this.uncertainRequestTimestamps[0] !== undefined && this.uncertainRequestTimestamps[0] <= oldestAllowed) {
780
+ this.uncertainRequestTimestamps.shift();
781
+ }
782
+ }
783
+ getLocalResetTime(now) {
784
+ this.pruneRequestTimestamps(now);
785
+ const oldestRequest = this.requestTimestamps[0] ?? now;
786
+ return new Date(oldestRequest + QPD_WINDOW_MS$1);
787
+ }
788
+ getLocalRemainingRequests(now) {
789
+ this.pruneRequestTimestamps(now);
790
+ const localLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
791
+ return Math.max(0, localLimit - this.requestTimestamps.length);
693
792
  }
694
- updateFromHeaders(headers) {
793
+ getEffectiveRemainingRequests(now) {
794
+ this.pruneRequestTimestamps(now);
795
+ if (this.headerRemainingToday !== undefined) {
796
+ return Math.max(0, this.headerRemainingToday - this.inFlightReservations.size - this.uncertainRequestTimestamps.length);
797
+ }
798
+ return this.getLocalRemainingRequests(now);
799
+ }
800
+ isQuotaProbeReady(now) {
801
+ const exhausted = this.headerRemainingToday === 0;
802
+ const uncertainBudgetExhausted = this.inFlightReservations.size === 0 &&
803
+ this.uncertainRequestTimestamps.length > 0 &&
804
+ this.getEffectiveRemainingRequests(now) === 0;
805
+ return (exhausted || uncertainBudgetExhausted) &&
806
+ !this.quotaProbeInFlight &&
807
+ this.inFlightReservations.size === 0 &&
808
+ this.headerQuotaAvailableAt <= now;
809
+ }
810
+ refreshExpiredHeaderQuota(now) {
811
+ if (this.headerRemainingToday === 0 && this.headerQuotaAvailableAt > 0 && now >= this.headerQuotaAvailableAt) {
812
+ this.headerQuotaAvailableAt = 0;
813
+ }
814
+ }
815
+ notifyStateWaiters() {
816
+ for (const resolve of this.stateWaiters)
817
+ resolve();
818
+ this.stateWaiters.clear();
819
+ }
820
+ scheduleQuotaProbe() {
821
+ this.headerQuotaAvailableAt = Date.now() + this.quotaProbeBackoffMs;
822
+ this.quotaProbeBackoffMs = Math.min(this.quotaProbeBackoffMs * 2, 60 * 60 * 1000);
823
+ }
824
+ waitForStateChange() {
825
+ return new Promise((resolve) => this.stateWaiters.add(resolve));
826
+ }
827
+ finishReservation(reservationId, receivedQuotaHeader) {
828
+ const wasQuotaProbe = this.inFlightReservations.get(reservationId);
829
+ if (wasQuotaProbe === undefined)
830
+ return;
831
+ this.inFlightReservations.delete(reservationId);
832
+ if (wasQuotaProbe.isQuotaProbe) {
833
+ this.quotaProbeInFlight = false;
834
+ if (this.headerRemainingToday === 0 && !receivedQuotaHeader) {
835
+ this.scheduleQuotaProbe();
836
+ }
837
+ }
838
+ this.notifyStateWaiters();
839
+ }
840
+ async acquireRequestSlot() {
841
+ const previousReservation = this.reservationQueue;
842
+ let releaseReservation = () => undefined;
843
+ this.reservationQueue = new Promise((resolve) => {
844
+ releaseReservation = resolve;
845
+ });
846
+ await previousReservation;
847
+ try {
848
+ while (true) {
849
+ const now = Date.now();
850
+ this.pruneRequestTimestamps(now);
851
+ this.refreshExpiredHeaderQuota(now);
852
+ let isQuotaProbe = false;
853
+ if (this.headerRemainingToday === 0 || this.isQuotaProbeReady(now)) {
854
+ if (this.quotaProbeInFlight || this.inFlightReservations.size > 0) {
855
+ await this.waitForStateChange();
856
+ continue;
857
+ }
858
+ const probeWait = this.headerQuotaAvailableAt - now;
859
+ if (probeWait > 0) {
860
+ await new Promise(resolve => setTimeout(resolve, probeWait));
861
+ continue;
862
+ }
863
+ isQuotaProbe = true;
864
+ }
865
+ else {
866
+ const effectiveRemaining = this.getEffectiveRemainingRequests(now);
867
+ if (effectiveRemaining <= 0) {
868
+ if (this.inFlightReservations.size > 0 && this.headerRemainingToday !== undefined) {
869
+ await this.waitForStateChange();
870
+ continue;
871
+ }
872
+ const effectiveDailyLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
873
+ const localResetTime = this.getLocalResetTime(now);
874
+ const timeUntilReset = Math.max(0, localResetTime.getTime() - now);
875
+ throw new EtsyRateLimitError(`Daily rate limit exhausted (${effectiveDailyLimit} requests in the local rolling 24-hour window). ` +
876
+ `The oldest local request expires in approximately ${Math.ceil(timeUntilReset / 1000 / 60)} minutes.`, Math.ceil(timeUntilReset / 1000), 'qpd_exhausted');
877
+ }
878
+ }
879
+ const intervalWait = Math.max(0, this.getEffectiveMinInterval() - (now - this.lastRequestTime));
880
+ const cooldownWait = Math.max(0, this.retryNotBefore - now);
881
+ const waitTime = Math.max(intervalWait, cooldownWait);
882
+ if (waitTime > 0) {
883
+ await new Promise(resolve => setTimeout(resolve, waitTime));
884
+ continue;
885
+ }
886
+ const requestStartedAt = Date.now();
887
+ const reservationId = ++this.nextReservationId;
888
+ this.requestTimestamps.push(requestStartedAt);
889
+ this.lastRequestTime = requestStartedAt;
890
+ this.inFlightReservations.set(reservationId, { isQuotaProbe, startedAt: requestStartedAt });
891
+ if (isQuotaProbe)
892
+ this.quotaProbeInFlight = true;
893
+ return reservationId;
894
+ }
895
+ }
896
+ finally {
897
+ releaseReservation();
898
+ }
899
+ }
900
+ releaseRequestSlot(reservationId) {
901
+ const reservation = this.inFlightReservations.get(reservationId);
902
+ if (!reservation)
903
+ return;
904
+ this.uncertainRequestTimestamps.push(reservation.startedAt);
905
+ this.finishReservation(reservationId, false);
906
+ }
907
+ updateFromHeaders(headers, reservationId) {
908
+ const matchedReservationId = reservationId;
909
+ const isQuotaProbe = matchedReservationId !== undefined &&
910
+ this.inFlightReservations.get(matchedReservationId)?.isQuotaProbe === true;
695
911
  if (!headers) {
912
+ if (matchedReservationId !== undefined)
913
+ this.finishReservation(matchedReservationId, false);
696
914
  return;
697
915
  }
698
916
  const parsed = this.parseRateLimitHeaders(headers);
917
+ if (matchedReservationId !== undefined) {
918
+ this.finishReservation(matchedReservationId, parsed.remainingToday !== undefined);
919
+ }
699
920
  if (parsed.limitPerSecond !== undefined) {
700
921
  this.headerLimitPerSecond = parsed.limitPerSecond;
701
922
  this.isHeaderBasedLimiting = true;
@@ -708,9 +929,39 @@ class EtsyRateLimiter {
708
929
  this.isHeaderBasedLimiting = true;
709
930
  }
710
931
  if (parsed.remainingToday !== undefined) {
711
- this.headerRemainingToday = parsed.remainingToday;
932
+ const observationId = matchedReservationId ?? this.nextReservationId;
933
+ if (isQuotaProbe && parsed.remainingToday > 0) {
934
+ this.uncertainRequestTimestamps = [];
935
+ }
936
+ const exhaustedByEarlierObservation = this.headerRemainingToday === 0 &&
937
+ parsed.remainingToday > 0 && matchedReservationId !== undefined && !isQuotaProbe;
938
+ const exhaustedByUncorrelatedObservation = this.headerRemainingToday === 0 &&
939
+ parsed.remainingToday > 0 && matchedReservationId === undefined;
940
+ if (exhaustedByEarlierObservation || exhaustedByUncorrelatedObservation) ;
941
+ else if (isQuotaProbe || this.headerRemainingToday === undefined) {
942
+ this.headerRemainingToday = parsed.remainingToday;
943
+ this.latestHeaderReservationId = Math.max(this.latestHeaderReservationId, observationId);
944
+ }
945
+ else if (parsed.remainingToday < this.headerRemainingToday) {
946
+ this.headerRemainingToday = parsed.remainingToday;
947
+ this.latestHeaderReservationId = Math.max(this.latestHeaderReservationId, observationId);
948
+ }
949
+ if (this.headerRemainingToday !== undefined && this.headerRemainingToday > 0) {
950
+ this.headerQuotaAvailableAt = 0;
951
+ this.quotaProbeBackoffMs = 60000;
952
+ }
953
+ else if (this.headerQuotaAvailableAt === 0) {
954
+ this.scheduleQuotaProbe();
955
+ }
712
956
  this.checkApproachingLimit();
713
957
  }
958
+ else if (matchedReservationId !== undefined && this.headerRemainingToday !== undefined) {
959
+ this.headerRemainingToday = Math.max(0, this.headerRemainingToday - 1);
960
+ if (this.headerRemainingToday === 0 && this.headerQuotaAvailableAt === 0) {
961
+ this.scheduleQuotaProbe();
962
+ }
963
+ }
964
+ this.notifyStateWaiters();
714
965
  }
715
966
  parseRateLimitHeaders(headers) {
716
967
  const getHeader = (name) => {
@@ -750,18 +1001,27 @@ class EtsyRateLimiter {
750
1001
  this.config.onApproachingLimit(remaining, limit, percentageUsed);
751
1002
  }
752
1003
  }
753
- async handleRateLimitResponse(headers) {
1004
+ async handleRateLimitResponse(headers, reservationId, retryAttempt) {
754
1005
  const parsed = headers ? this.parseRateLimitHeaders(headers) : {};
755
- this.updateFromHeaders(headers);
756
- if (this.headerRemainingToday === 0) {
757
- throw new EtsyRateLimitError('Daily rate limit exhausted. No requests remaining until limit resets.', parsed.retryAfter, 'qpd_exhausted');
758
- }
759
- this.currentRetryCount++;
760
- if (this.currentRetryCount > this.config.maxRetries) {
1006
+ this.updateFromHeaders(headers, reservationId);
1007
+ if (parsed.remainingToday === 0) {
1008
+ if (parsed.retryAfter !== undefined) {
1009
+ this.headerQuotaAvailableAt = Date.now() + parsed.retryAfter * 1000;
1010
+ this.retryNotBefore = Math.max(this.retryNotBefore, this.headerQuotaAvailableAt);
1011
+ }
761
1012
  this.currentRetryCount = 0;
1013
+ throw new EtsyRateLimitError('Daily rate limit exhausted according to Etsy response headers.', parsed.retryAfter, 'qpd_exhausted');
1014
+ }
1015
+ const attempt = retryAttempt ?? this.currentRetryCount + 1;
1016
+ if (retryAttempt === undefined)
1017
+ this.currentRetryCount = attempt;
1018
+ const delayMs = this.calculateBackoffDelay(attempt, parsed.retryAfter);
1019
+ this.retryNotBefore = Math.max(this.retryNotBefore, Date.now() + delayMs);
1020
+ if (attempt > this.config.maxRetries) {
1021
+ if (retryAttempt === undefined)
1022
+ this.currentRetryCount = 0;
762
1023
  throw new EtsyRateLimitError(`Max retries (${this.config.maxRetries}) exceeded for rate limit`, parsed.retryAfter, 'qps_exhausted');
763
1024
  }
764
- const delayMs = this.calculateBackoffDelay(this.currentRetryCount, parsed.retryAfter);
765
1025
  return { shouldRetry: true, delayMs };
766
1026
  }
767
1027
  calculateBackoffDelay(attempt, retryAfterSeconds) {
@@ -774,7 +1034,7 @@ class EtsyRateLimiter {
774
1034
  const randomJitter = Math.random() * jitterAmount * 2 - jitterAmount;
775
1035
  delay += randomJitter;
776
1036
  }
777
- return Math.max(0, Math.floor(delay));
1037
+ return Math.max(serverSuggestedMs, 0, Math.floor(delay));
778
1038
  }
779
1039
  resetRetryCount() {
780
1040
  this.currentRetryCount = 0;
@@ -792,41 +1052,26 @@ class EtsyRateLimiter {
792
1052
  return this.config.minRequestInterval;
793
1053
  }
794
1054
  async waitForRateLimit() {
795
- const now = Date.now();
796
- if (now >= this.dailyReset.getTime()) {
797
- this.requestCount = 0;
798
- this.setNextDailyReset();
799
- }
800
- const effectiveDailyLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
801
- const effectiveRemaining = this.headerRemainingToday ??
802
- (effectiveDailyLimit - this.requestCount);
803
- if (effectiveRemaining <= 0) {
804
- const timeUntilReset = this.dailyReset.getTime() - now;
805
- throw new EtsyRateLimitError(`Daily rate limit exhausted (${effectiveDailyLimit} requests). ` +
806
- `Reset in approximately ${Math.ceil(timeUntilReset / 1000 / 60)} minutes.`, Math.ceil(timeUntilReset / 1000), 'qpd_exhausted');
807
- }
808
- const minInterval = this.getEffectiveMinInterval();
809
- const timeSinceLastRequest = now - this.lastRequestTime;
810
- if (timeSinceLastRequest < minInterval) {
811
- const waitTime = minInterval - timeSinceLastRequest;
812
- await new Promise(resolve => setTimeout(resolve, waitTime));
813
- }
814
- this.requestCount++;
815
- this.lastRequestTime = Date.now();
1055
+ const reservationId = await this.acquireRequestSlot();
1056
+ this.finishReservation(reservationId, false);
1057
+ }
1058
+ async waitForRateLimitWithReservation() {
1059
+ return this.acquireRequestSlot();
816
1060
  }
817
1061
  getRateLimitStatus() {
818
1062
  const now = Date.now();
819
- if (now >= this.dailyReset.getTime()) {
820
- this.requestCount = 0;
821
- this.setNextDailyReset();
822
- }
823
- const effectiveRemaining = this.headerRemainingToday ??
824
- Math.max(0, this.config.maxRequestsPerDay - this.requestCount);
1063
+ this.pruneRequestTimestamps(now);
1064
+ this.refreshExpiredHeaderQuota(now);
1065
+ const effectiveRemaining = this.getEffectiveRemainingRequests(now);
1066
+ const qpdProbeReady = this.isQuotaProbeReady(now);
825
1067
  return {
826
1068
  remainingRequests: effectiveRemaining,
827
- resetTime: this.dailyReset,
828
- canMakeRequest: effectiveRemaining > 0 &&
829
- (now - this.lastRequestTime) >= this.getEffectiveMinInterval(),
1069
+ resetTime: this.headerRemainingToday === 0
1070
+ ? new Date(this.headerQuotaAvailableAt || now)
1071
+ : this.getLocalResetTime(now),
1072
+ canMakeRequest: (effectiveRemaining > 0 || qpdProbeReady) &&
1073
+ (now - this.lastRequestTime) >= this.getEffectiveMinInterval() &&
1074
+ now >= this.retryNotBefore,
830
1075
  isFromHeaders: this.isHeaderBasedLimiting,
831
1076
  limitPerSecond: this.headerLimitPerSecond,
832
1077
  remainingThisSecond: this.headerRemainingThisSecond,
@@ -834,41 +1079,47 @@ class EtsyRateLimiter {
834
1079
  };
835
1080
  }
836
1081
  getRemainingRequests() {
837
- return this.headerRemainingToday ??
838
- Math.max(0, this.config.maxRequestsPerDay - this.requestCount);
1082
+ const now = Date.now();
1083
+ this.pruneRequestTimestamps(now);
1084
+ this.refreshExpiredHeaderQuota(now);
1085
+ return this.getEffectiveRemainingRequests(now);
839
1086
  }
840
1087
  reset() {
841
- this.requestCount = 0;
1088
+ this.requestTimestamps = [];
842
1089
  this.lastRequestTime = 0;
843
1090
  this.currentRetryCount = 0;
1091
+ this.quotaProbeBackoffMs = 60000;
1092
+ this.headerQuotaAvailableAt = 0;
1093
+ this.retryNotBefore = 0;
844
1094
  this.headerLimitPerSecond = undefined;
845
1095
  this.headerRemainingThisSecond = undefined;
846
1096
  this.headerLimitPerDay = undefined;
847
1097
  this.headerRemainingToday = undefined;
848
1098
  this.isHeaderBasedLimiting = false;
849
- this.setNextDailyReset();
1099
+ this.nextReservationId = 0;
1100
+ this.latestHeaderReservationId = 0;
1101
+ this.inFlightReservations.clear();
1102
+ this.uncertainRequestTimestamps = [];
1103
+ this.quotaProbeInFlight = false;
1104
+ this.notifyStateWaiters();
850
1105
  }
851
1106
  canMakeRequest() {
852
1107
  const now = Date.now();
853
- if (now >= this.dailyReset.getTime()) {
854
- this.requestCount = 0;
855
- this.setNextDailyReset();
856
- }
857
- const effectiveRemaining = this.headerRemainingToday ??
858
- (this.config.maxRequestsPerDay - this.requestCount);
859
- if (effectiveRemaining <= 0) {
1108
+ this.pruneRequestTimestamps(now);
1109
+ this.refreshExpiredHeaderQuota(now);
1110
+ const effectiveRemaining = this.getEffectiveRemainingRequests(now);
1111
+ const qpdProbeReady = this.isQuotaProbeReady(now);
1112
+ if (effectiveRemaining <= 0 && !qpdProbeReady) {
860
1113
  return false;
861
1114
  }
862
- return (now - this.lastRequestTime) >= this.getEffectiveMinInterval();
1115
+ return (now - this.lastRequestTime) >= this.getEffectiveMinInterval() &&
1116
+ now >= this.retryNotBefore;
863
1117
  }
864
1118
  getTimeUntilNextRequest() {
865
1119
  const now = Date.now();
866
1120
  const timeSinceLastRequest = now - this.lastRequestTime;
867
1121
  const minInterval = this.getEffectiveMinInterval();
868
- if (timeSinceLastRequest >= minInterval) {
869
- return 0;
870
- }
871
- return minInterval - timeSinceLastRequest;
1122
+ return Math.max(0, minInterval - timeSinceLastRequest, this.retryNotBefore - now);
872
1123
  }
873
1124
  getConfig() {
874
1125
  return { ...this.config };
@@ -1315,7 +1566,8 @@ class EtsyClient {
1315
1566
  this.logger = new DefaultLogger();
1316
1567
  this.keystring = config.keystring;
1317
1568
  this.sharedSecret = config.sharedSecret;
1318
- if (config.rateLimiting?.enabled !== false) {
1569
+ this.rateLimitingEnabled = config.rateLimiting?.enabled !== false;
1570
+ if (this.rateLimitingEnabled) {
1319
1571
  this.rateLimiter = new EtsyRateLimiter({
1320
1572
  maxRequestsPerDay: config.rateLimiting?.maxRequestsPerDay || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
1321
1573
  maxRequestsPerSecond: config.rateLimiting?.maxRequestsPerSecond || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
@@ -1340,11 +1592,39 @@ class EtsyClient {
1340
1592
  stopOnError: false
1341
1593
  });
1342
1594
  }
1595
+ async acquireRateLimitSlot() {
1596
+ return this.rateLimitingEnabled
1597
+ ? this.rateLimiter.acquireRequestSlot()
1598
+ : undefined;
1599
+ }
1600
+ async fetchOnceWithRateLimit(url, options) {
1601
+ const reservationId = await this.acquireRateLimitSlot();
1602
+ let response;
1603
+ try {
1604
+ response = await this.fetch(url, options);
1605
+ }
1606
+ catch (error) {
1607
+ if (reservationId !== undefined)
1608
+ this.rateLimiter.releaseRequestSlot(reservationId);
1609
+ throw error;
1610
+ }
1611
+ if (response.status === 429) {
1612
+ await this.rateLimiter.handleRateLimitResponse(response.headers, reservationId);
1613
+ this.rateLimiter.resetRetryCount();
1614
+ }
1615
+ else {
1616
+ this.rateLimiter.updateFromHeaders(response.headers, reservationId);
1617
+ if (response.ok)
1618
+ this.rateLimiter.resetRetryCount();
1619
+ }
1620
+ return response;
1621
+ }
1343
1622
  async makeRequest(endpoint, options = {}, useCache = true) {
1344
1623
  const url = `${this.baseUrl}${endpoint}`;
1345
1624
  const requestOptions = {
1346
1625
  method: 'GET',
1347
- ...options
1626
+ ...options,
1627
+ redirect: 'error'
1348
1628
  };
1349
1629
  const cacheKey = `${url}:${JSON.stringify(requestOptions)}`;
1350
1630
  if (useCache && this.cache && requestOptions.method === 'GET') {
@@ -1358,7 +1638,6 @@ class EtsyClient {
1358
1638
  }
1359
1639
  }
1360
1640
  }
1361
- await this.rateLimiter.waitForRateLimit();
1362
1641
  const accessToken = await this.tokenManager.getAccessToken();
1363
1642
  const headers = {
1364
1643
  'Authorization': `Bearer ${accessToken}`,
@@ -1370,22 +1649,38 @@ class EtsyClient {
1370
1649
  return this.executeWithRetry(url, { ...requestOptions, headers }, cacheKey, useCache);
1371
1650
  }
1372
1651
  async executeWithRetry(url, options, cacheKey, useCache) {
1652
+ let retryAttempt = 0;
1373
1653
  while (true) {
1374
1654
  try {
1375
- const response = await this.fetch(url, options);
1655
+ const reservationId = await this.acquireRateLimitSlot();
1656
+ let response;
1657
+ try {
1658
+ response = await this.fetch(url, options);
1659
+ }
1660
+ catch (error) {
1661
+ if (reservationId !== undefined)
1662
+ this.rateLimiter.releaseRequestSlot(reservationId);
1663
+ throw error;
1664
+ }
1376
1665
  if (response.status === 429) {
1377
- const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers);
1666
+ const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers, reservationId, ++retryAttempt);
1378
1667
  if (shouldRetry) {
1668
+ const method = (options.method ?? 'GET').toUpperCase();
1669
+ if (method !== 'GET') {
1670
+ this.rateLimiter.resetRetryCount();
1671
+ const errorText = await response.text();
1672
+ throw new EtsyApiError(`Etsy API error: 429 ${response.statusText}; automatic retry skipped for ${method} requests`, 429, errorText);
1673
+ }
1379
1674
  this.logger.warn(`Rate limited. Retrying in ${delayMs}ms...`);
1380
1675
  await this.sleep(delayMs);
1381
1676
  continue;
1382
1677
  }
1383
1678
  }
1679
+ this.rateLimiter.updateFromHeaders(response.headers, reservationId);
1384
1680
  if (!response.ok) {
1385
1681
  const errorText = await response.text();
1386
1682
  throw new EtsyApiError(`Etsy API error: ${response.status} ${response.statusText}`, response.status, errorText);
1387
1683
  }
1388
- this.rateLimiter.updateFromHeaders(response.headers);
1389
1684
  this.rateLimiter.resetRetryCount();
1390
1685
  if (response.status === 204) {
1391
1686
  return undefined;
@@ -1688,9 +1983,87 @@ class EtsyClient {
1688
1983
  async updateListingInventory(listingId, params, options) {
1689
1984
  const legacy = options?.legacy;
1690
1985
  const query = legacy !== undefined ? `?legacy=${legacy}` : '';
1986
+ if (!Array.isArray(params.products) || params.products.length === 0) {
1987
+ throw new TypeError('Inventory update must contain at least one product.');
1988
+ }
1989
+ const body = {
1990
+ products: params.products.map((product, productIndex) => {
1991
+ if (product.is_deleted === true) {
1992
+ throw new TypeError(`Inventory product ${productIndex} is deleted and cannot be submitted for replacement.`);
1993
+ }
1994
+ if (!Array.isArray(product.offerings) || product.offerings.length === 0) {
1995
+ throw new TypeError(`Inventory product ${productIndex} must contain at least one offering.`);
1996
+ }
1997
+ if (!Array.isArray(product.property_values)) {
1998
+ throw new TypeError(`Inventory product ${productIndex} must include property_values; pass [] when it has no variation properties.`);
1999
+ }
2000
+ if (product.sku !== undefined && product.sku !== null && typeof product.sku !== 'string') {
2001
+ throw new TypeError(`Inventory product ${productIndex} sku must be a string or null.`);
2002
+ }
2003
+ return {
2004
+ ...(product.sku !== undefined ? { sku: product.sku } : {}),
2005
+ property_values: product.property_values.map((value, valueIndex) => {
2006
+ if (!Number.isInteger(value.property_id) || value.property_id < 1) {
2007
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_id must be a positive integer.`);
2008
+ }
2009
+ if (!Array.isArray(value.value_ids) || value.value_ids.some((id) => !Number.isInteger(id))) {
2010
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].value_ids must be an integer array.`);
2011
+ }
2012
+ if (!Array.isArray(value.values) || value.values.some((entry) => typeof entry !== 'string')) {
2013
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].values must be a string array.`);
2014
+ }
2015
+ if (value.property_name !== undefined && typeof value.property_name !== 'string') {
2016
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_name must be a string.`);
2017
+ }
2018
+ if (value.scale_id !== undefined && value.scale_id !== null &&
2019
+ (!Number.isInteger(value.scale_id) || value.scale_id < 1)) {
2020
+ throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].scale_id must be a positive integer or null.`);
2021
+ }
2022
+ return {
2023
+ property_id: value.property_id,
2024
+ ...(value.property_name !== undefined ? { property_name: value.property_name } : {}),
2025
+ ...(value.scale_id !== undefined ? { scale_id: value.scale_id } : {}),
2026
+ value_ids: value.value_ids,
2027
+ values: value.values,
2028
+ };
2029
+ }),
2030
+ offerings: product.offerings.map((offering, offeringIndex) => {
2031
+ if (offering.is_deleted === true) {
2032
+ throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is deleted and cannot be submitted for replacement.`);
2033
+ }
2034
+ if (typeof offering.price !== 'number' || !Number.isFinite(offering.price)) {
2035
+ throw new TypeError('Inventory offering price must be a finite decimal number in the listing currency.');
2036
+ }
2037
+ if (!Number.isInteger(offering.quantity)) {
2038
+ throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} quantity must be an integer.`);
2039
+ }
2040
+ if (typeof offering.is_enabled !== 'boolean') {
2041
+ throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is_enabled must be a boolean.`);
2042
+ }
2043
+ if (offering.readiness_state_id === undefined ||
2044
+ (offering.readiness_state_id !== null &&
2045
+ (!Number.isInteger(offering.readiness_state_id) || offering.readiness_state_id < 1))) {
2046
+ throw new TypeError('Inventory offering readiness_state_id is required; use null when not set.');
2047
+ }
2048
+ return {
2049
+ price: offering.price,
2050
+ quantity: offering.quantity,
2051
+ is_enabled: offering.is_enabled,
2052
+ readiness_state_id: offering.readiness_state_id,
2053
+ };
2054
+ }),
2055
+ };
2056
+ }),
2057
+ ...(params.price_on_property !== undefined ? { price_on_property: params.price_on_property } : {}),
2058
+ ...(params.quantity_on_property !== undefined ? { quantity_on_property: params.quantity_on_property } : {}),
2059
+ ...(params.sku_on_property !== undefined ? { sku_on_property: params.sku_on_property } : {}),
2060
+ ...(params.readiness_state_on_property !== undefined
2061
+ ? { readiness_state_on_property: params.readiness_state_on_property }
2062
+ : {}),
2063
+ };
1691
2064
  return this.makeRequest(`/listings/${listingId}/inventory${query}`, {
1692
2065
  method: 'PUT',
1693
- body: JSON.stringify(params)
2066
+ body: JSON.stringify(body)
1694
2067
  }, false);
1695
2068
  }
1696
2069
  async uploadListingImage(shopId, listingId, imageData, params) {
@@ -1705,9 +2078,8 @@ class EtsyClient {
1705
2078
  if (params?.alt_text)
1706
2079
  formData.append('alt_text', params.alt_text);
1707
2080
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/images`;
1708
- await this.rateLimiter.waitForRateLimit();
1709
2081
  const accessToken = await this.tokenManager.getAccessToken();
1710
- const response = await this.fetch(url, {
2082
+ const response = await this.fetchOnceWithRateLimit(url, {
1711
2083
  method: 'POST',
1712
2084
  headers: {
1713
2085
  'Authorization': `Bearer ${accessToken}`,
@@ -1902,10 +2274,11 @@ class EtsyClient {
1902
2274
  }
1903
2275
  async getShopPayment(shopId, paymentId) {
1904
2276
  const payments = await this.getPayments(shopId, [Number(paymentId)]);
1905
- if (payments.length === 0) {
2277
+ const payment = payments[0];
2278
+ if (!payment) {
1906
2279
  throw new EtsyApiError('Payment not found', 404);
1907
2280
  }
1908
- return payments[0];
2281
+ return payment;
1909
2282
  }
1910
2283
  async getBuyerTaxonomyNodes() {
1911
2284
  const response = await this.makeRequest('/buyer-taxonomy/nodes');
@@ -1928,9 +2301,8 @@ class EtsyClient {
1928
2301
  body.append('scale_id', scaleId.toString());
1929
2302
  }
1930
2303
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/properties/${propertyId}`;
1931
- await this.rateLimiter.waitForRateLimit();
1932
2304
  const accessToken = await this.tokenManager.getAccessToken();
1933
- const response = await this.fetch(url, {
2305
+ const response = await this.fetchOnceWithRateLimit(url, {
1934
2306
  method: 'PUT',
1935
2307
  headers: {
1936
2308
  'Authorization': `Bearer ${accessToken}`,
@@ -1939,8 +2311,6 @@ class EtsyClient {
1939
2311
  },
1940
2312
  body: body.toString()
1941
2313
  });
1942
- this.rateLimiter.updateFromHeaders(response.headers);
1943
- this.rateLimiter.resetRetryCount();
1944
2314
  if (!response.ok) {
1945
2315
  const errorText = await response.text();
1946
2316
  throw new EtsyApiError(`Failed to update listing property: ${response.status} ${response.statusText}`, response.status, errorText);
@@ -2040,9 +2410,8 @@ class EtsyClient {
2040
2410
  if (params?.listing_file_id !== undefined)
2041
2411
  formData.append('listing_file_id', params.listing_file_id.toString());
2042
2412
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/files`;
2043
- await this.rateLimiter.waitForRateLimit();
2044
2413
  const accessToken = await this.tokenManager.getAccessToken();
2045
- const response = await this.fetch(url, {
2414
+ const response = await this.fetchOnceWithRateLimit(url, {
2046
2415
  method: 'POST',
2047
2416
  headers: {
2048
2417
  'Authorization': `Bearer ${accessToken}`,
@@ -2074,9 +2443,8 @@ class EtsyClient {
2074
2443
  if (params?.video_id !== undefined)
2075
2444
  formData.append('video_id', params.video_id.toString());
2076
2445
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/videos`;
2077
- await this.rateLimiter.waitForRateLimit();
2078
2446
  const accessToken = await this.tokenManager.getAccessToken();
2079
- const response = await this.fetch(url, {
2447
+ const response = await this.fetchOnceWithRateLimit(url, {
2080
2448
  method: 'POST',
2081
2449
  headers: {
2082
2450
  'Authorization': `Bearer ${accessToken}`,
@@ -2473,6 +2841,7 @@ class AuthHelper {
2473
2841
  assertFetchSupport();
2474
2842
  const response = await fetch('https://api.etsy.com/v3/public/oauth/token', {
2475
2843
  method: 'POST',
2844
+ redirect: 'error',
2476
2845
  headers: {
2477
2846
  'Content-Type': 'application/x-www-form-urlencoded',
2478
2847
  'Accept': 'application/json'
@@ -2565,18 +2934,17 @@ const COMMON_SCOPE_COMBINATIONS = {
2565
2934
  ]
2566
2935
  };
2567
2936
 
2937
+ const QPD_WINDOW_MS = 24 * 60 * 60 * 1000;
2568
2938
  class GlobalRequestQueue {
2569
2939
  constructor() {
2570
2940
  this.queue = [];
2571
2941
  this.processing = false;
2572
2942
  this.rateLimits = new Map();
2573
- this.requestCount = 0;
2574
- this.dailyReset = new Date();
2943
+ this.requestTimestamps = [];
2575
2944
  this.lastRequestTime = 0;
2576
2945
  this.maxRequestsPerDay = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY;
2577
2946
  this.maxRequestsPerSecond = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND;
2578
2947
  this.minRequestInterval = ETSY_RATE_LIMITS.MIN_REQUEST_INTERVAL;
2579
- this.setNextDailyReset();
2580
2948
  }
2581
2949
  static getInstance() {
2582
2950
  if (!this.instance) {
@@ -2608,11 +2976,13 @@ class GlobalRequestQueue {
2608
2976
  });
2609
2977
  }
2610
2978
  getStatus() {
2979
+ const now = Date.now();
2980
+ this.pruneRequestTimestamps(now);
2611
2981
  return {
2612
2982
  queueLength: this.queue.length,
2613
2983
  processing: this.processing,
2614
- remainingRequests: Math.max(0, this.maxRequestsPerDay - this.requestCount),
2615
- resetTime: this.dailyReset,
2984
+ remainingRequests: Math.max(0, this.maxRequestsPerDay - this.requestTimestamps.length),
2985
+ resetTime: this.getNextRequestExpiry(now),
2616
2986
  };
2617
2987
  }
2618
2988
  clear() {
@@ -2628,16 +2998,13 @@ class GlobalRequestQueue {
2628
2998
  this.processing = true;
2629
2999
  try {
2630
3000
  while (this.queue.length > 0) {
2631
- if (Date.now() >= this.dailyReset.getTime()) {
2632
- this.requestCount = 0;
2633
- this.setNextDailyReset();
2634
- }
2635
- if (this.requestCount >= this.maxRequestsPerDay) {
2636
- const timeUntilReset = this.dailyReset.getTime() - Date.now();
2637
- console.warn(`Daily rate limit reached. Waiting ${Math.ceil(timeUntilReset / 1000 / 60)} minutes until reset.`);
3001
+ const now = Date.now();
3002
+ this.pruneRequestTimestamps(now);
3003
+ if (this.requestTimestamps.length >= this.maxRequestsPerDay) {
3004
+ const timeUntilReset = Math.max(0, this.getNextRequestExpiry(now).getTime() - now);
3005
+ console.warn(`Daily rate limit reached. Waiting ${Math.ceil(timeUntilReset / 1000 / 60)} minutes for the oldest request to leave the rolling 24-hour window.`);
2638
3006
  await this.delay(timeUntilReset);
2639
- this.requestCount = 0;
2640
- this.setNextDailyReset();
3007
+ continue;
2641
3008
  }
2642
3009
  await this.waitForRateLimit();
2643
3010
  this.queue.sort((a, b) => {
@@ -2672,7 +3039,7 @@ class GlobalRequestQueue {
2672
3039
  }
2673
3040
  });
2674
3041
  try {
2675
- result = await Promise.race([item.request(), timeoutPromise]);
3042
+ result = await Promise.race([this.startRequest(item.request), timeoutPromise]);
2676
3043
  }
2677
3044
  finally {
2678
3045
  if (timeoutId) {
@@ -2681,11 +3048,9 @@ class GlobalRequestQueue {
2681
3048
  }
2682
3049
  }
2683
3050
  else {
2684
- result = await item.request();
3051
+ result = await this.startRequest(item.request);
2685
3052
  }
2686
3053
  item.resolve(result);
2687
- this.requestCount++;
2688
- this.lastRequestTime = Date.now();
2689
3054
  }
2690
3055
  catch (error) {
2691
3056
  if (error instanceof EtsyRateLimitError) {
@@ -2705,7 +3070,7 @@ class GlobalRequestQueue {
2705
3070
  const globalRateLimit = this.rateLimits.get('global');
2706
3071
  if (globalRateLimit && now < globalRateLimit.resetAt) {
2707
3072
  const waitTime = globalRateLimit.resetAt - now;
2708
- console.log(`Global rate limit active. Waiting ${waitTime}ms`);
3073
+ console.warn(`Global rate limit active. Waiting ${waitTime}ms`);
2709
3074
  await this.delay(waitTime);
2710
3075
  }
2711
3076
  const timeSinceLastRequest = now - this.lastRequestTime;
@@ -2723,11 +3088,23 @@ class GlobalRequestQueue {
2723
3088
  });
2724
3089
  }
2725
3090
  }
2726
- setNextDailyReset() {
2727
- const now = new Date();
2728
- this.dailyReset = new Date(now);
2729
- this.dailyReset.setUTCDate(this.dailyReset.getUTCDate() + 1);
2730
- this.dailyReset.setUTCHours(0, 0, 0, 0);
3091
+ pruneRequestTimestamps(now) {
3092
+ const oldestAllowed = now - QPD_WINDOW_MS;
3093
+ while (this.requestTimestamps[0] !== undefined && this.requestTimestamps[0] <= oldestAllowed) {
3094
+ this.requestTimestamps.shift();
3095
+ }
3096
+ }
3097
+ getNextRequestExpiry(now) {
3098
+ this.pruneRequestTimestamps(now);
3099
+ const oldestRequest = this.requestTimestamps[0] ?? now;
3100
+ return new Date(oldestRequest + QPD_WINDOW_MS);
3101
+ }
3102
+ startRequest(request) {
3103
+ const startedAt = Date.now();
3104
+ this.pruneRequestTimestamps(startedAt);
3105
+ this.requestTimestamps.push(startedAt);
3106
+ this.lastRequestTime = startedAt;
3107
+ return request();
2731
3108
  }
2732
3109
  generateId() {
2733
3110
  return `${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;