@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.
package/dist/index.cjs CHANGED
@@ -343,9 +343,12 @@ class MemoryTokenStorage {
343
343
  class TokenManager {
344
344
  constructor(config, storage, rotationConfig) {
345
345
  this.currentTokens = null;
346
+ this.tokenMutationGeneration = 0;
346
347
  this.keystring = config.keystring;
347
- this.refreshCallback = config.refreshSave;
348
+ this.refreshCallback = config.refreshSaveAsync ?? config.refreshSave;
349
+ this.refreshClearRequired = config.refreshSaveAsync !== undefined;
348
350
  this.storage = storage;
351
+ this.clearCallback = config.refreshClearAsync;
349
352
  this.rotationConfig = rotationConfig;
350
353
  this.currentTokens = {
351
354
  access_token: config.accessToken,
@@ -382,21 +385,82 @@ class TokenManager {
382
385
  if (!this.currentTokens) {
383
386
  throw new EtsyAuthError('No tokens available to refresh', 'NO_REFRESH_TOKEN');
384
387
  }
385
- this.refreshPromise = this.performTokenRefresh();
386
- try {
387
- const newTokens = await this.refreshPromise;
388
- this.currentTokens = newTokens;
389
- if (this.storage) {
390
- await this.storage.save(newTokens);
388
+ const generation = this.tokenMutationGeneration;
389
+ const refreshPromise = (this.pendingTokens
390
+ ? this.retryPendingTokenPersistence()
391
+ : this.performTokenRefresh().then(async (newTokens) => {
392
+ this.throwIfSuperseded(generation);
393
+ this.pendingTokens = newTokens;
394
+ return this.persistRefreshedTokens(newTokens, generation);
395
+ })).finally(() => {
396
+ this.refreshPromise = undefined;
397
+ });
398
+ this.refreshPromise = refreshPromise;
399
+ return refreshPromise;
400
+ }
401
+ throwIfSuperseded(generation) {
402
+ if (generation !== this.tokenMutationGeneration) {
403
+ throw new EtsyAuthError('Token refresh was superseded by a token update or clear', 'TOKEN_REFRESH_SUPERSEDED');
404
+ }
405
+ }
406
+ async repairPersistenceToCurrentTokens() {
407
+ while (true) {
408
+ const generation = this.tokenMutationGeneration;
409
+ const current = this.currentTokens ? { ...this.currentTokens } : null;
410
+ try {
411
+ if (current) {
412
+ if (this.storage)
413
+ await this.storage.save(current);
414
+ if (this.refreshCallback) {
415
+ await this.refreshCallback(current.access_token, current.refresh_token, current.expires_at);
416
+ }
417
+ }
418
+ else {
419
+ if (this.storage)
420
+ await this.storage.clear();
421
+ if (this.clearCallback)
422
+ await this.clearCallback();
423
+ else if (this.refreshClearRequired) {
424
+ throw new EtsyAuthError('A refresh persistence callback is configured without refreshClearAsync; durable tokens could not be cleared safely', 'TOKEN_CLEAR_CALLBACK_REQUIRED');
425
+ }
426
+ }
427
+ }
428
+ catch (error) {
429
+ if (generation !== this.tokenMutationGeneration)
430
+ continue;
431
+ throw error;
391
432
  }
392
- if (this.refreshCallback) {
393
- 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);
394
444
  }
395
- return newTokens;
396
445
  }
397
- finally {
398
- 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
+ }
399
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');
462
+ }
463
+ return this.persistRefreshedTokens(this.pendingTokens, this.tokenMutationGeneration);
400
464
  }
401
465
  async performTokenRefresh() {
402
466
  if (!this.currentTokens) {
@@ -410,6 +474,7 @@ class TokenManager {
410
474
  try {
411
475
  const response = await this.fetch('https://api.etsy.com/v3/public/oauth/token', {
412
476
  method: 'POST',
477
+ redirect: 'error',
413
478
  headers: {
414
479
  'Content-Type': 'application/x-www-form-urlencoded',
415
480
  'Accept': 'application/json'
@@ -439,7 +504,9 @@ class TokenManager {
439
504
  return this.currentTokens ? { ...this.currentTokens } : null;
440
505
  }
441
506
  updateTokens(tokens) {
507
+ this.tokenMutationGeneration += 1;
442
508
  this.currentTokens = { ...tokens };
509
+ this.pendingTokens = undefined;
443
510
  }
444
511
  isTokenExpired() {
445
512
  if (!this.currentTokens) {
@@ -459,10 +526,19 @@ class TokenManager {
459
526
  return now.getTime() >= (expiresAt.getTime() - bufferTime);
460
527
  }
461
528
  async clearTokens() {
529
+ this.tokenMutationGeneration += 1;
462
530
  this.currentTokens = null;
531
+ this.pendingTokens = undefined;
532
+ if (this.refreshPromise)
533
+ await this.refreshPromise.catch(() => undefined);
463
534
  if (this.storage) {
464
535
  await this.storage.clear();
465
536
  }
537
+ if (this.clearCallback)
538
+ await this.clearCallback();
539
+ else if (this.refreshClearRequired) {
540
+ throw new EtsyAuthError('A refresh persistence callback is configured without refreshClearAsync; durable tokens could not be cleared safely', 'TOKEN_CLEAR_CALLBACK_REQUIRED');
541
+ }
466
542
  }
467
543
  getTimeUntilExpiration() {
468
544
  if (!this.currentTokens) {
@@ -702,13 +778,24 @@ const ETSY_RATE_LIMITS = {
702
778
  MAX_REQUESTS_PER_SECOND: 5,
703
779
  MIN_REQUEST_INTERVAL: 200,
704
780
  };
781
+ const QPD_WINDOW_MS$1 = 24 * 60 * 60 * 1000;
705
782
  class EtsyRateLimiter {
706
783
  constructor(config) {
707
- this.requestCount = 0;
708
- this.dailyReset = new Date();
784
+ this.requestTimestamps = [];
785
+ this.uncertainRequestTimestamps = [];
709
786
  this.lastRequestTime = 0;
710
787
  this.isHeaderBasedLimiting = false;
788
+ this.headerQuotaAvailableAt = 0;
789
+ this.retryNotBefore = 0;
790
+ this.quotaProbeBackoffMs = 60000;
791
+ this.nextReservationId = 0;
792
+ this.latestHeaderReservationId = 0;
793
+ this.inFlightReservations = new Map();
794
+ this.quotaProbeInFlight = false;
795
+ this.stateWaiters = new Set();
711
796
  this.currentRetryCount = 0;
797
+ this.reservationQueue = Promise.resolve();
798
+ const overrides = Object.fromEntries(Object.entries(config ?? {}).filter(([, value]) => value !== undefined));
712
799
  this.config = {
713
800
  maxRequestsPerDay: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
714
801
  maxRequestsPerSecond: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
@@ -719,21 +806,155 @@ class EtsyRateLimiter {
719
806
  jitter: 0.1,
720
807
  qpdWarningThreshold: 80,
721
808
  onApproachingLimit: undefined,
722
- ...config
809
+ ...overrides
723
810
  };
724
- this.setNextDailyReset();
725
811
  }
726
- setNextDailyReset() {
727
- const now = new Date();
728
- this.dailyReset = new Date(now);
729
- this.dailyReset.setUTCDate(this.dailyReset.getUTCDate() + 1);
730
- this.dailyReset.setUTCHours(0, 0, 0, 0);
812
+ pruneRequestTimestamps(now) {
813
+ const oldestAllowed = now - QPD_WINDOW_MS$1;
814
+ while (this.requestTimestamps[0] !== undefined && this.requestTimestamps[0] <= oldestAllowed) {
815
+ this.requestTimestamps.shift();
816
+ }
817
+ while (this.uncertainRequestTimestamps[0] !== undefined && this.uncertainRequestTimestamps[0] <= oldestAllowed) {
818
+ this.uncertainRequestTimestamps.shift();
819
+ }
820
+ }
821
+ getLocalResetTime(now) {
822
+ this.pruneRequestTimestamps(now);
823
+ const oldestRequest = this.requestTimestamps[0] ?? now;
824
+ return new Date(oldestRequest + QPD_WINDOW_MS$1);
825
+ }
826
+ getLocalRemainingRequests(now) {
827
+ this.pruneRequestTimestamps(now);
828
+ const localLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
829
+ return Math.max(0, localLimit - this.requestTimestamps.length);
731
830
  }
732
- updateFromHeaders(headers) {
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
+ }
937
+ }
938
+ releaseRequestSlot(reservationId) {
939
+ const reservation = this.inFlightReservations.get(reservationId);
940
+ if (!reservation)
941
+ return;
942
+ this.uncertainRequestTimestamps.push(reservation.startedAt);
943
+ this.finishReservation(reservationId, false);
944
+ }
945
+ updateFromHeaders(headers, reservationId) {
946
+ const matchedReservationId = reservationId;
947
+ const isQuotaProbe = matchedReservationId !== undefined &&
948
+ this.inFlightReservations.get(matchedReservationId)?.isQuotaProbe === true;
733
949
  if (!headers) {
950
+ if (matchedReservationId !== undefined)
951
+ this.finishReservation(matchedReservationId, false);
734
952
  return;
735
953
  }
736
954
  const parsed = this.parseRateLimitHeaders(headers);
955
+ if (matchedReservationId !== undefined) {
956
+ this.finishReservation(matchedReservationId, parsed.remainingToday !== undefined);
957
+ }
737
958
  if (parsed.limitPerSecond !== undefined) {
738
959
  this.headerLimitPerSecond = parsed.limitPerSecond;
739
960
  this.isHeaderBasedLimiting = true;
@@ -746,9 +967,39 @@ class EtsyRateLimiter {
746
967
  this.isHeaderBasedLimiting = true;
747
968
  }
748
969
  if (parsed.remainingToday !== undefined) {
749
- this.headerRemainingToday = parsed.remainingToday;
970
+ const observationId = matchedReservationId ?? this.nextReservationId;
971
+ if (isQuotaProbe && parsed.remainingToday > 0) {
972
+ this.uncertainRequestTimestamps = [];
973
+ }
974
+ const exhaustedByEarlierObservation = this.headerRemainingToday === 0 &&
975
+ parsed.remainingToday > 0 && matchedReservationId !== undefined && !isQuotaProbe;
976
+ const exhaustedByUncorrelatedObservation = this.headerRemainingToday === 0 &&
977
+ parsed.remainingToday > 0 && matchedReservationId === undefined;
978
+ if (exhaustedByEarlierObservation || exhaustedByUncorrelatedObservation) ;
979
+ else if (isQuotaProbe || this.headerRemainingToday === undefined) {
980
+ this.headerRemainingToday = parsed.remainingToday;
981
+ this.latestHeaderReservationId = Math.max(this.latestHeaderReservationId, observationId);
982
+ }
983
+ else if (parsed.remainingToday < this.headerRemainingToday) {
984
+ this.headerRemainingToday = parsed.remainingToday;
985
+ this.latestHeaderReservationId = Math.max(this.latestHeaderReservationId, observationId);
986
+ }
987
+ if (this.headerRemainingToday !== undefined && this.headerRemainingToday > 0) {
988
+ this.headerQuotaAvailableAt = 0;
989
+ this.quotaProbeBackoffMs = 60000;
990
+ }
991
+ else if (this.headerQuotaAvailableAt === 0) {
992
+ this.scheduleQuotaProbe();
993
+ }
750
994
  this.checkApproachingLimit();
751
995
  }
996
+ else if (matchedReservationId !== undefined && this.headerRemainingToday !== undefined) {
997
+ this.headerRemainingToday = Math.max(0, this.headerRemainingToday - 1);
998
+ if (this.headerRemainingToday === 0 && this.headerQuotaAvailableAt === 0) {
999
+ this.scheduleQuotaProbe();
1000
+ }
1001
+ }
1002
+ this.notifyStateWaiters();
752
1003
  }
753
1004
  parseRateLimitHeaders(headers) {
754
1005
  const getHeader = (name) => {
@@ -788,18 +1039,27 @@ class EtsyRateLimiter {
788
1039
  this.config.onApproachingLimit(remaining, limit, percentageUsed);
789
1040
  }
790
1041
  }
791
- async handleRateLimitResponse(headers) {
1042
+ async handleRateLimitResponse(headers, reservationId, retryAttempt) {
792
1043
  const parsed = headers ? this.parseRateLimitHeaders(headers) : {};
793
- this.updateFromHeaders(headers);
794
- if (this.headerRemainingToday === 0) {
795
- throw new EtsyRateLimitError('Daily rate limit exhausted. No requests remaining until limit resets.', parsed.retryAfter, 'qpd_exhausted');
796
- }
797
- this.currentRetryCount++;
798
- if (this.currentRetryCount > this.config.maxRetries) {
1044
+ this.updateFromHeaders(headers, reservationId);
1045
+ if (parsed.remainingToday === 0) {
1046
+ if (parsed.retryAfter !== undefined) {
1047
+ this.headerQuotaAvailableAt = Date.now() + parsed.retryAfter * 1000;
1048
+ this.retryNotBefore = Math.max(this.retryNotBefore, this.headerQuotaAvailableAt);
1049
+ }
799
1050
  this.currentRetryCount = 0;
1051
+ throw new EtsyRateLimitError('Daily rate limit exhausted according to Etsy response headers.', parsed.retryAfter, 'qpd_exhausted');
1052
+ }
1053
+ const attempt = retryAttempt ?? this.currentRetryCount + 1;
1054
+ if (retryAttempt === undefined)
1055
+ this.currentRetryCount = attempt;
1056
+ const delayMs = this.calculateBackoffDelay(attempt, parsed.retryAfter);
1057
+ this.retryNotBefore = Math.max(this.retryNotBefore, Date.now() + delayMs);
1058
+ if (attempt > this.config.maxRetries) {
1059
+ if (retryAttempt === undefined)
1060
+ this.currentRetryCount = 0;
800
1061
  throw new EtsyRateLimitError(`Max retries (${this.config.maxRetries}) exceeded for rate limit`, parsed.retryAfter, 'qps_exhausted');
801
1062
  }
802
- const delayMs = this.calculateBackoffDelay(this.currentRetryCount, parsed.retryAfter);
803
1063
  return { shouldRetry: true, delayMs };
804
1064
  }
805
1065
  calculateBackoffDelay(attempt, retryAfterSeconds) {
@@ -812,7 +1072,7 @@ class EtsyRateLimiter {
812
1072
  const randomJitter = Math.random() * jitterAmount * 2 - jitterAmount;
813
1073
  delay += randomJitter;
814
1074
  }
815
- return Math.max(0, Math.floor(delay));
1075
+ return Math.max(serverSuggestedMs, 0, Math.floor(delay));
816
1076
  }
817
1077
  resetRetryCount() {
818
1078
  this.currentRetryCount = 0;
@@ -830,41 +1090,26 @@ class EtsyRateLimiter {
830
1090
  return this.config.minRequestInterval;
831
1091
  }
832
1092
  async waitForRateLimit() {
833
- const now = Date.now();
834
- if (now >= this.dailyReset.getTime()) {
835
- this.requestCount = 0;
836
- this.setNextDailyReset();
837
- }
838
- const effectiveDailyLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
839
- const effectiveRemaining = this.headerRemainingToday ??
840
- (effectiveDailyLimit - this.requestCount);
841
- if (effectiveRemaining <= 0) {
842
- const timeUntilReset = this.dailyReset.getTime() - now;
843
- throw new EtsyRateLimitError(`Daily rate limit exhausted (${effectiveDailyLimit} requests). ` +
844
- `Reset in approximately ${Math.ceil(timeUntilReset / 1000 / 60)} minutes.`, Math.ceil(timeUntilReset / 1000), 'qpd_exhausted');
845
- }
846
- const minInterval = this.getEffectiveMinInterval();
847
- const timeSinceLastRequest = now - this.lastRequestTime;
848
- if (timeSinceLastRequest < minInterval) {
849
- const waitTime = minInterval - timeSinceLastRequest;
850
- await new Promise(resolve => setTimeout(resolve, waitTime));
851
- }
852
- this.requestCount++;
853
- this.lastRequestTime = Date.now();
1093
+ const reservationId = await this.acquireRequestSlot();
1094
+ this.finishReservation(reservationId, false);
1095
+ }
1096
+ async waitForRateLimitWithReservation() {
1097
+ return this.acquireRequestSlot();
854
1098
  }
855
1099
  getRateLimitStatus() {
856
1100
  const now = Date.now();
857
- if (now >= this.dailyReset.getTime()) {
858
- this.requestCount = 0;
859
- this.setNextDailyReset();
860
- }
861
- const effectiveRemaining = this.headerRemainingToday ??
862
- Math.max(0, this.config.maxRequestsPerDay - this.requestCount);
1101
+ this.pruneRequestTimestamps(now);
1102
+ this.refreshExpiredHeaderQuota(now);
1103
+ const effectiveRemaining = this.getEffectiveRemainingRequests(now);
1104
+ const qpdProbeReady = this.isQuotaProbeReady(now);
863
1105
  return {
864
1106
  remainingRequests: effectiveRemaining,
865
- resetTime: this.dailyReset,
866
- canMakeRequest: effectiveRemaining > 0 &&
867
- (now - this.lastRequestTime) >= this.getEffectiveMinInterval(),
1107
+ resetTime: this.headerRemainingToday === 0
1108
+ ? new Date(this.headerQuotaAvailableAt || now)
1109
+ : this.getLocalResetTime(now),
1110
+ canMakeRequest: (effectiveRemaining > 0 || qpdProbeReady) &&
1111
+ (now - this.lastRequestTime) >= this.getEffectiveMinInterval() &&
1112
+ now >= this.retryNotBefore,
868
1113
  isFromHeaders: this.isHeaderBasedLimiting,
869
1114
  limitPerSecond: this.headerLimitPerSecond,
870
1115
  remainingThisSecond: this.headerRemainingThisSecond,
@@ -872,41 +1117,47 @@ class EtsyRateLimiter {
872
1117
  };
873
1118
  }
874
1119
  getRemainingRequests() {
875
- return this.headerRemainingToday ??
876
- Math.max(0, this.config.maxRequestsPerDay - this.requestCount);
1120
+ const now = Date.now();
1121
+ this.pruneRequestTimestamps(now);
1122
+ this.refreshExpiredHeaderQuota(now);
1123
+ return this.getEffectiveRemainingRequests(now);
877
1124
  }
878
1125
  reset() {
879
- this.requestCount = 0;
1126
+ this.requestTimestamps = [];
880
1127
  this.lastRequestTime = 0;
881
1128
  this.currentRetryCount = 0;
1129
+ this.quotaProbeBackoffMs = 60000;
1130
+ this.headerQuotaAvailableAt = 0;
1131
+ this.retryNotBefore = 0;
882
1132
  this.headerLimitPerSecond = undefined;
883
1133
  this.headerRemainingThisSecond = undefined;
884
1134
  this.headerLimitPerDay = undefined;
885
1135
  this.headerRemainingToday = undefined;
886
1136
  this.isHeaderBasedLimiting = false;
887
- this.setNextDailyReset();
1137
+ this.nextReservationId = 0;
1138
+ this.latestHeaderReservationId = 0;
1139
+ this.inFlightReservations.clear();
1140
+ this.uncertainRequestTimestamps = [];
1141
+ this.quotaProbeInFlight = false;
1142
+ this.notifyStateWaiters();
888
1143
  }
889
1144
  canMakeRequest() {
890
1145
  const now = Date.now();
891
- if (now >= this.dailyReset.getTime()) {
892
- this.requestCount = 0;
893
- this.setNextDailyReset();
894
- }
895
- const effectiveRemaining = this.headerRemainingToday ??
896
- (this.config.maxRequestsPerDay - this.requestCount);
897
- if (effectiveRemaining <= 0) {
1146
+ this.pruneRequestTimestamps(now);
1147
+ this.refreshExpiredHeaderQuota(now);
1148
+ const effectiveRemaining = this.getEffectiveRemainingRequests(now);
1149
+ const qpdProbeReady = this.isQuotaProbeReady(now);
1150
+ if (effectiveRemaining <= 0 && !qpdProbeReady) {
898
1151
  return false;
899
1152
  }
900
- return (now - this.lastRequestTime) >= this.getEffectiveMinInterval();
1153
+ return (now - this.lastRequestTime) >= this.getEffectiveMinInterval() &&
1154
+ now >= this.retryNotBefore;
901
1155
  }
902
1156
  getTimeUntilNextRequest() {
903
1157
  const now = Date.now();
904
1158
  const timeSinceLastRequest = now - this.lastRequestTime;
905
1159
  const minInterval = this.getEffectiveMinInterval();
906
- if (timeSinceLastRequest >= minInterval) {
907
- return 0;
908
- }
909
- return minInterval - timeSinceLastRequest;
1160
+ return Math.max(0, minInterval - timeSinceLastRequest, this.retryNotBefore - now);
910
1161
  }
911
1162
  getConfig() {
912
1163
  return { ...this.config };
@@ -1356,7 +1607,8 @@ class EtsyClient {
1356
1607
  this.logger = new DefaultLogger();
1357
1608
  this.keystring = config.keystring;
1358
1609
  this.sharedSecret = config.sharedSecret;
1359
- if (config.rateLimiting?.enabled !== false) {
1610
+ this.rateLimitingEnabled = config.rateLimiting?.enabled !== false;
1611
+ if (this.rateLimitingEnabled) {
1360
1612
  this.rateLimiter = new EtsyRateLimiter({
1361
1613
  maxRequestsPerDay: config.rateLimiting?.maxRequestsPerDay || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
1362
1614
  maxRequestsPerSecond: config.rateLimiting?.maxRequestsPerSecond || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
@@ -1381,11 +1633,39 @@ class EtsyClient {
1381
1633
  stopOnError: false
1382
1634
  });
1383
1635
  }
1636
+ async acquireRateLimitSlot() {
1637
+ return this.rateLimitingEnabled
1638
+ ? this.rateLimiter.acquireRequestSlot()
1639
+ : undefined;
1640
+ }
1641
+ async fetchOnceWithRateLimit(url, options) {
1642
+ const reservationId = await this.acquireRateLimitSlot();
1643
+ let response;
1644
+ try {
1645
+ response = await this.fetch(url, options);
1646
+ }
1647
+ catch (error) {
1648
+ if (reservationId !== undefined)
1649
+ this.rateLimiter.releaseRequestSlot(reservationId);
1650
+ throw error;
1651
+ }
1652
+ if (response.status === 429) {
1653
+ await this.rateLimiter.handleRateLimitResponse(response.headers, reservationId);
1654
+ this.rateLimiter.resetRetryCount();
1655
+ }
1656
+ else {
1657
+ this.rateLimiter.updateFromHeaders(response.headers, reservationId);
1658
+ if (response.ok)
1659
+ this.rateLimiter.resetRetryCount();
1660
+ }
1661
+ return response;
1662
+ }
1384
1663
  async makeRequest(endpoint, options = {}, useCache = true) {
1385
1664
  const url = `${this.baseUrl}${endpoint}`;
1386
1665
  const requestOptions = {
1387
1666
  method: 'GET',
1388
- ...options
1667
+ ...options,
1668
+ redirect: 'error'
1389
1669
  };
1390
1670
  const cacheKey = `${url}:${JSON.stringify(requestOptions)}`;
1391
1671
  if (useCache && this.cache && requestOptions.method === 'GET') {
@@ -1399,7 +1679,6 @@ class EtsyClient {
1399
1679
  }
1400
1680
  }
1401
1681
  }
1402
- await this.rateLimiter.waitForRateLimit();
1403
1682
  const accessToken = await this.tokenManager.getAccessToken();
1404
1683
  const headers = {
1405
1684
  'Authorization': `Bearer ${accessToken}`,
@@ -1411,22 +1690,38 @@ class EtsyClient {
1411
1690
  return this.executeWithRetry(url, { ...requestOptions, headers }, cacheKey, useCache);
1412
1691
  }
1413
1692
  async executeWithRetry(url, options, cacheKey, useCache) {
1693
+ let retryAttempt = 0;
1414
1694
  while (true) {
1415
1695
  try {
1416
- const response = await this.fetch(url, options);
1696
+ const reservationId = await this.acquireRateLimitSlot();
1697
+ let response;
1698
+ try {
1699
+ response = await this.fetch(url, options);
1700
+ }
1701
+ catch (error) {
1702
+ if (reservationId !== undefined)
1703
+ this.rateLimiter.releaseRequestSlot(reservationId);
1704
+ throw error;
1705
+ }
1417
1706
  if (response.status === 429) {
1418
- const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers);
1707
+ const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers, reservationId, ++retryAttempt);
1419
1708
  if (shouldRetry) {
1709
+ const method = (options.method ?? 'GET').toUpperCase();
1710
+ if (method !== 'GET') {
1711
+ this.rateLimiter.resetRetryCount();
1712
+ const errorText = await response.text();
1713
+ throw new EtsyApiError(`Etsy API error: 429 ${response.statusText}; automatic retry skipped for ${method} requests`, 429, errorText);
1714
+ }
1420
1715
  this.logger.warn(`Rate limited. Retrying in ${delayMs}ms...`);
1421
1716
  await this.sleep(delayMs);
1422
1717
  continue;
1423
1718
  }
1424
1719
  }
1720
+ this.rateLimiter.updateFromHeaders(response.headers, reservationId);
1425
1721
  if (!response.ok) {
1426
1722
  const errorText = await response.text();
1427
1723
  throw new EtsyApiError(`Etsy API error: ${response.status} ${response.statusText}`, response.status, errorText);
1428
1724
  }
1429
- this.rateLimiter.updateFromHeaders(response.headers);
1430
1725
  this.rateLimiter.resetRetryCount();
1431
1726
  if (response.status === 204) {
1432
1727
  return undefined;
@@ -1729,9 +2024,87 @@ class EtsyClient {
1729
2024
  async updateListingInventory(listingId, params, options) {
1730
2025
  const legacy = options?.legacy;
1731
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
+ };
1732
2105
  return this.makeRequest(`/listings/${listingId}/inventory${query}`, {
1733
2106
  method: 'PUT',
1734
- body: JSON.stringify(params)
2107
+ body: JSON.stringify(body)
1735
2108
  }, false);
1736
2109
  }
1737
2110
  async uploadListingImage(shopId, listingId, imageData, params) {
@@ -1746,9 +2119,8 @@ class EtsyClient {
1746
2119
  if (params?.alt_text)
1747
2120
  formData.append('alt_text', params.alt_text);
1748
2121
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/images`;
1749
- await this.rateLimiter.waitForRateLimit();
1750
2122
  const accessToken = await this.tokenManager.getAccessToken();
1751
- const response = await this.fetch(url, {
2123
+ const response = await this.fetchOnceWithRateLimit(url, {
1752
2124
  method: 'POST',
1753
2125
  headers: {
1754
2126
  'Authorization': `Bearer ${accessToken}`,
@@ -1943,10 +2315,11 @@ class EtsyClient {
1943
2315
  }
1944
2316
  async getShopPayment(shopId, paymentId) {
1945
2317
  const payments = await this.getPayments(shopId, [Number(paymentId)]);
1946
- if (payments.length === 0) {
2318
+ const payment = payments[0];
2319
+ if (!payment) {
1947
2320
  throw new EtsyApiError('Payment not found', 404);
1948
2321
  }
1949
- return payments[0];
2322
+ return payment;
1950
2323
  }
1951
2324
  async getBuyerTaxonomyNodes() {
1952
2325
  const response = await this.makeRequest('/buyer-taxonomy/nodes');
@@ -1969,9 +2342,8 @@ class EtsyClient {
1969
2342
  body.append('scale_id', scaleId.toString());
1970
2343
  }
1971
2344
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/properties/${propertyId}`;
1972
- await this.rateLimiter.waitForRateLimit();
1973
2345
  const accessToken = await this.tokenManager.getAccessToken();
1974
- const response = await this.fetch(url, {
2346
+ const response = await this.fetchOnceWithRateLimit(url, {
1975
2347
  method: 'PUT',
1976
2348
  headers: {
1977
2349
  'Authorization': `Bearer ${accessToken}`,
@@ -1980,8 +2352,6 @@ class EtsyClient {
1980
2352
  },
1981
2353
  body: body.toString()
1982
2354
  });
1983
- this.rateLimiter.updateFromHeaders(response.headers);
1984
- this.rateLimiter.resetRetryCount();
1985
2355
  if (!response.ok) {
1986
2356
  const errorText = await response.text();
1987
2357
  throw new EtsyApiError(`Failed to update listing property: ${response.status} ${response.statusText}`, response.status, errorText);
@@ -2081,9 +2451,8 @@ class EtsyClient {
2081
2451
  if (params?.listing_file_id !== undefined)
2082
2452
  formData.append('listing_file_id', params.listing_file_id.toString());
2083
2453
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/files`;
2084
- await this.rateLimiter.waitForRateLimit();
2085
2454
  const accessToken = await this.tokenManager.getAccessToken();
2086
- const response = await this.fetch(url, {
2455
+ const response = await this.fetchOnceWithRateLimit(url, {
2087
2456
  method: 'POST',
2088
2457
  headers: {
2089
2458
  'Authorization': `Bearer ${accessToken}`,
@@ -2115,9 +2484,8 @@ class EtsyClient {
2115
2484
  if (params?.video_id !== undefined)
2116
2485
  formData.append('video_id', params.video_id.toString());
2117
2486
  const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/videos`;
2118
- await this.rateLimiter.waitForRateLimit();
2119
2487
  const accessToken = await this.tokenManager.getAccessToken();
2120
- const response = await this.fetch(url, {
2488
+ const response = await this.fetchOnceWithRateLimit(url, {
2121
2489
  method: 'POST',
2122
2490
  headers: {
2123
2491
  'Authorization': `Bearer ${accessToken}`,
@@ -2539,6 +2907,7 @@ class AuthHelper {
2539
2907
  assertFetchSupport();
2540
2908
  const response = await fetch('https://api.etsy.com/v3/public/oauth/token', {
2541
2909
  method: 'POST',
2910
+ redirect: 'error',
2542
2911
  headers: {
2543
2912
  'Content-Type': 'application/x-www-form-urlencoded',
2544
2913
  'Accept': 'application/json'
@@ -2631,18 +3000,17 @@ const COMMON_SCOPE_COMBINATIONS = {
2631
3000
  ]
2632
3001
  };
2633
3002
 
3003
+ const QPD_WINDOW_MS = 24 * 60 * 60 * 1000;
2634
3004
  class GlobalRequestQueue {
2635
3005
  constructor() {
2636
3006
  this.queue = [];
2637
3007
  this.processing = false;
2638
3008
  this.rateLimits = new Map();
2639
- this.requestCount = 0;
2640
- this.dailyReset = new Date();
3009
+ this.requestTimestamps = [];
2641
3010
  this.lastRequestTime = 0;
2642
3011
  this.maxRequestsPerDay = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY;
2643
3012
  this.maxRequestsPerSecond = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND;
2644
3013
  this.minRequestInterval = ETSY_RATE_LIMITS.MIN_REQUEST_INTERVAL;
2645
- this.setNextDailyReset();
2646
3014
  }
2647
3015
  static getInstance() {
2648
3016
  if (!this.instance) {
@@ -2674,11 +3042,13 @@ class GlobalRequestQueue {
2674
3042
  });
2675
3043
  }
2676
3044
  getStatus() {
3045
+ const now = Date.now();
3046
+ this.pruneRequestTimestamps(now);
2677
3047
  return {
2678
3048
  queueLength: this.queue.length,
2679
3049
  processing: this.processing,
2680
- remainingRequests: Math.max(0, this.maxRequestsPerDay - this.requestCount),
2681
- resetTime: this.dailyReset,
3050
+ remainingRequests: Math.max(0, this.maxRequestsPerDay - this.requestTimestamps.length),
3051
+ resetTime: this.getNextRequestExpiry(now),
2682
3052
  };
2683
3053
  }
2684
3054
  clear() {
@@ -2694,16 +3064,13 @@ class GlobalRequestQueue {
2694
3064
  this.processing = true;
2695
3065
  try {
2696
3066
  while (this.queue.length > 0) {
2697
- if (Date.now() >= this.dailyReset.getTime()) {
2698
- this.requestCount = 0;
2699
- this.setNextDailyReset();
2700
- }
2701
- if (this.requestCount >= this.maxRequestsPerDay) {
2702
- const timeUntilReset = this.dailyReset.getTime() - Date.now();
2703
- console.warn(`Daily rate limit reached. Waiting ${Math.ceil(timeUntilReset / 1000 / 60)} minutes until reset.`);
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.`);
2704
3072
  await this.delay(timeUntilReset);
2705
- this.requestCount = 0;
2706
- this.setNextDailyReset();
3073
+ continue;
2707
3074
  }
2708
3075
  await this.waitForRateLimit();
2709
3076
  this.queue.sort((a, b) => {
@@ -2738,7 +3105,7 @@ class GlobalRequestQueue {
2738
3105
  }
2739
3106
  });
2740
3107
  try {
2741
- result = await Promise.race([item.request(), timeoutPromise]);
3108
+ result = await Promise.race([this.startRequest(item.request), timeoutPromise]);
2742
3109
  }
2743
3110
  finally {
2744
3111
  if (timeoutId) {
@@ -2747,11 +3114,9 @@ class GlobalRequestQueue {
2747
3114
  }
2748
3115
  }
2749
3116
  else {
2750
- result = await item.request();
3117
+ result = await this.startRequest(item.request);
2751
3118
  }
2752
3119
  item.resolve(result);
2753
- this.requestCount++;
2754
- this.lastRequestTime = Date.now();
2755
3120
  }
2756
3121
  catch (error) {
2757
3122
  if (error instanceof EtsyRateLimitError) {
@@ -2771,7 +3136,7 @@ class GlobalRequestQueue {
2771
3136
  const globalRateLimit = this.rateLimits.get('global');
2772
3137
  if (globalRateLimit && now < globalRateLimit.resetAt) {
2773
3138
  const waitTime = globalRateLimit.resetAt - now;
2774
- console.log(`Global rate limit active. Waiting ${waitTime}ms`);
3139
+ console.warn(`Global rate limit active. Waiting ${waitTime}ms`);
2775
3140
  await this.delay(waitTime);
2776
3141
  }
2777
3142
  const timeSinceLastRequest = now - this.lastRequestTime;
@@ -2789,11 +3154,23 @@ class GlobalRequestQueue {
2789
3154
  });
2790
3155
  }
2791
3156
  }
2792
- setNextDailyReset() {
2793
- const now = new Date();
2794
- this.dailyReset = new Date(now);
2795
- this.dailyReset.setUTCDate(this.dailyReset.getUTCDate() + 1);
2796
- this.dailyReset.setUTCHours(0, 0, 0, 0);
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();
2797
3174
  }
2798
3175
  generateId() {
2799
3176
  return `${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;