@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.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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
this.
|
|
385
|
-
|
|
386
|
-
|
|
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.
|
|
389
|
-
|
|
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
|
-
|
|
394
|
-
|
|
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.
|
|
704
|
-
this.
|
|
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
|
-
...
|
|
805
|
+
...overrides
|
|
719
806
|
};
|
|
720
|
-
this.setNextDailyReset();
|
|
721
807
|
}
|
|
722
|
-
|
|
723
|
-
const
|
|
724
|
-
this.
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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.
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
872
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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.
|
|
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
|
-
|
|
2314
|
+
const payment = payments[0];
|
|
2315
|
+
if (!payment) {
|
|
1943
2316
|
throw new EtsyApiError('Payment not found', 404);
|
|
1944
2317
|
}
|
|
1945
|
-
return
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
2677
|
-
resetTime: this.
|
|
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
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
2789
|
-
const
|
|
2790
|
-
this.
|
|
2791
|
-
|
|
2792
|
-
|
|
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)}`;
|