@profplum700/etsy-v3-api-client 3.0.0 → 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 +20 -0
- package/README.md +43 -11
- package/dist/browser.esm.js +524 -145
- 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 +524 -145
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +55 -20
- package/dist/index.esm.js +524 -145
- package/dist/index.esm.js.map +1 -1
- package/dist/node.cjs +524 -145
- package/dist/node.cjs.map +1 -1
- package/dist/node.esm.js +524 -145
- package/dist/node.esm.js.map +1 -1
- package/package.json +50 -51
- package/dist/.tsbuildinfo +0 -1
package/dist/browser.esm.js
CHANGED
|
@@ -247,6 +247,26 @@ class EtsyRateLimitError extends Error {
|
|
|
247
247
|
return this.errorType !== 'qpd_exhausted';
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
|
+
const ETSY_WHEN_MADE_VALUES = [
|
|
251
|
+
'2020_2026',
|
|
252
|
+
'2010_2019',
|
|
253
|
+
'2007_2009',
|
|
254
|
+
'before_2007',
|
|
255
|
+
'2000_2006',
|
|
256
|
+
'1990s',
|
|
257
|
+
'1980s',
|
|
258
|
+
'1970s',
|
|
259
|
+
'1960s',
|
|
260
|
+
'1950s',
|
|
261
|
+
'1940s',
|
|
262
|
+
'1930s',
|
|
263
|
+
'1920s',
|
|
264
|
+
'1910s',
|
|
265
|
+
'1900s',
|
|
266
|
+
'1800s',
|
|
267
|
+
'1700s',
|
|
268
|
+
'before_1700'
|
|
269
|
+
];
|
|
250
270
|
|
|
251
271
|
const isBrowser$1 = typeof window !== 'undefined' && typeof window.document !== 'undefined';
|
|
252
272
|
const isNode = false;
|
|
@@ -316,9 +336,12 @@ class MemoryTokenStorage {
|
|
|
316
336
|
class TokenManager {
|
|
317
337
|
constructor(config, storage, rotationConfig) {
|
|
318
338
|
this.currentTokens = null;
|
|
339
|
+
this.tokenMutationGeneration = 0;
|
|
319
340
|
this.keystring = config.keystring;
|
|
320
|
-
this.refreshCallback = config.refreshSave;
|
|
341
|
+
this.refreshCallback = config.refreshSaveAsync ?? config.refreshSave;
|
|
342
|
+
this.refreshClearRequired = config.refreshSaveAsync !== undefined;
|
|
321
343
|
this.storage = storage;
|
|
344
|
+
this.clearCallback = config.refreshClearAsync;
|
|
322
345
|
this.rotationConfig = rotationConfig;
|
|
323
346
|
this.currentTokens = {
|
|
324
347
|
access_token: config.accessToken,
|
|
@@ -355,21 +378,82 @@ class TokenManager {
|
|
|
355
378
|
if (!this.currentTokens) {
|
|
356
379
|
throw new EtsyAuthError('No tokens available to refresh', 'NO_REFRESH_TOKEN');
|
|
357
380
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
this.
|
|
362
|
-
|
|
363
|
-
|
|
381
|
+
const generation = this.tokenMutationGeneration;
|
|
382
|
+
const refreshPromise = (this.pendingTokens
|
|
383
|
+
? this.retryPendingTokenPersistence()
|
|
384
|
+
: this.performTokenRefresh().then(async (newTokens) => {
|
|
385
|
+
this.throwIfSuperseded(generation);
|
|
386
|
+
this.pendingTokens = newTokens;
|
|
387
|
+
return this.persistRefreshedTokens(newTokens, generation);
|
|
388
|
+
})).finally(() => {
|
|
389
|
+
this.refreshPromise = undefined;
|
|
390
|
+
});
|
|
391
|
+
this.refreshPromise = refreshPromise;
|
|
392
|
+
return refreshPromise;
|
|
393
|
+
}
|
|
394
|
+
throwIfSuperseded(generation) {
|
|
395
|
+
if (generation !== this.tokenMutationGeneration) {
|
|
396
|
+
throw new EtsyAuthError('Token refresh was superseded by a token update or clear', 'TOKEN_REFRESH_SUPERSEDED');
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async repairPersistenceToCurrentTokens() {
|
|
400
|
+
while (true) {
|
|
401
|
+
const generation = this.tokenMutationGeneration;
|
|
402
|
+
const current = this.currentTokens ? { ...this.currentTokens } : null;
|
|
403
|
+
try {
|
|
404
|
+
if (current) {
|
|
405
|
+
if (this.storage)
|
|
406
|
+
await this.storage.save(current);
|
|
407
|
+
if (this.refreshCallback) {
|
|
408
|
+
await this.refreshCallback(current.access_token, current.refresh_token, current.expires_at);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
if (this.storage)
|
|
413
|
+
await this.storage.clear();
|
|
414
|
+
if (this.clearCallback)
|
|
415
|
+
await this.clearCallback();
|
|
416
|
+
else if (this.refreshClearRequired) {
|
|
417
|
+
throw new EtsyAuthError('A refresh persistence callback is configured without refreshClearAsync; durable tokens could not be cleared safely', 'TOKEN_CLEAR_CALLBACK_REQUIRED');
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
if (generation !== this.tokenMutationGeneration)
|
|
423
|
+
continue;
|
|
424
|
+
throw error;
|
|
364
425
|
}
|
|
365
|
-
if (this.
|
|
366
|
-
|
|
426
|
+
if (generation === this.tokenMutationGeneration)
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
async persistRefreshedTokens(newTokens, generation) {
|
|
431
|
+
this.throwIfSuperseded(generation);
|
|
432
|
+
if (this.storage) {
|
|
433
|
+
await this.storage.save(newTokens);
|
|
434
|
+
if (generation !== this.tokenMutationGeneration) {
|
|
435
|
+
await this.repairPersistenceToCurrentTokens();
|
|
436
|
+
this.throwIfSuperseded(generation);
|
|
367
437
|
}
|
|
368
|
-
return newTokens;
|
|
369
438
|
}
|
|
370
|
-
|
|
371
|
-
|
|
439
|
+
this.throwIfSuperseded(generation);
|
|
440
|
+
if (this.refreshCallback) {
|
|
441
|
+
await this.refreshCallback(newTokens.access_token, newTokens.refresh_token, newTokens.expires_at);
|
|
442
|
+
if (generation !== this.tokenMutationGeneration) {
|
|
443
|
+
await this.repairPersistenceToCurrentTokens();
|
|
444
|
+
this.throwIfSuperseded(generation);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
this.throwIfSuperseded(generation);
|
|
448
|
+
this.currentTokens = newTokens;
|
|
449
|
+
this.pendingTokens = undefined;
|
|
450
|
+
return newTokens;
|
|
451
|
+
}
|
|
452
|
+
async retryPendingTokenPersistence() {
|
|
453
|
+
if (!this.pendingTokens) {
|
|
454
|
+
throw new EtsyAuthError('No rotated tokens are pending persistence', 'NO_PENDING_TOKENS');
|
|
372
455
|
}
|
|
456
|
+
return this.persistRefreshedTokens(this.pendingTokens, this.tokenMutationGeneration);
|
|
373
457
|
}
|
|
374
458
|
async performTokenRefresh() {
|
|
375
459
|
if (!this.currentTokens) {
|
|
@@ -383,6 +467,7 @@ class TokenManager {
|
|
|
383
467
|
try {
|
|
384
468
|
const response = await this.fetch('https://api.etsy.com/v3/public/oauth/token', {
|
|
385
469
|
method: 'POST',
|
|
470
|
+
redirect: 'error',
|
|
386
471
|
headers: {
|
|
387
472
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
388
473
|
'Accept': 'application/json'
|
|
@@ -412,7 +497,9 @@ class TokenManager {
|
|
|
412
497
|
return this.currentTokens ? { ...this.currentTokens } : null;
|
|
413
498
|
}
|
|
414
499
|
updateTokens(tokens) {
|
|
500
|
+
this.tokenMutationGeneration += 1;
|
|
415
501
|
this.currentTokens = { ...tokens };
|
|
502
|
+
this.pendingTokens = undefined;
|
|
416
503
|
}
|
|
417
504
|
isTokenExpired() {
|
|
418
505
|
if (!this.currentTokens) {
|
|
@@ -432,10 +519,19 @@ class TokenManager {
|
|
|
432
519
|
return now.getTime() >= (expiresAt.getTime() - bufferTime);
|
|
433
520
|
}
|
|
434
521
|
async clearTokens() {
|
|
522
|
+
this.tokenMutationGeneration += 1;
|
|
435
523
|
this.currentTokens = null;
|
|
524
|
+
this.pendingTokens = undefined;
|
|
525
|
+
if (this.refreshPromise)
|
|
526
|
+
await this.refreshPromise.catch(() => undefined);
|
|
436
527
|
if (this.storage) {
|
|
437
528
|
await this.storage.clear();
|
|
438
529
|
}
|
|
530
|
+
if (this.clearCallback)
|
|
531
|
+
await this.clearCallback();
|
|
532
|
+
else if (this.refreshClearRequired) {
|
|
533
|
+
throw new EtsyAuthError('A refresh persistence callback is configured without refreshClearAsync; durable tokens could not be cleared safely', 'TOKEN_CLEAR_CALLBACK_REQUIRED');
|
|
534
|
+
}
|
|
439
535
|
}
|
|
440
536
|
getTimeUntilExpiration() {
|
|
441
537
|
if (!this.currentTokens) {
|
|
@@ -644,13 +740,24 @@ const ETSY_RATE_LIMITS = {
|
|
|
644
740
|
MAX_REQUESTS_PER_SECOND: 5,
|
|
645
741
|
MIN_REQUEST_INTERVAL: 200,
|
|
646
742
|
};
|
|
743
|
+
const QPD_WINDOW_MS$1 = 24 * 60 * 60 * 1000;
|
|
647
744
|
class EtsyRateLimiter {
|
|
648
745
|
constructor(config) {
|
|
649
|
-
this.
|
|
650
|
-
this.
|
|
746
|
+
this.requestTimestamps = [];
|
|
747
|
+
this.uncertainRequestTimestamps = [];
|
|
651
748
|
this.lastRequestTime = 0;
|
|
652
749
|
this.isHeaderBasedLimiting = false;
|
|
750
|
+
this.headerQuotaAvailableAt = 0;
|
|
751
|
+
this.retryNotBefore = 0;
|
|
752
|
+
this.quotaProbeBackoffMs = 60000;
|
|
753
|
+
this.nextReservationId = 0;
|
|
754
|
+
this.latestHeaderReservationId = 0;
|
|
755
|
+
this.inFlightReservations = new Map();
|
|
756
|
+
this.quotaProbeInFlight = false;
|
|
757
|
+
this.stateWaiters = new Set();
|
|
653
758
|
this.currentRetryCount = 0;
|
|
759
|
+
this.reservationQueue = Promise.resolve();
|
|
760
|
+
const overrides = Object.fromEntries(Object.entries(config ?? {}).filter(([, value]) => value !== undefined));
|
|
654
761
|
this.config = {
|
|
655
762
|
maxRequestsPerDay: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
|
|
656
763
|
maxRequestsPerSecond: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
|
|
@@ -661,21 +768,155 @@ class EtsyRateLimiter {
|
|
|
661
768
|
jitter: 0.1,
|
|
662
769
|
qpdWarningThreshold: 80,
|
|
663
770
|
onApproachingLimit: undefined,
|
|
664
|
-
...
|
|
771
|
+
...overrides
|
|
665
772
|
};
|
|
666
|
-
this.setNextDailyReset();
|
|
667
773
|
}
|
|
668
|
-
|
|
669
|
-
const
|
|
670
|
-
this.
|
|
671
|
-
|
|
672
|
-
|
|
774
|
+
pruneRequestTimestamps(now) {
|
|
775
|
+
const oldestAllowed = now - QPD_WINDOW_MS$1;
|
|
776
|
+
while (this.requestTimestamps[0] !== undefined && this.requestTimestamps[0] <= oldestAllowed) {
|
|
777
|
+
this.requestTimestamps.shift();
|
|
778
|
+
}
|
|
779
|
+
while (this.uncertainRequestTimestamps[0] !== undefined && this.uncertainRequestTimestamps[0] <= oldestAllowed) {
|
|
780
|
+
this.uncertainRequestTimestamps.shift();
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
getLocalResetTime(now) {
|
|
784
|
+
this.pruneRequestTimestamps(now);
|
|
785
|
+
const oldestRequest = this.requestTimestamps[0] ?? now;
|
|
786
|
+
return new Date(oldestRequest + QPD_WINDOW_MS$1);
|
|
787
|
+
}
|
|
788
|
+
getLocalRemainingRequests(now) {
|
|
789
|
+
this.pruneRequestTimestamps(now);
|
|
790
|
+
const localLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
|
|
791
|
+
return Math.max(0, localLimit - this.requestTimestamps.length);
|
|
792
|
+
}
|
|
793
|
+
getEffectiveRemainingRequests(now) {
|
|
794
|
+
this.pruneRequestTimestamps(now);
|
|
795
|
+
if (this.headerRemainingToday !== undefined) {
|
|
796
|
+
return Math.max(0, this.headerRemainingToday - this.inFlightReservations.size - this.uncertainRequestTimestamps.length);
|
|
797
|
+
}
|
|
798
|
+
return this.getLocalRemainingRequests(now);
|
|
799
|
+
}
|
|
800
|
+
isQuotaProbeReady(now) {
|
|
801
|
+
const exhausted = this.headerRemainingToday === 0;
|
|
802
|
+
const uncertainBudgetExhausted = this.inFlightReservations.size === 0 &&
|
|
803
|
+
this.uncertainRequestTimestamps.length > 0 &&
|
|
804
|
+
this.getEffectiveRemainingRequests(now) === 0;
|
|
805
|
+
return (exhausted || uncertainBudgetExhausted) &&
|
|
806
|
+
!this.quotaProbeInFlight &&
|
|
807
|
+
this.inFlightReservations.size === 0 &&
|
|
808
|
+
this.headerQuotaAvailableAt <= now;
|
|
809
|
+
}
|
|
810
|
+
refreshExpiredHeaderQuota(now) {
|
|
811
|
+
if (this.headerRemainingToday === 0 && this.headerQuotaAvailableAt > 0 && now >= this.headerQuotaAvailableAt) {
|
|
812
|
+
this.headerQuotaAvailableAt = 0;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
notifyStateWaiters() {
|
|
816
|
+
for (const resolve of this.stateWaiters)
|
|
817
|
+
resolve();
|
|
818
|
+
this.stateWaiters.clear();
|
|
819
|
+
}
|
|
820
|
+
scheduleQuotaProbe() {
|
|
821
|
+
this.headerQuotaAvailableAt = Date.now() + this.quotaProbeBackoffMs;
|
|
822
|
+
this.quotaProbeBackoffMs = Math.min(this.quotaProbeBackoffMs * 2, 60 * 60 * 1000);
|
|
823
|
+
}
|
|
824
|
+
waitForStateChange() {
|
|
825
|
+
return new Promise((resolve) => this.stateWaiters.add(resolve));
|
|
826
|
+
}
|
|
827
|
+
finishReservation(reservationId, receivedQuotaHeader) {
|
|
828
|
+
const wasQuotaProbe = this.inFlightReservations.get(reservationId);
|
|
829
|
+
if (wasQuotaProbe === undefined)
|
|
830
|
+
return;
|
|
831
|
+
this.inFlightReservations.delete(reservationId);
|
|
832
|
+
if (wasQuotaProbe.isQuotaProbe) {
|
|
833
|
+
this.quotaProbeInFlight = false;
|
|
834
|
+
if (this.headerRemainingToday === 0 && !receivedQuotaHeader) {
|
|
835
|
+
this.scheduleQuotaProbe();
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
this.notifyStateWaiters();
|
|
839
|
+
}
|
|
840
|
+
async acquireRequestSlot() {
|
|
841
|
+
const previousReservation = this.reservationQueue;
|
|
842
|
+
let releaseReservation = () => undefined;
|
|
843
|
+
this.reservationQueue = new Promise((resolve) => {
|
|
844
|
+
releaseReservation = resolve;
|
|
845
|
+
});
|
|
846
|
+
await previousReservation;
|
|
847
|
+
try {
|
|
848
|
+
while (true) {
|
|
849
|
+
const now = Date.now();
|
|
850
|
+
this.pruneRequestTimestamps(now);
|
|
851
|
+
this.refreshExpiredHeaderQuota(now);
|
|
852
|
+
let isQuotaProbe = false;
|
|
853
|
+
if (this.headerRemainingToday === 0 || this.isQuotaProbeReady(now)) {
|
|
854
|
+
if (this.quotaProbeInFlight || this.inFlightReservations.size > 0) {
|
|
855
|
+
await this.waitForStateChange();
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
const probeWait = this.headerQuotaAvailableAt - now;
|
|
859
|
+
if (probeWait > 0) {
|
|
860
|
+
await new Promise(resolve => setTimeout(resolve, probeWait));
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
isQuotaProbe = true;
|
|
864
|
+
}
|
|
865
|
+
else {
|
|
866
|
+
const effectiveRemaining = this.getEffectiveRemainingRequests(now);
|
|
867
|
+
if (effectiveRemaining <= 0) {
|
|
868
|
+
if (this.inFlightReservations.size > 0 && this.headerRemainingToday !== undefined) {
|
|
869
|
+
await this.waitForStateChange();
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
const effectiveDailyLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
|
|
873
|
+
const localResetTime = this.getLocalResetTime(now);
|
|
874
|
+
const timeUntilReset = Math.max(0, localResetTime.getTime() - now);
|
|
875
|
+
throw new EtsyRateLimitError(`Daily rate limit exhausted (${effectiveDailyLimit} requests in the local rolling 24-hour window). ` +
|
|
876
|
+
`The oldest local request expires in approximately ${Math.ceil(timeUntilReset / 1000 / 60)} minutes.`, Math.ceil(timeUntilReset / 1000), 'qpd_exhausted');
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
const intervalWait = Math.max(0, this.getEffectiveMinInterval() - (now - this.lastRequestTime));
|
|
880
|
+
const cooldownWait = Math.max(0, this.retryNotBefore - now);
|
|
881
|
+
const waitTime = Math.max(intervalWait, cooldownWait);
|
|
882
|
+
if (waitTime > 0) {
|
|
883
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
const requestStartedAt = Date.now();
|
|
887
|
+
const reservationId = ++this.nextReservationId;
|
|
888
|
+
this.requestTimestamps.push(requestStartedAt);
|
|
889
|
+
this.lastRequestTime = requestStartedAt;
|
|
890
|
+
this.inFlightReservations.set(reservationId, { isQuotaProbe, startedAt: requestStartedAt });
|
|
891
|
+
if (isQuotaProbe)
|
|
892
|
+
this.quotaProbeInFlight = true;
|
|
893
|
+
return reservationId;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
finally {
|
|
897
|
+
releaseReservation();
|
|
898
|
+
}
|
|
673
899
|
}
|
|
674
|
-
|
|
900
|
+
releaseRequestSlot(reservationId) {
|
|
901
|
+
const reservation = this.inFlightReservations.get(reservationId);
|
|
902
|
+
if (!reservation)
|
|
903
|
+
return;
|
|
904
|
+
this.uncertainRequestTimestamps.push(reservation.startedAt);
|
|
905
|
+
this.finishReservation(reservationId, false);
|
|
906
|
+
}
|
|
907
|
+
updateFromHeaders(headers, reservationId) {
|
|
908
|
+
const matchedReservationId = reservationId;
|
|
909
|
+
const isQuotaProbe = matchedReservationId !== undefined &&
|
|
910
|
+
this.inFlightReservations.get(matchedReservationId)?.isQuotaProbe === true;
|
|
675
911
|
if (!headers) {
|
|
912
|
+
if (matchedReservationId !== undefined)
|
|
913
|
+
this.finishReservation(matchedReservationId, false);
|
|
676
914
|
return;
|
|
677
915
|
}
|
|
678
916
|
const parsed = this.parseRateLimitHeaders(headers);
|
|
917
|
+
if (matchedReservationId !== undefined) {
|
|
918
|
+
this.finishReservation(matchedReservationId, parsed.remainingToday !== undefined);
|
|
919
|
+
}
|
|
679
920
|
if (parsed.limitPerSecond !== undefined) {
|
|
680
921
|
this.headerLimitPerSecond = parsed.limitPerSecond;
|
|
681
922
|
this.isHeaderBasedLimiting = true;
|
|
@@ -688,9 +929,39 @@ class EtsyRateLimiter {
|
|
|
688
929
|
this.isHeaderBasedLimiting = true;
|
|
689
930
|
}
|
|
690
931
|
if (parsed.remainingToday !== undefined) {
|
|
691
|
-
|
|
932
|
+
const observationId = matchedReservationId ?? this.nextReservationId;
|
|
933
|
+
if (isQuotaProbe && parsed.remainingToday > 0) {
|
|
934
|
+
this.uncertainRequestTimestamps = [];
|
|
935
|
+
}
|
|
936
|
+
const exhaustedByEarlierObservation = this.headerRemainingToday === 0 &&
|
|
937
|
+
parsed.remainingToday > 0 && matchedReservationId !== undefined && !isQuotaProbe;
|
|
938
|
+
const exhaustedByUncorrelatedObservation = this.headerRemainingToday === 0 &&
|
|
939
|
+
parsed.remainingToday > 0 && matchedReservationId === undefined;
|
|
940
|
+
if (exhaustedByEarlierObservation || exhaustedByUncorrelatedObservation) ;
|
|
941
|
+
else if (isQuotaProbe || this.headerRemainingToday === undefined) {
|
|
942
|
+
this.headerRemainingToday = parsed.remainingToday;
|
|
943
|
+
this.latestHeaderReservationId = Math.max(this.latestHeaderReservationId, observationId);
|
|
944
|
+
}
|
|
945
|
+
else if (parsed.remainingToday < this.headerRemainingToday) {
|
|
946
|
+
this.headerRemainingToday = parsed.remainingToday;
|
|
947
|
+
this.latestHeaderReservationId = Math.max(this.latestHeaderReservationId, observationId);
|
|
948
|
+
}
|
|
949
|
+
if (this.headerRemainingToday !== undefined && this.headerRemainingToday > 0) {
|
|
950
|
+
this.headerQuotaAvailableAt = 0;
|
|
951
|
+
this.quotaProbeBackoffMs = 60000;
|
|
952
|
+
}
|
|
953
|
+
else if (this.headerQuotaAvailableAt === 0) {
|
|
954
|
+
this.scheduleQuotaProbe();
|
|
955
|
+
}
|
|
692
956
|
this.checkApproachingLimit();
|
|
693
957
|
}
|
|
958
|
+
else if (matchedReservationId !== undefined && this.headerRemainingToday !== undefined) {
|
|
959
|
+
this.headerRemainingToday = Math.max(0, this.headerRemainingToday - 1);
|
|
960
|
+
if (this.headerRemainingToday === 0 && this.headerQuotaAvailableAt === 0) {
|
|
961
|
+
this.scheduleQuotaProbe();
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
this.notifyStateWaiters();
|
|
694
965
|
}
|
|
695
966
|
parseRateLimitHeaders(headers) {
|
|
696
967
|
const getHeader = (name) => {
|
|
@@ -730,18 +1001,27 @@ class EtsyRateLimiter {
|
|
|
730
1001
|
this.config.onApproachingLimit(remaining, limit, percentageUsed);
|
|
731
1002
|
}
|
|
732
1003
|
}
|
|
733
|
-
async handleRateLimitResponse(headers) {
|
|
1004
|
+
async handleRateLimitResponse(headers, reservationId, retryAttempt) {
|
|
734
1005
|
const parsed = headers ? this.parseRateLimitHeaders(headers) : {};
|
|
735
|
-
this.updateFromHeaders(headers);
|
|
736
|
-
if (
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
1006
|
+
this.updateFromHeaders(headers, reservationId);
|
|
1007
|
+
if (parsed.remainingToday === 0) {
|
|
1008
|
+
if (parsed.retryAfter !== undefined) {
|
|
1009
|
+
this.headerQuotaAvailableAt = Date.now() + parsed.retryAfter * 1000;
|
|
1010
|
+
this.retryNotBefore = Math.max(this.retryNotBefore, this.headerQuotaAvailableAt);
|
|
1011
|
+
}
|
|
741
1012
|
this.currentRetryCount = 0;
|
|
1013
|
+
throw new EtsyRateLimitError('Daily rate limit exhausted according to Etsy response headers.', parsed.retryAfter, 'qpd_exhausted');
|
|
1014
|
+
}
|
|
1015
|
+
const attempt = retryAttempt ?? this.currentRetryCount + 1;
|
|
1016
|
+
if (retryAttempt === undefined)
|
|
1017
|
+
this.currentRetryCount = attempt;
|
|
1018
|
+
const delayMs = this.calculateBackoffDelay(attempt, parsed.retryAfter);
|
|
1019
|
+
this.retryNotBefore = Math.max(this.retryNotBefore, Date.now() + delayMs);
|
|
1020
|
+
if (attempt > this.config.maxRetries) {
|
|
1021
|
+
if (retryAttempt === undefined)
|
|
1022
|
+
this.currentRetryCount = 0;
|
|
742
1023
|
throw new EtsyRateLimitError(`Max retries (${this.config.maxRetries}) exceeded for rate limit`, parsed.retryAfter, 'qps_exhausted');
|
|
743
1024
|
}
|
|
744
|
-
const delayMs = this.calculateBackoffDelay(this.currentRetryCount, parsed.retryAfter);
|
|
745
1025
|
return { shouldRetry: true, delayMs };
|
|
746
1026
|
}
|
|
747
1027
|
calculateBackoffDelay(attempt, retryAfterSeconds) {
|
|
@@ -754,7 +1034,7 @@ class EtsyRateLimiter {
|
|
|
754
1034
|
const randomJitter = Math.random() * jitterAmount * 2 - jitterAmount;
|
|
755
1035
|
delay += randomJitter;
|
|
756
1036
|
}
|
|
757
|
-
return Math.max(0, Math.floor(delay));
|
|
1037
|
+
return Math.max(serverSuggestedMs, 0, Math.floor(delay));
|
|
758
1038
|
}
|
|
759
1039
|
resetRetryCount() {
|
|
760
1040
|
this.currentRetryCount = 0;
|
|
@@ -772,41 +1052,26 @@ class EtsyRateLimiter {
|
|
|
772
1052
|
return this.config.minRequestInterval;
|
|
773
1053
|
}
|
|
774
1054
|
async waitForRateLimit() {
|
|
775
|
-
const
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
const effectiveDailyLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
|
|
781
|
-
const effectiveRemaining = this.headerRemainingToday ??
|
|
782
|
-
(effectiveDailyLimit - this.requestCount);
|
|
783
|
-
if (effectiveRemaining <= 0) {
|
|
784
|
-
const timeUntilReset = this.dailyReset.getTime() - now;
|
|
785
|
-
throw new EtsyRateLimitError(`Daily rate limit exhausted (${effectiveDailyLimit} requests). ` +
|
|
786
|
-
`Reset in approximately ${Math.ceil(timeUntilReset / 1000 / 60)} minutes.`, Math.ceil(timeUntilReset / 1000), 'qpd_exhausted');
|
|
787
|
-
}
|
|
788
|
-
const minInterval = this.getEffectiveMinInterval();
|
|
789
|
-
const timeSinceLastRequest = now - this.lastRequestTime;
|
|
790
|
-
if (timeSinceLastRequest < minInterval) {
|
|
791
|
-
const waitTime = minInterval - timeSinceLastRequest;
|
|
792
|
-
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
793
|
-
}
|
|
794
|
-
this.requestCount++;
|
|
795
|
-
this.lastRequestTime = Date.now();
|
|
1055
|
+
const reservationId = await this.acquireRequestSlot();
|
|
1056
|
+
this.finishReservation(reservationId, false);
|
|
1057
|
+
}
|
|
1058
|
+
async waitForRateLimitWithReservation() {
|
|
1059
|
+
return this.acquireRequestSlot();
|
|
796
1060
|
}
|
|
797
1061
|
getRateLimitStatus() {
|
|
798
1062
|
const now = Date.now();
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
const effectiveRemaining = this.headerRemainingToday ??
|
|
804
|
-
Math.max(0, this.config.maxRequestsPerDay - this.requestCount);
|
|
1063
|
+
this.pruneRequestTimestamps(now);
|
|
1064
|
+
this.refreshExpiredHeaderQuota(now);
|
|
1065
|
+
const effectiveRemaining = this.getEffectiveRemainingRequests(now);
|
|
1066
|
+
const qpdProbeReady = this.isQuotaProbeReady(now);
|
|
805
1067
|
return {
|
|
806
1068
|
remainingRequests: effectiveRemaining,
|
|
807
|
-
resetTime: this.
|
|
808
|
-
|
|
809
|
-
|
|
1069
|
+
resetTime: this.headerRemainingToday === 0
|
|
1070
|
+
? new Date(this.headerQuotaAvailableAt || now)
|
|
1071
|
+
: this.getLocalResetTime(now),
|
|
1072
|
+
canMakeRequest: (effectiveRemaining > 0 || qpdProbeReady) &&
|
|
1073
|
+
(now - this.lastRequestTime) >= this.getEffectiveMinInterval() &&
|
|
1074
|
+
now >= this.retryNotBefore,
|
|
810
1075
|
isFromHeaders: this.isHeaderBasedLimiting,
|
|
811
1076
|
limitPerSecond: this.headerLimitPerSecond,
|
|
812
1077
|
remainingThisSecond: this.headerRemainingThisSecond,
|
|
@@ -814,41 +1079,47 @@ class EtsyRateLimiter {
|
|
|
814
1079
|
};
|
|
815
1080
|
}
|
|
816
1081
|
getRemainingRequests() {
|
|
817
|
-
|
|
818
|
-
|
|
1082
|
+
const now = Date.now();
|
|
1083
|
+
this.pruneRequestTimestamps(now);
|
|
1084
|
+
this.refreshExpiredHeaderQuota(now);
|
|
1085
|
+
return this.getEffectiveRemainingRequests(now);
|
|
819
1086
|
}
|
|
820
1087
|
reset() {
|
|
821
|
-
this.
|
|
1088
|
+
this.requestTimestamps = [];
|
|
822
1089
|
this.lastRequestTime = 0;
|
|
823
1090
|
this.currentRetryCount = 0;
|
|
1091
|
+
this.quotaProbeBackoffMs = 60000;
|
|
1092
|
+
this.headerQuotaAvailableAt = 0;
|
|
1093
|
+
this.retryNotBefore = 0;
|
|
824
1094
|
this.headerLimitPerSecond = undefined;
|
|
825
1095
|
this.headerRemainingThisSecond = undefined;
|
|
826
1096
|
this.headerLimitPerDay = undefined;
|
|
827
1097
|
this.headerRemainingToday = undefined;
|
|
828
1098
|
this.isHeaderBasedLimiting = false;
|
|
829
|
-
this.
|
|
1099
|
+
this.nextReservationId = 0;
|
|
1100
|
+
this.latestHeaderReservationId = 0;
|
|
1101
|
+
this.inFlightReservations.clear();
|
|
1102
|
+
this.uncertainRequestTimestamps = [];
|
|
1103
|
+
this.quotaProbeInFlight = false;
|
|
1104
|
+
this.notifyStateWaiters();
|
|
830
1105
|
}
|
|
831
1106
|
canMakeRequest() {
|
|
832
1107
|
const now = Date.now();
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
(this.config.maxRequestsPerDay - this.requestCount);
|
|
839
|
-
if (effectiveRemaining <= 0) {
|
|
1108
|
+
this.pruneRequestTimestamps(now);
|
|
1109
|
+
this.refreshExpiredHeaderQuota(now);
|
|
1110
|
+
const effectiveRemaining = this.getEffectiveRemainingRequests(now);
|
|
1111
|
+
const qpdProbeReady = this.isQuotaProbeReady(now);
|
|
1112
|
+
if (effectiveRemaining <= 0 && !qpdProbeReady) {
|
|
840
1113
|
return false;
|
|
841
1114
|
}
|
|
842
|
-
return (now - this.lastRequestTime) >= this.getEffectiveMinInterval()
|
|
1115
|
+
return (now - this.lastRequestTime) >= this.getEffectiveMinInterval() &&
|
|
1116
|
+
now >= this.retryNotBefore;
|
|
843
1117
|
}
|
|
844
1118
|
getTimeUntilNextRequest() {
|
|
845
1119
|
const now = Date.now();
|
|
846
1120
|
const timeSinceLastRequest = now - this.lastRequestTime;
|
|
847
1121
|
const minInterval = this.getEffectiveMinInterval();
|
|
848
|
-
|
|
849
|
-
return 0;
|
|
850
|
-
}
|
|
851
|
-
return minInterval - timeSinceLastRequest;
|
|
1122
|
+
return Math.max(0, minInterval - timeSinceLastRequest, this.retryNotBefore - now);
|
|
852
1123
|
}
|
|
853
1124
|
getConfig() {
|
|
854
1125
|
return { ...this.config };
|
|
@@ -1050,10 +1321,10 @@ class FieldValidator {
|
|
|
1050
1321
|
const value = data[this.field];
|
|
1051
1322
|
if (value === undefined || value === null)
|
|
1052
1323
|
return null;
|
|
1053
|
-
if (typeof value !== 'number' || Number.
|
|
1324
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
1054
1325
|
return {
|
|
1055
1326
|
field: this.field,
|
|
1056
|
-
message: options.message || `${this.field} must be a number`,
|
|
1327
|
+
message: options.message || `${this.field} must be a finite number`,
|
|
1057
1328
|
value
|
|
1058
1329
|
};
|
|
1059
1330
|
}
|
|
@@ -1158,28 +1429,10 @@ const CreateListingSchema = new Validator()
|
|
|
1158
1429
|
.rule(field('title').string({ min: 1, max: 140, message: 'title must be 1-140 characters' }))
|
|
1159
1430
|
.rule(field('description').string({ max: 65535, message: 'description must be less than 65535 characters' }))
|
|
1160
1431
|
.rule(field('price').required())
|
|
1161
|
-
.rule(field('price').number({
|
|
1432
|
+
.rule(field('price').number({ positive: true, message: 'price must be a finite positive number' }))
|
|
1162
1433
|
.rule(field('who_made').enum(['i_did', 'someone_else', 'collective'], 'who_made must be one of: i_did, someone_else, collective'))
|
|
1163
|
-
.rule(field('when_made').
|
|
1164
|
-
'made_to_order',
|
|
1165
|
-
'2020_2024',
|
|
1166
|
-
'2010_2019',
|
|
1167
|
-
'2005_2009',
|
|
1168
|
-
'2000_2004',
|
|
1169
|
-
'1990s',
|
|
1170
|
-
'1980s',
|
|
1171
|
-
'1970s',
|
|
1172
|
-
'1960s',
|
|
1173
|
-
'1950s',
|
|
1174
|
-
'1940s',
|
|
1175
|
-
'1930s',
|
|
1176
|
-
'1920s',
|
|
1177
|
-
'1910s',
|
|
1178
|
-
'1900s',
|
|
1179
|
-
'1800s',
|
|
1180
|
-
'1700s',
|
|
1181
|
-
'before_1700'
|
|
1182
|
-
]))
|
|
1434
|
+
.rule(field('when_made').required())
|
|
1435
|
+
.rule(field('when_made').enum(['made_to_order', ...ETSY_WHEN_MADE_VALUES]))
|
|
1183
1436
|
.rule(field('taxonomy_id').required())
|
|
1184
1437
|
.rule(field('taxonomy_id').number({ integer: true, positive: true, message: 'taxonomy_id must be a positive integer' }));
|
|
1185
1438
|
const UpdateListingSchema = new Validator()
|
|
@@ -1313,7 +1566,8 @@ class EtsyClient {
|
|
|
1313
1566
|
this.logger = new DefaultLogger();
|
|
1314
1567
|
this.keystring = config.keystring;
|
|
1315
1568
|
this.sharedSecret = config.sharedSecret;
|
|
1316
|
-
|
|
1569
|
+
this.rateLimitingEnabled = config.rateLimiting?.enabled !== false;
|
|
1570
|
+
if (this.rateLimitingEnabled) {
|
|
1317
1571
|
this.rateLimiter = new EtsyRateLimiter({
|
|
1318
1572
|
maxRequestsPerDay: config.rateLimiting?.maxRequestsPerDay || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
|
|
1319
1573
|
maxRequestsPerSecond: config.rateLimiting?.maxRequestsPerSecond || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
|
|
@@ -1338,11 +1592,39 @@ class EtsyClient {
|
|
|
1338
1592
|
stopOnError: false
|
|
1339
1593
|
});
|
|
1340
1594
|
}
|
|
1595
|
+
async acquireRateLimitSlot() {
|
|
1596
|
+
return this.rateLimitingEnabled
|
|
1597
|
+
? this.rateLimiter.acquireRequestSlot()
|
|
1598
|
+
: undefined;
|
|
1599
|
+
}
|
|
1600
|
+
async fetchOnceWithRateLimit(url, options) {
|
|
1601
|
+
const reservationId = await this.acquireRateLimitSlot();
|
|
1602
|
+
let response;
|
|
1603
|
+
try {
|
|
1604
|
+
response = await this.fetch(url, options);
|
|
1605
|
+
}
|
|
1606
|
+
catch (error) {
|
|
1607
|
+
if (reservationId !== undefined)
|
|
1608
|
+
this.rateLimiter.releaseRequestSlot(reservationId);
|
|
1609
|
+
throw error;
|
|
1610
|
+
}
|
|
1611
|
+
if (response.status === 429) {
|
|
1612
|
+
await this.rateLimiter.handleRateLimitResponse(response.headers, reservationId);
|
|
1613
|
+
this.rateLimiter.resetRetryCount();
|
|
1614
|
+
}
|
|
1615
|
+
else {
|
|
1616
|
+
this.rateLimiter.updateFromHeaders(response.headers, reservationId);
|
|
1617
|
+
if (response.ok)
|
|
1618
|
+
this.rateLimiter.resetRetryCount();
|
|
1619
|
+
}
|
|
1620
|
+
return response;
|
|
1621
|
+
}
|
|
1341
1622
|
async makeRequest(endpoint, options = {}, useCache = true) {
|
|
1342
1623
|
const url = `${this.baseUrl}${endpoint}`;
|
|
1343
1624
|
const requestOptions = {
|
|
1344
1625
|
method: 'GET',
|
|
1345
|
-
...options
|
|
1626
|
+
...options,
|
|
1627
|
+
redirect: 'error'
|
|
1346
1628
|
};
|
|
1347
1629
|
const cacheKey = `${url}:${JSON.stringify(requestOptions)}`;
|
|
1348
1630
|
if (useCache && this.cache && requestOptions.method === 'GET') {
|
|
@@ -1356,7 +1638,6 @@ class EtsyClient {
|
|
|
1356
1638
|
}
|
|
1357
1639
|
}
|
|
1358
1640
|
}
|
|
1359
|
-
await this.rateLimiter.waitForRateLimit();
|
|
1360
1641
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
1361
1642
|
const headers = {
|
|
1362
1643
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -1368,22 +1649,38 @@ class EtsyClient {
|
|
|
1368
1649
|
return this.executeWithRetry(url, { ...requestOptions, headers }, cacheKey, useCache);
|
|
1369
1650
|
}
|
|
1370
1651
|
async executeWithRetry(url, options, cacheKey, useCache) {
|
|
1652
|
+
let retryAttempt = 0;
|
|
1371
1653
|
while (true) {
|
|
1372
1654
|
try {
|
|
1373
|
-
const
|
|
1655
|
+
const reservationId = await this.acquireRateLimitSlot();
|
|
1656
|
+
let response;
|
|
1657
|
+
try {
|
|
1658
|
+
response = await this.fetch(url, options);
|
|
1659
|
+
}
|
|
1660
|
+
catch (error) {
|
|
1661
|
+
if (reservationId !== undefined)
|
|
1662
|
+
this.rateLimiter.releaseRequestSlot(reservationId);
|
|
1663
|
+
throw error;
|
|
1664
|
+
}
|
|
1374
1665
|
if (response.status === 429) {
|
|
1375
|
-
const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers);
|
|
1666
|
+
const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers, reservationId, ++retryAttempt);
|
|
1376
1667
|
if (shouldRetry) {
|
|
1668
|
+
const method = (options.method ?? 'GET').toUpperCase();
|
|
1669
|
+
if (method !== 'GET') {
|
|
1670
|
+
this.rateLimiter.resetRetryCount();
|
|
1671
|
+
const errorText = await response.text();
|
|
1672
|
+
throw new EtsyApiError(`Etsy API error: 429 ${response.statusText}; automatic retry skipped for ${method} requests`, 429, errorText);
|
|
1673
|
+
}
|
|
1377
1674
|
this.logger.warn(`Rate limited. Retrying in ${delayMs}ms...`);
|
|
1378
1675
|
await this.sleep(delayMs);
|
|
1379
1676
|
continue;
|
|
1380
1677
|
}
|
|
1381
1678
|
}
|
|
1679
|
+
this.rateLimiter.updateFromHeaders(response.headers, reservationId);
|
|
1382
1680
|
if (!response.ok) {
|
|
1383
1681
|
const errorText = await response.text();
|
|
1384
1682
|
throw new EtsyApiError(`Etsy API error: ${response.status} ${response.statusText}`, response.status, errorText);
|
|
1385
1683
|
}
|
|
1386
|
-
this.rateLimiter.updateFromHeaders(response.headers);
|
|
1387
1684
|
this.rateLimiter.resetRetryCount();
|
|
1388
1685
|
if (response.status === 204) {
|
|
1389
1686
|
return undefined;
|
|
@@ -1686,9 +1983,87 @@ class EtsyClient {
|
|
|
1686
1983
|
async updateListingInventory(listingId, params, options) {
|
|
1687
1984
|
const legacy = options?.legacy;
|
|
1688
1985
|
const query = legacy !== undefined ? `?legacy=${legacy}` : '';
|
|
1986
|
+
if (!Array.isArray(params.products) || params.products.length === 0) {
|
|
1987
|
+
throw new TypeError('Inventory update must contain at least one product.');
|
|
1988
|
+
}
|
|
1989
|
+
const body = {
|
|
1990
|
+
products: params.products.map((product, productIndex) => {
|
|
1991
|
+
if (product.is_deleted === true) {
|
|
1992
|
+
throw new TypeError(`Inventory product ${productIndex} is deleted and cannot be submitted for replacement.`);
|
|
1993
|
+
}
|
|
1994
|
+
if (!Array.isArray(product.offerings) || product.offerings.length === 0) {
|
|
1995
|
+
throw new TypeError(`Inventory product ${productIndex} must contain at least one offering.`);
|
|
1996
|
+
}
|
|
1997
|
+
if (!Array.isArray(product.property_values)) {
|
|
1998
|
+
throw new TypeError(`Inventory product ${productIndex} must include property_values; pass [] when it has no variation properties.`);
|
|
1999
|
+
}
|
|
2000
|
+
if (product.sku !== undefined && product.sku !== null && typeof product.sku !== 'string') {
|
|
2001
|
+
throw new TypeError(`Inventory product ${productIndex} sku must be a string or null.`);
|
|
2002
|
+
}
|
|
2003
|
+
return {
|
|
2004
|
+
...(product.sku !== undefined ? { sku: product.sku } : {}),
|
|
2005
|
+
property_values: product.property_values.map((value, valueIndex) => {
|
|
2006
|
+
if (!Number.isInteger(value.property_id) || value.property_id < 1) {
|
|
2007
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_id must be a positive integer.`);
|
|
2008
|
+
}
|
|
2009
|
+
if (!Array.isArray(value.value_ids) || value.value_ids.some((id) => !Number.isInteger(id))) {
|
|
2010
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].value_ids must be an integer array.`);
|
|
2011
|
+
}
|
|
2012
|
+
if (!Array.isArray(value.values) || value.values.some((entry) => typeof entry !== 'string')) {
|
|
2013
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].values must be a string array.`);
|
|
2014
|
+
}
|
|
2015
|
+
if (value.property_name !== undefined && typeof value.property_name !== 'string') {
|
|
2016
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_name must be a string.`);
|
|
2017
|
+
}
|
|
2018
|
+
if (value.scale_id !== undefined && value.scale_id !== null &&
|
|
2019
|
+
(!Number.isInteger(value.scale_id) || value.scale_id < 1)) {
|
|
2020
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].scale_id must be a positive integer or null.`);
|
|
2021
|
+
}
|
|
2022
|
+
return {
|
|
2023
|
+
property_id: value.property_id,
|
|
2024
|
+
...(value.property_name !== undefined ? { property_name: value.property_name } : {}),
|
|
2025
|
+
...(value.scale_id !== undefined ? { scale_id: value.scale_id } : {}),
|
|
2026
|
+
value_ids: value.value_ids,
|
|
2027
|
+
values: value.values,
|
|
2028
|
+
};
|
|
2029
|
+
}),
|
|
2030
|
+
offerings: product.offerings.map((offering, offeringIndex) => {
|
|
2031
|
+
if (offering.is_deleted === true) {
|
|
2032
|
+
throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is deleted and cannot be submitted for replacement.`);
|
|
2033
|
+
}
|
|
2034
|
+
if (typeof offering.price !== 'number' || !Number.isFinite(offering.price)) {
|
|
2035
|
+
throw new TypeError('Inventory offering price must be a finite decimal number in the listing currency.');
|
|
2036
|
+
}
|
|
2037
|
+
if (!Number.isInteger(offering.quantity)) {
|
|
2038
|
+
throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} quantity must be an integer.`);
|
|
2039
|
+
}
|
|
2040
|
+
if (typeof offering.is_enabled !== 'boolean') {
|
|
2041
|
+
throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is_enabled must be a boolean.`);
|
|
2042
|
+
}
|
|
2043
|
+
if (offering.readiness_state_id === undefined ||
|
|
2044
|
+
(offering.readiness_state_id !== null &&
|
|
2045
|
+
(!Number.isInteger(offering.readiness_state_id) || offering.readiness_state_id < 1))) {
|
|
2046
|
+
throw new TypeError('Inventory offering readiness_state_id is required; use null when not set.');
|
|
2047
|
+
}
|
|
2048
|
+
return {
|
|
2049
|
+
price: offering.price,
|
|
2050
|
+
quantity: offering.quantity,
|
|
2051
|
+
is_enabled: offering.is_enabled,
|
|
2052
|
+
readiness_state_id: offering.readiness_state_id,
|
|
2053
|
+
};
|
|
2054
|
+
}),
|
|
2055
|
+
};
|
|
2056
|
+
}),
|
|
2057
|
+
...(params.price_on_property !== undefined ? { price_on_property: params.price_on_property } : {}),
|
|
2058
|
+
...(params.quantity_on_property !== undefined ? { quantity_on_property: params.quantity_on_property } : {}),
|
|
2059
|
+
...(params.sku_on_property !== undefined ? { sku_on_property: params.sku_on_property } : {}),
|
|
2060
|
+
...(params.readiness_state_on_property !== undefined
|
|
2061
|
+
? { readiness_state_on_property: params.readiness_state_on_property }
|
|
2062
|
+
: {}),
|
|
2063
|
+
};
|
|
1689
2064
|
return this.makeRequest(`/listings/${listingId}/inventory${query}`, {
|
|
1690
2065
|
method: 'PUT',
|
|
1691
|
-
body: JSON.stringify(
|
|
2066
|
+
body: JSON.stringify(body)
|
|
1692
2067
|
}, false);
|
|
1693
2068
|
}
|
|
1694
2069
|
async uploadListingImage(shopId, listingId, imageData, params) {
|
|
@@ -1703,9 +2078,8 @@ class EtsyClient {
|
|
|
1703
2078
|
if (params?.alt_text)
|
|
1704
2079
|
formData.append('alt_text', params.alt_text);
|
|
1705
2080
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/images`;
|
|
1706
|
-
await this.rateLimiter.waitForRateLimit();
|
|
1707
2081
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
1708
|
-
const response = await this.
|
|
2082
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
1709
2083
|
method: 'POST',
|
|
1710
2084
|
headers: {
|
|
1711
2085
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -1900,10 +2274,11 @@ class EtsyClient {
|
|
|
1900
2274
|
}
|
|
1901
2275
|
async getShopPayment(shopId, paymentId) {
|
|
1902
2276
|
const payments = await this.getPayments(shopId, [Number(paymentId)]);
|
|
1903
|
-
|
|
2277
|
+
const payment = payments[0];
|
|
2278
|
+
if (!payment) {
|
|
1904
2279
|
throw new EtsyApiError('Payment not found', 404);
|
|
1905
2280
|
}
|
|
1906
|
-
return
|
|
2281
|
+
return payment;
|
|
1907
2282
|
}
|
|
1908
2283
|
async getBuyerTaxonomyNodes() {
|
|
1909
2284
|
const response = await this.makeRequest('/buyer-taxonomy/nodes');
|
|
@@ -1926,9 +2301,8 @@ class EtsyClient {
|
|
|
1926
2301
|
body.append('scale_id', scaleId.toString());
|
|
1927
2302
|
}
|
|
1928
2303
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/properties/${propertyId}`;
|
|
1929
|
-
await this.rateLimiter.waitForRateLimit();
|
|
1930
2304
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
1931
|
-
const response = await this.
|
|
2305
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
1932
2306
|
method: 'PUT',
|
|
1933
2307
|
headers: {
|
|
1934
2308
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -1937,8 +2311,6 @@ class EtsyClient {
|
|
|
1937
2311
|
},
|
|
1938
2312
|
body: body.toString()
|
|
1939
2313
|
});
|
|
1940
|
-
this.rateLimiter.updateFromHeaders(response.headers);
|
|
1941
|
-
this.rateLimiter.resetRetryCount();
|
|
1942
2314
|
if (!response.ok) {
|
|
1943
2315
|
const errorText = await response.text();
|
|
1944
2316
|
throw new EtsyApiError(`Failed to update listing property: ${response.status} ${response.statusText}`, response.status, errorText);
|
|
@@ -2038,9 +2410,8 @@ class EtsyClient {
|
|
|
2038
2410
|
if (params?.listing_file_id !== undefined)
|
|
2039
2411
|
formData.append('listing_file_id', params.listing_file_id.toString());
|
|
2040
2412
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/files`;
|
|
2041
|
-
await this.rateLimiter.waitForRateLimit();
|
|
2042
2413
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
2043
|
-
const response = await this.
|
|
2414
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
2044
2415
|
method: 'POST',
|
|
2045
2416
|
headers: {
|
|
2046
2417
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -2072,9 +2443,8 @@ class EtsyClient {
|
|
|
2072
2443
|
if (params?.video_id !== undefined)
|
|
2073
2444
|
formData.append('video_id', params.video_id.toString());
|
|
2074
2445
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/videos`;
|
|
2075
|
-
await this.rateLimiter.waitForRateLimit();
|
|
2076
2446
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
2077
|
-
const response = await this.
|
|
2447
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
2078
2448
|
method: 'POST',
|
|
2079
2449
|
headers: {
|
|
2080
2450
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -2471,6 +2841,7 @@ class AuthHelper {
|
|
|
2471
2841
|
assertFetchSupport();
|
|
2472
2842
|
const response = await fetch('https://api.etsy.com/v3/public/oauth/token', {
|
|
2473
2843
|
method: 'POST',
|
|
2844
|
+
redirect: 'error',
|
|
2474
2845
|
headers: {
|
|
2475
2846
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
2476
2847
|
'Accept': 'application/json'
|
|
@@ -2563,18 +2934,17 @@ const COMMON_SCOPE_COMBINATIONS = {
|
|
|
2563
2934
|
]
|
|
2564
2935
|
};
|
|
2565
2936
|
|
|
2937
|
+
const QPD_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
2566
2938
|
class GlobalRequestQueue {
|
|
2567
2939
|
constructor() {
|
|
2568
2940
|
this.queue = [];
|
|
2569
2941
|
this.processing = false;
|
|
2570
2942
|
this.rateLimits = new Map();
|
|
2571
|
-
this.
|
|
2572
|
-
this.dailyReset = new Date();
|
|
2943
|
+
this.requestTimestamps = [];
|
|
2573
2944
|
this.lastRequestTime = 0;
|
|
2574
2945
|
this.maxRequestsPerDay = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY;
|
|
2575
2946
|
this.maxRequestsPerSecond = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND;
|
|
2576
2947
|
this.minRequestInterval = ETSY_RATE_LIMITS.MIN_REQUEST_INTERVAL;
|
|
2577
|
-
this.setNextDailyReset();
|
|
2578
2948
|
}
|
|
2579
2949
|
static getInstance() {
|
|
2580
2950
|
if (!this.instance) {
|
|
@@ -2606,11 +2976,13 @@ class GlobalRequestQueue {
|
|
|
2606
2976
|
});
|
|
2607
2977
|
}
|
|
2608
2978
|
getStatus() {
|
|
2979
|
+
const now = Date.now();
|
|
2980
|
+
this.pruneRequestTimestamps(now);
|
|
2609
2981
|
return {
|
|
2610
2982
|
queueLength: this.queue.length,
|
|
2611
2983
|
processing: this.processing,
|
|
2612
|
-
remainingRequests: Math.max(0, this.maxRequestsPerDay - this.
|
|
2613
|
-
resetTime: this.
|
|
2984
|
+
remainingRequests: Math.max(0, this.maxRequestsPerDay - this.requestTimestamps.length),
|
|
2985
|
+
resetTime: this.getNextRequestExpiry(now),
|
|
2614
2986
|
};
|
|
2615
2987
|
}
|
|
2616
2988
|
clear() {
|
|
@@ -2626,16 +2998,13 @@ class GlobalRequestQueue {
|
|
|
2626
2998
|
this.processing = true;
|
|
2627
2999
|
try {
|
|
2628
3000
|
while (this.queue.length > 0) {
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
const timeUntilReset = this.dailyReset.getTime() - Date.now();
|
|
2635
|
-
console.warn(`Daily rate limit reached. Waiting ${Math.ceil(timeUntilReset / 1000 / 60)} minutes until reset.`);
|
|
3001
|
+
const now = Date.now();
|
|
3002
|
+
this.pruneRequestTimestamps(now);
|
|
3003
|
+
if (this.requestTimestamps.length >= this.maxRequestsPerDay) {
|
|
3004
|
+
const timeUntilReset = Math.max(0, this.getNextRequestExpiry(now).getTime() - now);
|
|
3005
|
+
console.warn(`Daily rate limit reached. Waiting ${Math.ceil(timeUntilReset / 1000 / 60)} minutes for the oldest request to leave the rolling 24-hour window.`);
|
|
2636
3006
|
await this.delay(timeUntilReset);
|
|
2637
|
-
|
|
2638
|
-
this.setNextDailyReset();
|
|
3007
|
+
continue;
|
|
2639
3008
|
}
|
|
2640
3009
|
await this.waitForRateLimit();
|
|
2641
3010
|
this.queue.sort((a, b) => {
|
|
@@ -2670,7 +3039,7 @@ class GlobalRequestQueue {
|
|
|
2670
3039
|
}
|
|
2671
3040
|
});
|
|
2672
3041
|
try {
|
|
2673
|
-
result = await Promise.race([item.request
|
|
3042
|
+
result = await Promise.race([this.startRequest(item.request), timeoutPromise]);
|
|
2674
3043
|
}
|
|
2675
3044
|
finally {
|
|
2676
3045
|
if (timeoutId) {
|
|
@@ -2679,11 +3048,9 @@ class GlobalRequestQueue {
|
|
|
2679
3048
|
}
|
|
2680
3049
|
}
|
|
2681
3050
|
else {
|
|
2682
|
-
result = await item.request
|
|
3051
|
+
result = await this.startRequest(item.request);
|
|
2683
3052
|
}
|
|
2684
3053
|
item.resolve(result);
|
|
2685
|
-
this.requestCount++;
|
|
2686
|
-
this.lastRequestTime = Date.now();
|
|
2687
3054
|
}
|
|
2688
3055
|
catch (error) {
|
|
2689
3056
|
if (error instanceof EtsyRateLimitError) {
|
|
@@ -2703,7 +3070,7 @@ class GlobalRequestQueue {
|
|
|
2703
3070
|
const globalRateLimit = this.rateLimits.get('global');
|
|
2704
3071
|
if (globalRateLimit && now < globalRateLimit.resetAt) {
|
|
2705
3072
|
const waitTime = globalRateLimit.resetAt - now;
|
|
2706
|
-
console.
|
|
3073
|
+
console.warn(`Global rate limit active. Waiting ${waitTime}ms`);
|
|
2707
3074
|
await this.delay(waitTime);
|
|
2708
3075
|
}
|
|
2709
3076
|
const timeSinceLastRequest = now - this.lastRequestTime;
|
|
@@ -2721,11 +3088,23 @@ class GlobalRequestQueue {
|
|
|
2721
3088
|
});
|
|
2722
3089
|
}
|
|
2723
3090
|
}
|
|
2724
|
-
|
|
2725
|
-
const
|
|
2726
|
-
this.
|
|
2727
|
-
|
|
2728
|
-
|
|
3091
|
+
pruneRequestTimestamps(now) {
|
|
3092
|
+
const oldestAllowed = now - QPD_WINDOW_MS;
|
|
3093
|
+
while (this.requestTimestamps[0] !== undefined && this.requestTimestamps[0] <= oldestAllowed) {
|
|
3094
|
+
this.requestTimestamps.shift();
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
getNextRequestExpiry(now) {
|
|
3098
|
+
this.pruneRequestTimestamps(now);
|
|
3099
|
+
const oldestRequest = this.requestTimestamps[0] ?? now;
|
|
3100
|
+
return new Date(oldestRequest + QPD_WINDOW_MS);
|
|
3101
|
+
}
|
|
3102
|
+
startRequest(request) {
|
|
3103
|
+
const startedAt = Date.now();
|
|
3104
|
+
this.pruneRequestTimestamps(startedAt);
|
|
3105
|
+
this.requestTimestamps.push(startedAt);
|
|
3106
|
+
this.lastRequestTime = startedAt;
|
|
3107
|
+
return request();
|
|
2729
3108
|
}
|
|
2730
3109
|
generateId() {
|
|
2731
3110
|
return `${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
@@ -4110,7 +4489,7 @@ function createTokenManager(config, storage) {
|
|
|
4110
4489
|
function createRateLimiter(config) {
|
|
4111
4490
|
return new EtsyRateLimiter(config);
|
|
4112
4491
|
}
|
|
4113
|
-
const VERSION = '
|
|
4492
|
+
const VERSION = '3.0.1';
|
|
4114
4493
|
const LIBRARY_NAME = 'etsy-v3-api-client';
|
|
4115
4494
|
function getLibraryInfo() {
|
|
4116
4495
|
return {
|