@profplum700/etsy-v3-api-client 3.0.1 → 4.1.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/CHANGELOG.md +20 -0
- package/README.md +43 -11
- package/dist/browser.esm.js +508 -124
- package/dist/browser.esm.js.map +1 -1
- package/dist/browser.umd.js +1 -1
- package/dist/browser.umd.js.map +1 -1
- package/dist/index.cjs +508 -124
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +54 -18
- package/dist/index.esm.js +508 -124
- package/dist/index.esm.js.map +1 -1
- package/dist/node.cjs +508 -124
- package/dist/node.cjs.map +1 -1
- package/dist/node.esm.js +508 -124
- package/dist/node.esm.js.map +1 -1
- package/package.json +52 -46
- package/dist/.tsbuildinfo +0 -1
package/dist/browser.esm.js
CHANGED
|
@@ -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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
this.
|
|
382
|
-
|
|
383
|
-
|
|
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
|
+
}
|
|
384
420
|
}
|
|
385
|
-
|
|
386
|
-
|
|
421
|
+
catch (error) {
|
|
422
|
+
if (generation !== this.tokenMutationGeneration)
|
|
423
|
+
continue;
|
|
424
|
+
throw error;
|
|
387
425
|
}
|
|
388
|
-
|
|
426
|
+
if (generation === this.tokenMutationGeneration)
|
|
427
|
+
return;
|
|
389
428
|
}
|
|
390
|
-
|
|
391
|
-
|
|
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);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
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
|
+
}
|
|
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');
|
|
392
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.
|
|
670
|
-
this.
|
|
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
|
-
...
|
|
771
|
+
...overrides
|
|
685
772
|
};
|
|
686
|
-
this.setNextDailyReset();
|
|
687
773
|
}
|
|
688
|
-
|
|
689
|
-
const
|
|
690
|
-
this.
|
|
691
|
-
|
|
692
|
-
|
|
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);
|
|
792
|
+
}
|
|
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();
|
|
693
839
|
}
|
|
694
|
-
|
|
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
|
-
|
|
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 (
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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.
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
|
|
838
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
|
@@ -1686,11 +1981,96 @@ class EtsyClient {
|
|
|
1686
1981
|
await this.makeRequest(`/listings/${listingId}`, { method: 'DELETE' }, false);
|
|
1687
1982
|
}
|
|
1688
1983
|
async updateListingInventory(listingId, params, options) {
|
|
1689
|
-
const
|
|
1690
|
-
|
|
1984
|
+
const searchParams = new URLSearchParams();
|
|
1985
|
+
if (options?.legacy !== undefined) {
|
|
1986
|
+
searchParams.set('legacy', options.legacy.toString());
|
|
1987
|
+
}
|
|
1988
|
+
if (options?.enableScaleValidation !== undefined) {
|
|
1989
|
+
searchParams.set('enable_scale_validation', options.enableScaleValidation.toString());
|
|
1990
|
+
}
|
|
1991
|
+
const queryString = searchParams.toString();
|
|
1992
|
+
const query = queryString ? `?${queryString}` : '';
|
|
1993
|
+
if (!Array.isArray(params.products) || params.products.length === 0) {
|
|
1994
|
+
throw new TypeError('Inventory update must contain at least one product.');
|
|
1995
|
+
}
|
|
1996
|
+
const body = {
|
|
1997
|
+
products: params.products.map((product, productIndex) => {
|
|
1998
|
+
if (product.is_deleted === true) {
|
|
1999
|
+
throw new TypeError(`Inventory product ${productIndex} is deleted and cannot be submitted for replacement.`);
|
|
2000
|
+
}
|
|
2001
|
+
if (!Array.isArray(product.offerings) || product.offerings.length === 0) {
|
|
2002
|
+
throw new TypeError(`Inventory product ${productIndex} must contain at least one offering.`);
|
|
2003
|
+
}
|
|
2004
|
+
if (!Array.isArray(product.property_values)) {
|
|
2005
|
+
throw new TypeError(`Inventory product ${productIndex} must include property_values; pass [] when it has no variation properties.`);
|
|
2006
|
+
}
|
|
2007
|
+
if (product.sku !== undefined && product.sku !== null && typeof product.sku !== 'string') {
|
|
2008
|
+
throw new TypeError(`Inventory product ${productIndex} sku must be a string or null.`);
|
|
2009
|
+
}
|
|
2010
|
+
return {
|
|
2011
|
+
...(product.sku !== undefined ? { sku: product.sku } : {}),
|
|
2012
|
+
property_values: product.property_values.map((value, valueIndex) => {
|
|
2013
|
+
if (!Number.isInteger(value.property_id) || value.property_id < 1) {
|
|
2014
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_id must be a positive integer.`);
|
|
2015
|
+
}
|
|
2016
|
+
if (!Array.isArray(value.value_ids) || value.value_ids.some((id) => !Number.isInteger(id))) {
|
|
2017
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].value_ids must be an integer array.`);
|
|
2018
|
+
}
|
|
2019
|
+
if (!Array.isArray(value.values) || value.values.some((entry) => typeof entry !== 'string')) {
|
|
2020
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].values must be a string array.`);
|
|
2021
|
+
}
|
|
2022
|
+
if (value.property_name !== undefined && typeof value.property_name !== 'string') {
|
|
2023
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_name must be a string.`);
|
|
2024
|
+
}
|
|
2025
|
+
if (value.scale_id !== undefined && value.scale_id !== null &&
|
|
2026
|
+
(!Number.isInteger(value.scale_id) || value.scale_id < 1)) {
|
|
2027
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].scale_id must be a positive integer or null.`);
|
|
2028
|
+
}
|
|
2029
|
+
return {
|
|
2030
|
+
property_id: value.property_id,
|
|
2031
|
+
...(value.property_name !== undefined ? { property_name: value.property_name } : {}),
|
|
2032
|
+
...(value.scale_id !== undefined ? { scale_id: value.scale_id } : {}),
|
|
2033
|
+
value_ids: value.value_ids,
|
|
2034
|
+
values: value.values,
|
|
2035
|
+
};
|
|
2036
|
+
}),
|
|
2037
|
+
offerings: product.offerings.map((offering, offeringIndex) => {
|
|
2038
|
+
if (offering.is_deleted === true) {
|
|
2039
|
+
throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is deleted and cannot be submitted for replacement.`);
|
|
2040
|
+
}
|
|
2041
|
+
if (typeof offering.price !== 'number' || !Number.isFinite(offering.price)) {
|
|
2042
|
+
throw new TypeError('Inventory offering price must be a finite decimal number in the listing currency.');
|
|
2043
|
+
}
|
|
2044
|
+
if (!Number.isInteger(offering.quantity)) {
|
|
2045
|
+
throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} quantity must be an integer.`);
|
|
2046
|
+
}
|
|
2047
|
+
if (typeof offering.is_enabled !== 'boolean') {
|
|
2048
|
+
throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is_enabled must be a boolean.`);
|
|
2049
|
+
}
|
|
2050
|
+
if (offering.readiness_state_id === undefined ||
|
|
2051
|
+
(offering.readiness_state_id !== null &&
|
|
2052
|
+
(!Number.isInteger(offering.readiness_state_id) || offering.readiness_state_id < 1))) {
|
|
2053
|
+
throw new TypeError('Inventory offering readiness_state_id is required; use null when not set.');
|
|
2054
|
+
}
|
|
2055
|
+
return {
|
|
2056
|
+
price: offering.price,
|
|
2057
|
+
quantity: offering.quantity,
|
|
2058
|
+
is_enabled: offering.is_enabled,
|
|
2059
|
+
readiness_state_id: offering.readiness_state_id,
|
|
2060
|
+
};
|
|
2061
|
+
}),
|
|
2062
|
+
};
|
|
2063
|
+
}),
|
|
2064
|
+
...(params.price_on_property !== undefined ? { price_on_property: params.price_on_property } : {}),
|
|
2065
|
+
...(params.quantity_on_property !== undefined ? { quantity_on_property: params.quantity_on_property } : {}),
|
|
2066
|
+
...(params.sku_on_property !== undefined ? { sku_on_property: params.sku_on_property } : {}),
|
|
2067
|
+
...(params.readiness_state_on_property !== undefined
|
|
2068
|
+
? { readiness_state_on_property: params.readiness_state_on_property }
|
|
2069
|
+
: {}),
|
|
2070
|
+
};
|
|
1691
2071
|
return this.makeRequest(`/listings/${listingId}/inventory${query}`, {
|
|
1692
2072
|
method: 'PUT',
|
|
1693
|
-
body: JSON.stringify(
|
|
2073
|
+
body: JSON.stringify(body)
|
|
1694
2074
|
}, false);
|
|
1695
2075
|
}
|
|
1696
2076
|
async uploadListingImage(shopId, listingId, imageData, params) {
|
|
@@ -1705,9 +2085,8 @@ class EtsyClient {
|
|
|
1705
2085
|
if (params?.alt_text)
|
|
1706
2086
|
formData.append('alt_text', params.alt_text);
|
|
1707
2087
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/images`;
|
|
1708
|
-
await this.rateLimiter.waitForRateLimit();
|
|
1709
2088
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
1710
|
-
const response = await this.
|
|
2089
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
1711
2090
|
method: 'POST',
|
|
1712
2091
|
headers: {
|
|
1713
2092
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -1902,10 +2281,11 @@ class EtsyClient {
|
|
|
1902
2281
|
}
|
|
1903
2282
|
async getShopPayment(shopId, paymentId) {
|
|
1904
2283
|
const payments = await this.getPayments(shopId, [Number(paymentId)]);
|
|
1905
|
-
|
|
2284
|
+
const payment = payments[0];
|
|
2285
|
+
if (!payment) {
|
|
1906
2286
|
throw new EtsyApiError('Payment not found', 404);
|
|
1907
2287
|
}
|
|
1908
|
-
return
|
|
2288
|
+
return payment;
|
|
1909
2289
|
}
|
|
1910
2290
|
async getBuyerTaxonomyNodes() {
|
|
1911
2291
|
const response = await this.makeRequest('/buyer-taxonomy/nodes');
|
|
@@ -1928,9 +2308,8 @@ class EtsyClient {
|
|
|
1928
2308
|
body.append('scale_id', scaleId.toString());
|
|
1929
2309
|
}
|
|
1930
2310
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/properties/${propertyId}`;
|
|
1931
|
-
await this.rateLimiter.waitForRateLimit();
|
|
1932
2311
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
1933
|
-
const response = await this.
|
|
2312
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
1934
2313
|
method: 'PUT',
|
|
1935
2314
|
headers: {
|
|
1936
2315
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -1939,8 +2318,6 @@ class EtsyClient {
|
|
|
1939
2318
|
},
|
|
1940
2319
|
body: body.toString()
|
|
1941
2320
|
});
|
|
1942
|
-
this.rateLimiter.updateFromHeaders(response.headers);
|
|
1943
|
-
this.rateLimiter.resetRetryCount();
|
|
1944
2321
|
if (!response.ok) {
|
|
1945
2322
|
const errorText = await response.text();
|
|
1946
2323
|
throw new EtsyApiError(`Failed to update listing property: ${response.status} ${response.statusText}`, response.status, errorText);
|
|
@@ -2040,9 +2417,8 @@ class EtsyClient {
|
|
|
2040
2417
|
if (params?.listing_file_id !== undefined)
|
|
2041
2418
|
formData.append('listing_file_id', params.listing_file_id.toString());
|
|
2042
2419
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/files`;
|
|
2043
|
-
await this.rateLimiter.waitForRateLimit();
|
|
2044
2420
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
2045
|
-
const response = await this.
|
|
2421
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
2046
2422
|
method: 'POST',
|
|
2047
2423
|
headers: {
|
|
2048
2424
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -2074,9 +2450,8 @@ class EtsyClient {
|
|
|
2074
2450
|
if (params?.video_id !== undefined)
|
|
2075
2451
|
formData.append('video_id', params.video_id.toString());
|
|
2076
2452
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/videos`;
|
|
2077
|
-
await this.rateLimiter.waitForRateLimit();
|
|
2078
2453
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
2079
|
-
const response = await this.
|
|
2454
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
2080
2455
|
method: 'POST',
|
|
2081
2456
|
headers: {
|
|
2082
2457
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -2388,7 +2763,7 @@ async function sha256(data) {
|
|
|
2388
2763
|
const encoder = new TextEncoder();
|
|
2389
2764
|
const dataBytes = typeof data === 'string' ? encoder.encode(data) : data;
|
|
2390
2765
|
if (isBrowser) {
|
|
2391
|
-
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBytes);
|
|
2766
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', new Uint8Array(dataBytes));
|
|
2392
2767
|
return new Uint8Array(hashBuffer);
|
|
2393
2768
|
}
|
|
2394
2769
|
else {
|
|
@@ -2473,6 +2848,7 @@ class AuthHelper {
|
|
|
2473
2848
|
assertFetchSupport();
|
|
2474
2849
|
const response = await fetch('https://api.etsy.com/v3/public/oauth/token', {
|
|
2475
2850
|
method: 'POST',
|
|
2851
|
+
redirect: 'error',
|
|
2476
2852
|
headers: {
|
|
2477
2853
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
2478
2854
|
'Accept': 'application/json'
|
|
@@ -2565,18 +2941,17 @@ const COMMON_SCOPE_COMBINATIONS = {
|
|
|
2565
2941
|
]
|
|
2566
2942
|
};
|
|
2567
2943
|
|
|
2944
|
+
const QPD_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
2568
2945
|
class GlobalRequestQueue {
|
|
2569
2946
|
constructor() {
|
|
2570
2947
|
this.queue = [];
|
|
2571
2948
|
this.processing = false;
|
|
2572
2949
|
this.rateLimits = new Map();
|
|
2573
|
-
this.
|
|
2574
|
-
this.dailyReset = new Date();
|
|
2950
|
+
this.requestTimestamps = [];
|
|
2575
2951
|
this.lastRequestTime = 0;
|
|
2576
2952
|
this.maxRequestsPerDay = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY;
|
|
2577
2953
|
this.maxRequestsPerSecond = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND;
|
|
2578
2954
|
this.minRequestInterval = ETSY_RATE_LIMITS.MIN_REQUEST_INTERVAL;
|
|
2579
|
-
this.setNextDailyReset();
|
|
2580
2955
|
}
|
|
2581
2956
|
static getInstance() {
|
|
2582
2957
|
if (!this.instance) {
|
|
@@ -2608,11 +2983,13 @@ class GlobalRequestQueue {
|
|
|
2608
2983
|
});
|
|
2609
2984
|
}
|
|
2610
2985
|
getStatus() {
|
|
2986
|
+
const now = Date.now();
|
|
2987
|
+
this.pruneRequestTimestamps(now);
|
|
2611
2988
|
return {
|
|
2612
2989
|
queueLength: this.queue.length,
|
|
2613
2990
|
processing: this.processing,
|
|
2614
|
-
remainingRequests: Math.max(0, this.maxRequestsPerDay - this.
|
|
2615
|
-
resetTime: this.
|
|
2991
|
+
remainingRequests: Math.max(0, this.maxRequestsPerDay - this.requestTimestamps.length),
|
|
2992
|
+
resetTime: this.getNextRequestExpiry(now),
|
|
2616
2993
|
};
|
|
2617
2994
|
}
|
|
2618
2995
|
clear() {
|
|
@@ -2628,16 +3005,13 @@ class GlobalRequestQueue {
|
|
|
2628
3005
|
this.processing = true;
|
|
2629
3006
|
try {
|
|
2630
3007
|
while (this.queue.length > 0) {
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
const timeUntilReset = this.dailyReset.getTime() - Date.now();
|
|
2637
|
-
console.warn(`Daily rate limit reached. Waiting ${Math.ceil(timeUntilReset / 1000 / 60)} minutes until reset.`);
|
|
3008
|
+
const now = Date.now();
|
|
3009
|
+
this.pruneRequestTimestamps(now);
|
|
3010
|
+
if (this.requestTimestamps.length >= this.maxRequestsPerDay) {
|
|
3011
|
+
const timeUntilReset = Math.max(0, this.getNextRequestExpiry(now).getTime() - now);
|
|
3012
|
+
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
3013
|
await this.delay(timeUntilReset);
|
|
2639
|
-
|
|
2640
|
-
this.setNextDailyReset();
|
|
3014
|
+
continue;
|
|
2641
3015
|
}
|
|
2642
3016
|
await this.waitForRateLimit();
|
|
2643
3017
|
this.queue.sort((a, b) => {
|
|
@@ -2672,7 +3046,7 @@ class GlobalRequestQueue {
|
|
|
2672
3046
|
}
|
|
2673
3047
|
});
|
|
2674
3048
|
try {
|
|
2675
|
-
result = await Promise.race([item.request
|
|
3049
|
+
result = await Promise.race([this.startRequest(item.request), timeoutPromise]);
|
|
2676
3050
|
}
|
|
2677
3051
|
finally {
|
|
2678
3052
|
if (timeoutId) {
|
|
@@ -2681,11 +3055,9 @@ class GlobalRequestQueue {
|
|
|
2681
3055
|
}
|
|
2682
3056
|
}
|
|
2683
3057
|
else {
|
|
2684
|
-
result = await item.request
|
|
3058
|
+
result = await this.startRequest(item.request);
|
|
2685
3059
|
}
|
|
2686
3060
|
item.resolve(result);
|
|
2687
|
-
this.requestCount++;
|
|
2688
|
-
this.lastRequestTime = Date.now();
|
|
2689
3061
|
}
|
|
2690
3062
|
catch (error) {
|
|
2691
3063
|
if (error instanceof EtsyRateLimitError) {
|
|
@@ -2705,7 +3077,7 @@ class GlobalRequestQueue {
|
|
|
2705
3077
|
const globalRateLimit = this.rateLimits.get('global');
|
|
2706
3078
|
if (globalRateLimit && now < globalRateLimit.resetAt) {
|
|
2707
3079
|
const waitTime = globalRateLimit.resetAt - now;
|
|
2708
|
-
console.
|
|
3080
|
+
console.warn(`Global rate limit active. Waiting ${waitTime}ms`);
|
|
2709
3081
|
await this.delay(waitTime);
|
|
2710
3082
|
}
|
|
2711
3083
|
const timeSinceLastRequest = now - this.lastRequestTime;
|
|
@@ -2723,11 +3095,23 @@ class GlobalRequestQueue {
|
|
|
2723
3095
|
});
|
|
2724
3096
|
}
|
|
2725
3097
|
}
|
|
2726
|
-
|
|
2727
|
-
const
|
|
2728
|
-
this.
|
|
2729
|
-
|
|
2730
|
-
|
|
3098
|
+
pruneRequestTimestamps(now) {
|
|
3099
|
+
const oldestAllowed = now - QPD_WINDOW_MS;
|
|
3100
|
+
while (this.requestTimestamps[0] !== undefined && this.requestTimestamps[0] <= oldestAllowed) {
|
|
3101
|
+
this.requestTimestamps.shift();
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
getNextRequestExpiry(now) {
|
|
3105
|
+
this.pruneRequestTimestamps(now);
|
|
3106
|
+
const oldestRequest = this.requestTimestamps[0] ?? now;
|
|
3107
|
+
return new Date(oldestRequest + QPD_WINDOW_MS);
|
|
3108
|
+
}
|
|
3109
|
+
startRequest(request) {
|
|
3110
|
+
const startedAt = Date.now();
|
|
3111
|
+
this.pruneRequestTimestamps(startedAt);
|
|
3112
|
+
this.requestTimestamps.push(startedAt);
|
|
3113
|
+
this.lastRequestTime = startedAt;
|
|
3114
|
+
return request();
|
|
2731
3115
|
}
|
|
2732
3116
|
generateId() {
|
|
2733
3117
|
return `${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|