@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/CHANGELOG.md +14 -0
- package/README.md +43 -11
- package/dist/browser.esm.js +498 -121
- 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 +498 -121
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +53 -18
- package/dist/index.esm.js +498 -121
- package/dist/index.esm.js.map +1 -1
- package/dist/node.cjs +498 -121
- package/dist/node.cjs.map +1 -1
- package/dist/node.esm.js +498 -121
- package/dist/node.esm.js.map +1 -1
- package/package.json +38 -39
- package/dist/.tsbuildinfo +0 -1
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
this.
|
|
389
|
-
|
|
390
|
-
|
|
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.
|
|
393
|
-
|
|
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
|
-
|
|
398
|
-
|
|
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.
|
|
708
|
-
this.
|
|
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
|
-
...
|
|
809
|
+
...overrides
|
|
723
810
|
};
|
|
724
|
-
this.setNextDailyReset();
|
|
725
811
|
}
|
|
726
|
-
|
|
727
|
-
const
|
|
728
|
-
this.
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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.
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
876
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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.
|
|
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
|
-
|
|
2318
|
+
const payment = payments[0];
|
|
2319
|
+
if (!payment) {
|
|
1947
2320
|
throw new EtsyApiError('Payment not found', 404);
|
|
1948
2321
|
}
|
|
1949
|
-
return
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
2681
|
-
resetTime: this.
|
|
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
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
2793
|
-
const
|
|
2794
|
-
this.
|
|
2795
|
-
|
|
2796
|
-
|
|
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)}`;
|