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