@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/node.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
|
+
}
|
|
391
427
|
}
|
|
392
|
-
|
|
393
|
-
|
|
428
|
+
catch (error) {
|
|
429
|
+
if (generation !== this.tokenMutationGeneration)
|
|
430
|
+
continue;
|
|
431
|
+
throw error;
|
|
394
432
|
}
|
|
395
|
-
|
|
433
|
+
if (generation === this.tokenMutationGeneration)
|
|
434
|
+
return;
|
|
396
435
|
}
|
|
397
|
-
|
|
398
|
-
|
|
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);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
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
|
+
}
|
|
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');
|
|
399
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);
|
|
830
|
+
}
|
|
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();
|
|
731
877
|
}
|
|
732
|
-
|
|
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;
|
|
@@ -1727,11 +2022,96 @@ class EtsyClient {
|
|
|
1727
2022
|
await this.makeRequest(`/listings/${listingId}`, { method: 'DELETE' }, false);
|
|
1728
2023
|
}
|
|
1729
2024
|
async updateListingInventory(listingId, params, options) {
|
|
1730
|
-
const
|
|
1731
|
-
|
|
2025
|
+
const searchParams = new URLSearchParams();
|
|
2026
|
+
if (options?.legacy !== undefined) {
|
|
2027
|
+
searchParams.set('legacy', options.legacy.toString());
|
|
2028
|
+
}
|
|
2029
|
+
if (options?.enableScaleValidation !== undefined) {
|
|
2030
|
+
searchParams.set('enable_scale_validation', options.enableScaleValidation.toString());
|
|
2031
|
+
}
|
|
2032
|
+
const queryString = searchParams.toString();
|
|
2033
|
+
const query = queryString ? `?${queryString}` : '';
|
|
2034
|
+
if (!Array.isArray(params.products) || params.products.length === 0) {
|
|
2035
|
+
throw new TypeError('Inventory update must contain at least one product.');
|
|
2036
|
+
}
|
|
2037
|
+
const body = {
|
|
2038
|
+
products: params.products.map((product, productIndex) => {
|
|
2039
|
+
if (product.is_deleted === true) {
|
|
2040
|
+
throw new TypeError(`Inventory product ${productIndex} is deleted and cannot be submitted for replacement.`);
|
|
2041
|
+
}
|
|
2042
|
+
if (!Array.isArray(product.offerings) || product.offerings.length === 0) {
|
|
2043
|
+
throw new TypeError(`Inventory product ${productIndex} must contain at least one offering.`);
|
|
2044
|
+
}
|
|
2045
|
+
if (!Array.isArray(product.property_values)) {
|
|
2046
|
+
throw new TypeError(`Inventory product ${productIndex} must include property_values; pass [] when it has no variation properties.`);
|
|
2047
|
+
}
|
|
2048
|
+
if (product.sku !== undefined && product.sku !== null && typeof product.sku !== 'string') {
|
|
2049
|
+
throw new TypeError(`Inventory product ${productIndex} sku must be a string or null.`);
|
|
2050
|
+
}
|
|
2051
|
+
return {
|
|
2052
|
+
...(product.sku !== undefined ? { sku: product.sku } : {}),
|
|
2053
|
+
property_values: product.property_values.map((value, valueIndex) => {
|
|
2054
|
+
if (!Number.isInteger(value.property_id) || value.property_id < 1) {
|
|
2055
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_id must be a positive integer.`);
|
|
2056
|
+
}
|
|
2057
|
+
if (!Array.isArray(value.value_ids) || value.value_ids.some((id) => !Number.isInteger(id))) {
|
|
2058
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].value_ids must be an integer array.`);
|
|
2059
|
+
}
|
|
2060
|
+
if (!Array.isArray(value.values) || value.values.some((entry) => typeof entry !== 'string')) {
|
|
2061
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].values must be a string array.`);
|
|
2062
|
+
}
|
|
2063
|
+
if (value.property_name !== undefined && typeof value.property_name !== 'string') {
|
|
2064
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_name must be a string.`);
|
|
2065
|
+
}
|
|
2066
|
+
if (value.scale_id !== undefined && value.scale_id !== null &&
|
|
2067
|
+
(!Number.isInteger(value.scale_id) || value.scale_id < 1)) {
|
|
2068
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].scale_id must be a positive integer or null.`);
|
|
2069
|
+
}
|
|
2070
|
+
return {
|
|
2071
|
+
property_id: value.property_id,
|
|
2072
|
+
...(value.property_name !== undefined ? { property_name: value.property_name } : {}),
|
|
2073
|
+
...(value.scale_id !== undefined ? { scale_id: value.scale_id } : {}),
|
|
2074
|
+
value_ids: value.value_ids,
|
|
2075
|
+
values: value.values,
|
|
2076
|
+
};
|
|
2077
|
+
}),
|
|
2078
|
+
offerings: product.offerings.map((offering, offeringIndex) => {
|
|
2079
|
+
if (offering.is_deleted === true) {
|
|
2080
|
+
throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is deleted and cannot be submitted for replacement.`);
|
|
2081
|
+
}
|
|
2082
|
+
if (typeof offering.price !== 'number' || !Number.isFinite(offering.price)) {
|
|
2083
|
+
throw new TypeError('Inventory offering price must be a finite decimal number in the listing currency.');
|
|
2084
|
+
}
|
|
2085
|
+
if (!Number.isInteger(offering.quantity)) {
|
|
2086
|
+
throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} quantity must be an integer.`);
|
|
2087
|
+
}
|
|
2088
|
+
if (typeof offering.is_enabled !== 'boolean') {
|
|
2089
|
+
throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is_enabled must be a boolean.`);
|
|
2090
|
+
}
|
|
2091
|
+
if (offering.readiness_state_id === undefined ||
|
|
2092
|
+
(offering.readiness_state_id !== null &&
|
|
2093
|
+
(!Number.isInteger(offering.readiness_state_id) || offering.readiness_state_id < 1))) {
|
|
2094
|
+
throw new TypeError('Inventory offering readiness_state_id is required; use null when not set.');
|
|
2095
|
+
}
|
|
2096
|
+
return {
|
|
2097
|
+
price: offering.price,
|
|
2098
|
+
quantity: offering.quantity,
|
|
2099
|
+
is_enabled: offering.is_enabled,
|
|
2100
|
+
readiness_state_id: offering.readiness_state_id,
|
|
2101
|
+
};
|
|
2102
|
+
}),
|
|
2103
|
+
};
|
|
2104
|
+
}),
|
|
2105
|
+
...(params.price_on_property !== undefined ? { price_on_property: params.price_on_property } : {}),
|
|
2106
|
+
...(params.quantity_on_property !== undefined ? { quantity_on_property: params.quantity_on_property } : {}),
|
|
2107
|
+
...(params.sku_on_property !== undefined ? { sku_on_property: params.sku_on_property } : {}),
|
|
2108
|
+
...(params.readiness_state_on_property !== undefined
|
|
2109
|
+
? { readiness_state_on_property: params.readiness_state_on_property }
|
|
2110
|
+
: {}),
|
|
2111
|
+
};
|
|
1732
2112
|
return this.makeRequest(`/listings/${listingId}/inventory${query}`, {
|
|
1733
2113
|
method: 'PUT',
|
|
1734
|
-
body: JSON.stringify(
|
|
2114
|
+
body: JSON.stringify(body)
|
|
1735
2115
|
}, false);
|
|
1736
2116
|
}
|
|
1737
2117
|
async uploadListingImage(shopId, listingId, imageData, params) {
|
|
@@ -1746,9 +2126,8 @@ class EtsyClient {
|
|
|
1746
2126
|
if (params?.alt_text)
|
|
1747
2127
|
formData.append('alt_text', params.alt_text);
|
|
1748
2128
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/images`;
|
|
1749
|
-
await this.rateLimiter.waitForRateLimit();
|
|
1750
2129
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
1751
|
-
const response = await this.
|
|
2130
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
1752
2131
|
method: 'POST',
|
|
1753
2132
|
headers: {
|
|
1754
2133
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -1943,10 +2322,11 @@ class EtsyClient {
|
|
|
1943
2322
|
}
|
|
1944
2323
|
async getShopPayment(shopId, paymentId) {
|
|
1945
2324
|
const payments = await this.getPayments(shopId, [Number(paymentId)]);
|
|
1946
|
-
|
|
2325
|
+
const payment = payments[0];
|
|
2326
|
+
if (!payment) {
|
|
1947
2327
|
throw new EtsyApiError('Payment not found', 404);
|
|
1948
2328
|
}
|
|
1949
|
-
return
|
|
2329
|
+
return payment;
|
|
1950
2330
|
}
|
|
1951
2331
|
async getBuyerTaxonomyNodes() {
|
|
1952
2332
|
const response = await this.makeRequest('/buyer-taxonomy/nodes');
|
|
@@ -1969,9 +2349,8 @@ class EtsyClient {
|
|
|
1969
2349
|
body.append('scale_id', scaleId.toString());
|
|
1970
2350
|
}
|
|
1971
2351
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/properties/${propertyId}`;
|
|
1972
|
-
await this.rateLimiter.waitForRateLimit();
|
|
1973
2352
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
1974
|
-
const response = await this.
|
|
2353
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
1975
2354
|
method: 'PUT',
|
|
1976
2355
|
headers: {
|
|
1977
2356
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -1980,8 +2359,6 @@ class EtsyClient {
|
|
|
1980
2359
|
},
|
|
1981
2360
|
body: body.toString()
|
|
1982
2361
|
});
|
|
1983
|
-
this.rateLimiter.updateFromHeaders(response.headers);
|
|
1984
|
-
this.rateLimiter.resetRetryCount();
|
|
1985
2362
|
if (!response.ok) {
|
|
1986
2363
|
const errorText = await response.text();
|
|
1987
2364
|
throw new EtsyApiError(`Failed to update listing property: ${response.status} ${response.statusText}`, response.status, errorText);
|
|
@@ -2081,9 +2458,8 @@ class EtsyClient {
|
|
|
2081
2458
|
if (params?.listing_file_id !== undefined)
|
|
2082
2459
|
formData.append('listing_file_id', params.listing_file_id.toString());
|
|
2083
2460
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/files`;
|
|
2084
|
-
await this.rateLimiter.waitForRateLimit();
|
|
2085
2461
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
2086
|
-
const response = await this.
|
|
2462
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
2087
2463
|
method: 'POST',
|
|
2088
2464
|
headers: {
|
|
2089
2465
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -2115,9 +2491,8 @@ class EtsyClient {
|
|
|
2115
2491
|
if (params?.video_id !== undefined)
|
|
2116
2492
|
formData.append('video_id', params.video_id.toString());
|
|
2117
2493
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/videos`;
|
|
2118
|
-
await this.rateLimiter.waitForRateLimit();
|
|
2119
2494
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
2120
|
-
const response = await this.
|
|
2495
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
2121
2496
|
method: 'POST',
|
|
2122
2497
|
headers: {
|
|
2123
2498
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -2439,7 +2814,7 @@ async function sha256(data) {
|
|
|
2439
2814
|
const encoder = new TextEncoder();
|
|
2440
2815
|
const dataBytes = typeof data === 'string' ? encoder.encode(data) : data;
|
|
2441
2816
|
if (isBrowser) {
|
|
2442
|
-
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBytes);
|
|
2817
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', new Uint8Array(dataBytes));
|
|
2443
2818
|
return new Uint8Array(hashBuffer);
|
|
2444
2819
|
}
|
|
2445
2820
|
else if (isNode) {
|
|
@@ -2539,6 +2914,7 @@ class AuthHelper {
|
|
|
2539
2914
|
assertFetchSupport();
|
|
2540
2915
|
const response = await fetch('https://api.etsy.com/v3/public/oauth/token', {
|
|
2541
2916
|
method: 'POST',
|
|
2917
|
+
redirect: 'error',
|
|
2542
2918
|
headers: {
|
|
2543
2919
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
2544
2920
|
'Accept': 'application/json'
|
|
@@ -2631,18 +3007,17 @@ const COMMON_SCOPE_COMBINATIONS = {
|
|
|
2631
3007
|
]
|
|
2632
3008
|
};
|
|
2633
3009
|
|
|
3010
|
+
const QPD_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
2634
3011
|
class GlobalRequestQueue {
|
|
2635
3012
|
constructor() {
|
|
2636
3013
|
this.queue = [];
|
|
2637
3014
|
this.processing = false;
|
|
2638
3015
|
this.rateLimits = new Map();
|
|
2639
|
-
this.
|
|
2640
|
-
this.dailyReset = new Date();
|
|
3016
|
+
this.requestTimestamps = [];
|
|
2641
3017
|
this.lastRequestTime = 0;
|
|
2642
3018
|
this.maxRequestsPerDay = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY;
|
|
2643
3019
|
this.maxRequestsPerSecond = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND;
|
|
2644
3020
|
this.minRequestInterval = ETSY_RATE_LIMITS.MIN_REQUEST_INTERVAL;
|
|
2645
|
-
this.setNextDailyReset();
|
|
2646
3021
|
}
|
|
2647
3022
|
static getInstance() {
|
|
2648
3023
|
if (!this.instance) {
|
|
@@ -2674,11 +3049,13 @@ class GlobalRequestQueue {
|
|
|
2674
3049
|
});
|
|
2675
3050
|
}
|
|
2676
3051
|
getStatus() {
|
|
3052
|
+
const now = Date.now();
|
|
3053
|
+
this.pruneRequestTimestamps(now);
|
|
2677
3054
|
return {
|
|
2678
3055
|
queueLength: this.queue.length,
|
|
2679
3056
|
processing: this.processing,
|
|
2680
|
-
remainingRequests: Math.max(0, this.maxRequestsPerDay - this.
|
|
2681
|
-
resetTime: this.
|
|
3057
|
+
remainingRequests: Math.max(0, this.maxRequestsPerDay - this.requestTimestamps.length),
|
|
3058
|
+
resetTime: this.getNextRequestExpiry(now),
|
|
2682
3059
|
};
|
|
2683
3060
|
}
|
|
2684
3061
|
clear() {
|
|
@@ -2694,16 +3071,13 @@ class GlobalRequestQueue {
|
|
|
2694
3071
|
this.processing = true;
|
|
2695
3072
|
try {
|
|
2696
3073
|
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.`);
|
|
3074
|
+
const now = Date.now();
|
|
3075
|
+
this.pruneRequestTimestamps(now);
|
|
3076
|
+
if (this.requestTimestamps.length >= this.maxRequestsPerDay) {
|
|
3077
|
+
const timeUntilReset = Math.max(0, this.getNextRequestExpiry(now).getTime() - now);
|
|
3078
|
+
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
3079
|
await this.delay(timeUntilReset);
|
|
2705
|
-
|
|
2706
|
-
this.setNextDailyReset();
|
|
3080
|
+
continue;
|
|
2707
3081
|
}
|
|
2708
3082
|
await this.waitForRateLimit();
|
|
2709
3083
|
this.queue.sort((a, b) => {
|
|
@@ -2738,7 +3112,7 @@ class GlobalRequestQueue {
|
|
|
2738
3112
|
}
|
|
2739
3113
|
});
|
|
2740
3114
|
try {
|
|
2741
|
-
result = await Promise.race([item.request
|
|
3115
|
+
result = await Promise.race([this.startRequest(item.request), timeoutPromise]);
|
|
2742
3116
|
}
|
|
2743
3117
|
finally {
|
|
2744
3118
|
if (timeoutId) {
|
|
@@ -2747,11 +3121,9 @@ class GlobalRequestQueue {
|
|
|
2747
3121
|
}
|
|
2748
3122
|
}
|
|
2749
3123
|
else {
|
|
2750
|
-
result = await item.request
|
|
3124
|
+
result = await this.startRequest(item.request);
|
|
2751
3125
|
}
|
|
2752
3126
|
item.resolve(result);
|
|
2753
|
-
this.requestCount++;
|
|
2754
|
-
this.lastRequestTime = Date.now();
|
|
2755
3127
|
}
|
|
2756
3128
|
catch (error) {
|
|
2757
3129
|
if (error instanceof EtsyRateLimitError) {
|
|
@@ -2771,7 +3143,7 @@ class GlobalRequestQueue {
|
|
|
2771
3143
|
const globalRateLimit = this.rateLimits.get('global');
|
|
2772
3144
|
if (globalRateLimit && now < globalRateLimit.resetAt) {
|
|
2773
3145
|
const waitTime = globalRateLimit.resetAt - now;
|
|
2774
|
-
console.
|
|
3146
|
+
console.warn(`Global rate limit active. Waiting ${waitTime}ms`);
|
|
2775
3147
|
await this.delay(waitTime);
|
|
2776
3148
|
}
|
|
2777
3149
|
const timeSinceLastRequest = now - this.lastRequestTime;
|
|
@@ -2789,11 +3161,23 @@ class GlobalRequestQueue {
|
|
|
2789
3161
|
});
|
|
2790
3162
|
}
|
|
2791
3163
|
}
|
|
2792
|
-
|
|
2793
|
-
const
|
|
2794
|
-
this.
|
|
2795
|
-
|
|
2796
|
-
|
|
3164
|
+
pruneRequestTimestamps(now) {
|
|
3165
|
+
const oldestAllowed = now - QPD_WINDOW_MS;
|
|
3166
|
+
while (this.requestTimestamps[0] !== undefined && this.requestTimestamps[0] <= oldestAllowed) {
|
|
3167
|
+
this.requestTimestamps.shift();
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
getNextRequestExpiry(now) {
|
|
3171
|
+
this.pruneRequestTimestamps(now);
|
|
3172
|
+
const oldestRequest = this.requestTimestamps[0] ?? now;
|
|
3173
|
+
return new Date(oldestRequest + QPD_WINDOW_MS);
|
|
3174
|
+
}
|
|
3175
|
+
startRequest(request) {
|
|
3176
|
+
const startedAt = Date.now();
|
|
3177
|
+
this.pruneRequestTimestamps(startedAt);
|
|
3178
|
+
this.requestTimestamps.push(startedAt);
|
|
3179
|
+
this.lastRequestTime = startedAt;
|
|
3180
|
+
return request();
|
|
2797
3181
|
}
|
|
2798
3182
|
generateId() {
|
|
2799
3183
|
return `${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|