@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/index.cjs
CHANGED
|
@@ -251,6 +251,26 @@ class EtsyRateLimitError extends Error {
|
|
|
251
251
|
return this.errorType !== 'qpd_exhausted';
|
|
252
252
|
}
|
|
253
253
|
}
|
|
254
|
+
const ETSY_WHEN_MADE_VALUES = [
|
|
255
|
+
'2020_2026',
|
|
256
|
+
'2010_2019',
|
|
257
|
+
'2007_2009',
|
|
258
|
+
'before_2007',
|
|
259
|
+
'2000_2006',
|
|
260
|
+
'1990s',
|
|
261
|
+
'1980s',
|
|
262
|
+
'1970s',
|
|
263
|
+
'1960s',
|
|
264
|
+
'1950s',
|
|
265
|
+
'1940s',
|
|
266
|
+
'1930s',
|
|
267
|
+
'1920s',
|
|
268
|
+
'1910s',
|
|
269
|
+
'1900s',
|
|
270
|
+
'1800s',
|
|
271
|
+
'1700s',
|
|
272
|
+
'before_1700'
|
|
273
|
+
];
|
|
254
274
|
|
|
255
275
|
const isBrowser$1 = typeof window !== 'undefined' && typeof window.document !== 'undefined';
|
|
256
276
|
const isNode$1 = typeof process !== 'undefined' && !!process.versions?.node;
|
|
@@ -323,9 +343,12 @@ class MemoryTokenStorage {
|
|
|
323
343
|
class TokenManager {
|
|
324
344
|
constructor(config, storage, rotationConfig) {
|
|
325
345
|
this.currentTokens = null;
|
|
346
|
+
this.tokenMutationGeneration = 0;
|
|
326
347
|
this.keystring = config.keystring;
|
|
327
|
-
this.refreshCallback = config.refreshSave;
|
|
348
|
+
this.refreshCallback = config.refreshSaveAsync ?? config.refreshSave;
|
|
349
|
+
this.refreshClearRequired = config.refreshSaveAsync !== undefined;
|
|
328
350
|
this.storage = storage;
|
|
351
|
+
this.clearCallback = config.refreshClearAsync;
|
|
329
352
|
this.rotationConfig = rotationConfig;
|
|
330
353
|
this.currentTokens = {
|
|
331
354
|
access_token: config.accessToken,
|
|
@@ -362,21 +385,82 @@ class TokenManager {
|
|
|
362
385
|
if (!this.currentTokens) {
|
|
363
386
|
throw new EtsyAuthError('No tokens available to refresh', 'NO_REFRESH_TOKEN');
|
|
364
387
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
this.
|
|
369
|
-
|
|
370
|
-
|
|
388
|
+
const generation = this.tokenMutationGeneration;
|
|
389
|
+
const refreshPromise = (this.pendingTokens
|
|
390
|
+
? this.retryPendingTokenPersistence()
|
|
391
|
+
: this.performTokenRefresh().then(async (newTokens) => {
|
|
392
|
+
this.throwIfSuperseded(generation);
|
|
393
|
+
this.pendingTokens = newTokens;
|
|
394
|
+
return this.persistRefreshedTokens(newTokens, generation);
|
|
395
|
+
})).finally(() => {
|
|
396
|
+
this.refreshPromise = undefined;
|
|
397
|
+
});
|
|
398
|
+
this.refreshPromise = refreshPromise;
|
|
399
|
+
return refreshPromise;
|
|
400
|
+
}
|
|
401
|
+
throwIfSuperseded(generation) {
|
|
402
|
+
if (generation !== this.tokenMutationGeneration) {
|
|
403
|
+
throw new EtsyAuthError('Token refresh was superseded by a token update or clear', 'TOKEN_REFRESH_SUPERSEDED');
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async repairPersistenceToCurrentTokens() {
|
|
407
|
+
while (true) {
|
|
408
|
+
const generation = this.tokenMutationGeneration;
|
|
409
|
+
const current = this.currentTokens ? { ...this.currentTokens } : null;
|
|
410
|
+
try {
|
|
411
|
+
if (current) {
|
|
412
|
+
if (this.storage)
|
|
413
|
+
await this.storage.save(current);
|
|
414
|
+
if (this.refreshCallback) {
|
|
415
|
+
await this.refreshCallback(current.access_token, current.refresh_token, current.expires_at);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
if (this.storage)
|
|
420
|
+
await this.storage.clear();
|
|
421
|
+
if (this.clearCallback)
|
|
422
|
+
await this.clearCallback();
|
|
423
|
+
else if (this.refreshClearRequired) {
|
|
424
|
+
throw new EtsyAuthError('A refresh persistence callback is configured without refreshClearAsync; durable tokens could not be cleared safely', 'TOKEN_CLEAR_CALLBACK_REQUIRED');
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
if (generation !== this.tokenMutationGeneration)
|
|
430
|
+
continue;
|
|
431
|
+
throw error;
|
|
371
432
|
}
|
|
372
|
-
if (this.
|
|
373
|
-
|
|
433
|
+
if (generation === this.tokenMutationGeneration)
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
async persistRefreshedTokens(newTokens, generation) {
|
|
438
|
+
this.throwIfSuperseded(generation);
|
|
439
|
+
if (this.storage) {
|
|
440
|
+
await this.storage.save(newTokens);
|
|
441
|
+
if (generation !== this.tokenMutationGeneration) {
|
|
442
|
+
await this.repairPersistenceToCurrentTokens();
|
|
443
|
+
this.throwIfSuperseded(generation);
|
|
374
444
|
}
|
|
375
|
-
return newTokens;
|
|
376
445
|
}
|
|
377
|
-
|
|
378
|
-
|
|
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');
|
|
379
462
|
}
|
|
463
|
+
return this.persistRefreshedTokens(this.pendingTokens, this.tokenMutationGeneration);
|
|
380
464
|
}
|
|
381
465
|
async performTokenRefresh() {
|
|
382
466
|
if (!this.currentTokens) {
|
|
@@ -390,6 +474,7 @@ class TokenManager {
|
|
|
390
474
|
try {
|
|
391
475
|
const response = await this.fetch('https://api.etsy.com/v3/public/oauth/token', {
|
|
392
476
|
method: 'POST',
|
|
477
|
+
redirect: 'error',
|
|
393
478
|
headers: {
|
|
394
479
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
395
480
|
'Accept': 'application/json'
|
|
@@ -419,7 +504,9 @@ class TokenManager {
|
|
|
419
504
|
return this.currentTokens ? { ...this.currentTokens } : null;
|
|
420
505
|
}
|
|
421
506
|
updateTokens(tokens) {
|
|
507
|
+
this.tokenMutationGeneration += 1;
|
|
422
508
|
this.currentTokens = { ...tokens };
|
|
509
|
+
this.pendingTokens = undefined;
|
|
423
510
|
}
|
|
424
511
|
isTokenExpired() {
|
|
425
512
|
if (!this.currentTokens) {
|
|
@@ -439,10 +526,19 @@ class TokenManager {
|
|
|
439
526
|
return now.getTime() >= (expiresAt.getTime() - bufferTime);
|
|
440
527
|
}
|
|
441
528
|
async clearTokens() {
|
|
529
|
+
this.tokenMutationGeneration += 1;
|
|
442
530
|
this.currentTokens = null;
|
|
531
|
+
this.pendingTokens = undefined;
|
|
532
|
+
if (this.refreshPromise)
|
|
533
|
+
await this.refreshPromise.catch(() => undefined);
|
|
443
534
|
if (this.storage) {
|
|
444
535
|
await this.storage.clear();
|
|
445
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
|
+
}
|
|
446
542
|
}
|
|
447
543
|
getTimeUntilExpiration() {
|
|
448
544
|
if (!this.currentTokens) {
|
|
@@ -682,13 +778,24 @@ const ETSY_RATE_LIMITS = {
|
|
|
682
778
|
MAX_REQUESTS_PER_SECOND: 5,
|
|
683
779
|
MIN_REQUEST_INTERVAL: 200,
|
|
684
780
|
};
|
|
781
|
+
const QPD_WINDOW_MS$1 = 24 * 60 * 60 * 1000;
|
|
685
782
|
class EtsyRateLimiter {
|
|
686
783
|
constructor(config) {
|
|
687
|
-
this.
|
|
688
|
-
this.
|
|
784
|
+
this.requestTimestamps = [];
|
|
785
|
+
this.uncertainRequestTimestamps = [];
|
|
689
786
|
this.lastRequestTime = 0;
|
|
690
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();
|
|
691
796
|
this.currentRetryCount = 0;
|
|
797
|
+
this.reservationQueue = Promise.resolve();
|
|
798
|
+
const overrides = Object.fromEntries(Object.entries(config ?? {}).filter(([, value]) => value !== undefined));
|
|
692
799
|
this.config = {
|
|
693
800
|
maxRequestsPerDay: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
|
|
694
801
|
maxRequestsPerSecond: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
|
|
@@ -699,21 +806,155 @@ class EtsyRateLimiter {
|
|
|
699
806
|
jitter: 0.1,
|
|
700
807
|
qpdWarningThreshold: 80,
|
|
701
808
|
onApproachingLimit: undefined,
|
|
702
|
-
...
|
|
809
|
+
...overrides
|
|
703
810
|
};
|
|
704
|
-
this.setNextDailyReset();
|
|
705
811
|
}
|
|
706
|
-
|
|
707
|
-
const
|
|
708
|
-
this.
|
|
709
|
-
|
|
710
|
-
|
|
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();
|
|
877
|
+
}
|
|
878
|
+
async acquireRequestSlot() {
|
|
879
|
+
const previousReservation = this.reservationQueue;
|
|
880
|
+
let releaseReservation = () => undefined;
|
|
881
|
+
this.reservationQueue = new Promise((resolve) => {
|
|
882
|
+
releaseReservation = resolve;
|
|
883
|
+
});
|
|
884
|
+
await previousReservation;
|
|
885
|
+
try {
|
|
886
|
+
while (true) {
|
|
887
|
+
const now = Date.now();
|
|
888
|
+
this.pruneRequestTimestamps(now);
|
|
889
|
+
this.refreshExpiredHeaderQuota(now);
|
|
890
|
+
let isQuotaProbe = false;
|
|
891
|
+
if (this.headerRemainingToday === 0 || this.isQuotaProbeReady(now)) {
|
|
892
|
+
if (this.quotaProbeInFlight || this.inFlightReservations.size > 0) {
|
|
893
|
+
await this.waitForStateChange();
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
const probeWait = this.headerQuotaAvailableAt - now;
|
|
897
|
+
if (probeWait > 0) {
|
|
898
|
+
await new Promise(resolve => setTimeout(resolve, probeWait));
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
isQuotaProbe = true;
|
|
902
|
+
}
|
|
903
|
+
else {
|
|
904
|
+
const effectiveRemaining = this.getEffectiveRemainingRequests(now);
|
|
905
|
+
if (effectiveRemaining <= 0) {
|
|
906
|
+
if (this.inFlightReservations.size > 0 && this.headerRemainingToday !== undefined) {
|
|
907
|
+
await this.waitForStateChange();
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
const effectiveDailyLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
|
|
911
|
+
const localResetTime = this.getLocalResetTime(now);
|
|
912
|
+
const timeUntilReset = Math.max(0, localResetTime.getTime() - now);
|
|
913
|
+
throw new EtsyRateLimitError(`Daily rate limit exhausted (${effectiveDailyLimit} requests in the local rolling 24-hour window). ` +
|
|
914
|
+
`The oldest local request expires in approximately ${Math.ceil(timeUntilReset / 1000 / 60)} minutes.`, Math.ceil(timeUntilReset / 1000), 'qpd_exhausted');
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
const intervalWait = Math.max(0, this.getEffectiveMinInterval() - (now - this.lastRequestTime));
|
|
918
|
+
const cooldownWait = Math.max(0, this.retryNotBefore - now);
|
|
919
|
+
const waitTime = Math.max(intervalWait, cooldownWait);
|
|
920
|
+
if (waitTime > 0) {
|
|
921
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
const requestStartedAt = Date.now();
|
|
925
|
+
const reservationId = ++this.nextReservationId;
|
|
926
|
+
this.requestTimestamps.push(requestStartedAt);
|
|
927
|
+
this.lastRequestTime = requestStartedAt;
|
|
928
|
+
this.inFlightReservations.set(reservationId, { isQuotaProbe, startedAt: requestStartedAt });
|
|
929
|
+
if (isQuotaProbe)
|
|
930
|
+
this.quotaProbeInFlight = true;
|
|
931
|
+
return reservationId;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
finally {
|
|
935
|
+
releaseReservation();
|
|
936
|
+
}
|
|
711
937
|
}
|
|
712
|
-
|
|
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;
|
|
713
949
|
if (!headers) {
|
|
950
|
+
if (matchedReservationId !== undefined)
|
|
951
|
+
this.finishReservation(matchedReservationId, false);
|
|
714
952
|
return;
|
|
715
953
|
}
|
|
716
954
|
const parsed = this.parseRateLimitHeaders(headers);
|
|
955
|
+
if (matchedReservationId !== undefined) {
|
|
956
|
+
this.finishReservation(matchedReservationId, parsed.remainingToday !== undefined);
|
|
957
|
+
}
|
|
717
958
|
if (parsed.limitPerSecond !== undefined) {
|
|
718
959
|
this.headerLimitPerSecond = parsed.limitPerSecond;
|
|
719
960
|
this.isHeaderBasedLimiting = true;
|
|
@@ -726,9 +967,39 @@ class EtsyRateLimiter {
|
|
|
726
967
|
this.isHeaderBasedLimiting = true;
|
|
727
968
|
}
|
|
728
969
|
if (parsed.remainingToday !== undefined) {
|
|
729
|
-
|
|
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
|
+
}
|
|
730
994
|
this.checkApproachingLimit();
|
|
731
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();
|
|
732
1003
|
}
|
|
733
1004
|
parseRateLimitHeaders(headers) {
|
|
734
1005
|
const getHeader = (name) => {
|
|
@@ -768,18 +1039,27 @@ class EtsyRateLimiter {
|
|
|
768
1039
|
this.config.onApproachingLimit(remaining, limit, percentageUsed);
|
|
769
1040
|
}
|
|
770
1041
|
}
|
|
771
|
-
async handleRateLimitResponse(headers) {
|
|
1042
|
+
async handleRateLimitResponse(headers, reservationId, retryAttempt) {
|
|
772
1043
|
const parsed = headers ? this.parseRateLimitHeaders(headers) : {};
|
|
773
|
-
this.updateFromHeaders(headers);
|
|
774
|
-
if (
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
+
}
|
|
779
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;
|
|
780
1061
|
throw new EtsyRateLimitError(`Max retries (${this.config.maxRetries}) exceeded for rate limit`, parsed.retryAfter, 'qps_exhausted');
|
|
781
1062
|
}
|
|
782
|
-
const delayMs = this.calculateBackoffDelay(this.currentRetryCount, parsed.retryAfter);
|
|
783
1063
|
return { shouldRetry: true, delayMs };
|
|
784
1064
|
}
|
|
785
1065
|
calculateBackoffDelay(attempt, retryAfterSeconds) {
|
|
@@ -792,7 +1072,7 @@ class EtsyRateLimiter {
|
|
|
792
1072
|
const randomJitter = Math.random() * jitterAmount * 2 - jitterAmount;
|
|
793
1073
|
delay += randomJitter;
|
|
794
1074
|
}
|
|
795
|
-
return Math.max(0, Math.floor(delay));
|
|
1075
|
+
return Math.max(serverSuggestedMs, 0, Math.floor(delay));
|
|
796
1076
|
}
|
|
797
1077
|
resetRetryCount() {
|
|
798
1078
|
this.currentRetryCount = 0;
|
|
@@ -810,41 +1090,26 @@ class EtsyRateLimiter {
|
|
|
810
1090
|
return this.config.minRequestInterval;
|
|
811
1091
|
}
|
|
812
1092
|
async waitForRateLimit() {
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
const effectiveDailyLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
|
|
819
|
-
const effectiveRemaining = this.headerRemainingToday ??
|
|
820
|
-
(effectiveDailyLimit - this.requestCount);
|
|
821
|
-
if (effectiveRemaining <= 0) {
|
|
822
|
-
const timeUntilReset = this.dailyReset.getTime() - now;
|
|
823
|
-
throw new EtsyRateLimitError(`Daily rate limit exhausted (${effectiveDailyLimit} requests). ` +
|
|
824
|
-
`Reset in approximately ${Math.ceil(timeUntilReset / 1000 / 60)} minutes.`, Math.ceil(timeUntilReset / 1000), 'qpd_exhausted');
|
|
825
|
-
}
|
|
826
|
-
const minInterval = this.getEffectiveMinInterval();
|
|
827
|
-
const timeSinceLastRequest = now - this.lastRequestTime;
|
|
828
|
-
if (timeSinceLastRequest < minInterval) {
|
|
829
|
-
const waitTime = minInterval - timeSinceLastRequest;
|
|
830
|
-
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
831
|
-
}
|
|
832
|
-
this.requestCount++;
|
|
833
|
-
this.lastRequestTime = Date.now();
|
|
1093
|
+
const reservationId = await this.acquireRequestSlot();
|
|
1094
|
+
this.finishReservation(reservationId, false);
|
|
1095
|
+
}
|
|
1096
|
+
async waitForRateLimitWithReservation() {
|
|
1097
|
+
return this.acquireRequestSlot();
|
|
834
1098
|
}
|
|
835
1099
|
getRateLimitStatus() {
|
|
836
1100
|
const now = Date.now();
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
const effectiveRemaining = this.headerRemainingToday ??
|
|
842
|
-
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);
|
|
843
1105
|
return {
|
|
844
1106
|
remainingRequests: effectiveRemaining,
|
|
845
|
-
resetTime: this.
|
|
846
|
-
|
|
847
|
-
|
|
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,
|
|
848
1113
|
isFromHeaders: this.isHeaderBasedLimiting,
|
|
849
1114
|
limitPerSecond: this.headerLimitPerSecond,
|
|
850
1115
|
remainingThisSecond: this.headerRemainingThisSecond,
|
|
@@ -852,41 +1117,47 @@ class EtsyRateLimiter {
|
|
|
852
1117
|
};
|
|
853
1118
|
}
|
|
854
1119
|
getRemainingRequests() {
|
|
855
|
-
|
|
856
|
-
|
|
1120
|
+
const now = Date.now();
|
|
1121
|
+
this.pruneRequestTimestamps(now);
|
|
1122
|
+
this.refreshExpiredHeaderQuota(now);
|
|
1123
|
+
return this.getEffectiveRemainingRequests(now);
|
|
857
1124
|
}
|
|
858
1125
|
reset() {
|
|
859
|
-
this.
|
|
1126
|
+
this.requestTimestamps = [];
|
|
860
1127
|
this.lastRequestTime = 0;
|
|
861
1128
|
this.currentRetryCount = 0;
|
|
1129
|
+
this.quotaProbeBackoffMs = 60000;
|
|
1130
|
+
this.headerQuotaAvailableAt = 0;
|
|
1131
|
+
this.retryNotBefore = 0;
|
|
862
1132
|
this.headerLimitPerSecond = undefined;
|
|
863
1133
|
this.headerRemainingThisSecond = undefined;
|
|
864
1134
|
this.headerLimitPerDay = undefined;
|
|
865
1135
|
this.headerRemainingToday = undefined;
|
|
866
1136
|
this.isHeaderBasedLimiting = false;
|
|
867
|
-
this.
|
|
1137
|
+
this.nextReservationId = 0;
|
|
1138
|
+
this.latestHeaderReservationId = 0;
|
|
1139
|
+
this.inFlightReservations.clear();
|
|
1140
|
+
this.uncertainRequestTimestamps = [];
|
|
1141
|
+
this.quotaProbeInFlight = false;
|
|
1142
|
+
this.notifyStateWaiters();
|
|
868
1143
|
}
|
|
869
1144
|
canMakeRequest() {
|
|
870
1145
|
const now = Date.now();
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
(this.config.maxRequestsPerDay - this.requestCount);
|
|
877
|
-
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) {
|
|
878
1151
|
return false;
|
|
879
1152
|
}
|
|
880
|
-
return (now - this.lastRequestTime) >= this.getEffectiveMinInterval()
|
|
1153
|
+
return (now - this.lastRequestTime) >= this.getEffectiveMinInterval() &&
|
|
1154
|
+
now >= this.retryNotBefore;
|
|
881
1155
|
}
|
|
882
1156
|
getTimeUntilNextRequest() {
|
|
883
1157
|
const now = Date.now();
|
|
884
1158
|
const timeSinceLastRequest = now - this.lastRequestTime;
|
|
885
1159
|
const minInterval = this.getEffectiveMinInterval();
|
|
886
|
-
|
|
887
|
-
return 0;
|
|
888
|
-
}
|
|
889
|
-
return minInterval - timeSinceLastRequest;
|
|
1160
|
+
return Math.max(0, minInterval - timeSinceLastRequest, this.retryNotBefore - now);
|
|
890
1161
|
}
|
|
891
1162
|
getConfig() {
|
|
892
1163
|
return { ...this.config };
|
|
@@ -1088,10 +1359,10 @@ class FieldValidator {
|
|
|
1088
1359
|
const value = data[this.field];
|
|
1089
1360
|
if (value === undefined || value === null)
|
|
1090
1361
|
return null;
|
|
1091
|
-
if (typeof value !== 'number' || Number.
|
|
1362
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
1092
1363
|
return {
|
|
1093
1364
|
field: this.field,
|
|
1094
|
-
message: options.message || `${this.field} must be a number`,
|
|
1365
|
+
message: options.message || `${this.field} must be a finite number`,
|
|
1095
1366
|
value
|
|
1096
1367
|
};
|
|
1097
1368
|
}
|
|
@@ -1196,28 +1467,10 @@ const CreateListingSchema = new Validator()
|
|
|
1196
1467
|
.rule(field('title').string({ min: 1, max: 140, message: 'title must be 1-140 characters' }))
|
|
1197
1468
|
.rule(field('description').string({ max: 65535, message: 'description must be less than 65535 characters' }))
|
|
1198
1469
|
.rule(field('price').required())
|
|
1199
|
-
.rule(field('price').number({
|
|
1470
|
+
.rule(field('price').number({ positive: true, message: 'price must be a finite positive number' }))
|
|
1200
1471
|
.rule(field('who_made').enum(['i_did', 'someone_else', 'collective'], 'who_made must be one of: i_did, someone_else, collective'))
|
|
1201
|
-
.rule(field('when_made').
|
|
1202
|
-
'made_to_order',
|
|
1203
|
-
'2020_2024',
|
|
1204
|
-
'2010_2019',
|
|
1205
|
-
'2005_2009',
|
|
1206
|
-
'2000_2004',
|
|
1207
|
-
'1990s',
|
|
1208
|
-
'1980s',
|
|
1209
|
-
'1970s',
|
|
1210
|
-
'1960s',
|
|
1211
|
-
'1950s',
|
|
1212
|
-
'1940s',
|
|
1213
|
-
'1930s',
|
|
1214
|
-
'1920s',
|
|
1215
|
-
'1910s',
|
|
1216
|
-
'1900s',
|
|
1217
|
-
'1800s',
|
|
1218
|
-
'1700s',
|
|
1219
|
-
'before_1700'
|
|
1220
|
-
]))
|
|
1472
|
+
.rule(field('when_made').required())
|
|
1473
|
+
.rule(field('when_made').enum(['made_to_order', ...ETSY_WHEN_MADE_VALUES]))
|
|
1221
1474
|
.rule(field('taxonomy_id').required())
|
|
1222
1475
|
.rule(field('taxonomy_id').number({ integer: true, positive: true, message: 'taxonomy_id must be a positive integer' }));
|
|
1223
1476
|
const UpdateListingSchema = new Validator()
|
|
@@ -1354,7 +1607,8 @@ class EtsyClient {
|
|
|
1354
1607
|
this.logger = new DefaultLogger();
|
|
1355
1608
|
this.keystring = config.keystring;
|
|
1356
1609
|
this.sharedSecret = config.sharedSecret;
|
|
1357
|
-
|
|
1610
|
+
this.rateLimitingEnabled = config.rateLimiting?.enabled !== false;
|
|
1611
|
+
if (this.rateLimitingEnabled) {
|
|
1358
1612
|
this.rateLimiter = new EtsyRateLimiter({
|
|
1359
1613
|
maxRequestsPerDay: config.rateLimiting?.maxRequestsPerDay || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
|
|
1360
1614
|
maxRequestsPerSecond: config.rateLimiting?.maxRequestsPerSecond || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
|
|
@@ -1379,11 +1633,39 @@ class EtsyClient {
|
|
|
1379
1633
|
stopOnError: false
|
|
1380
1634
|
});
|
|
1381
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
|
+
}
|
|
1382
1663
|
async makeRequest(endpoint, options = {}, useCache = true) {
|
|
1383
1664
|
const url = `${this.baseUrl}${endpoint}`;
|
|
1384
1665
|
const requestOptions = {
|
|
1385
1666
|
method: 'GET',
|
|
1386
|
-
...options
|
|
1667
|
+
...options,
|
|
1668
|
+
redirect: 'error'
|
|
1387
1669
|
};
|
|
1388
1670
|
const cacheKey = `${url}:${JSON.stringify(requestOptions)}`;
|
|
1389
1671
|
if (useCache && this.cache && requestOptions.method === 'GET') {
|
|
@@ -1397,7 +1679,6 @@ class EtsyClient {
|
|
|
1397
1679
|
}
|
|
1398
1680
|
}
|
|
1399
1681
|
}
|
|
1400
|
-
await this.rateLimiter.waitForRateLimit();
|
|
1401
1682
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
1402
1683
|
const headers = {
|
|
1403
1684
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -1409,22 +1690,38 @@ class EtsyClient {
|
|
|
1409
1690
|
return this.executeWithRetry(url, { ...requestOptions, headers }, cacheKey, useCache);
|
|
1410
1691
|
}
|
|
1411
1692
|
async executeWithRetry(url, options, cacheKey, useCache) {
|
|
1693
|
+
let retryAttempt = 0;
|
|
1412
1694
|
while (true) {
|
|
1413
1695
|
try {
|
|
1414
|
-
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
|
+
}
|
|
1415
1706
|
if (response.status === 429) {
|
|
1416
|
-
const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers);
|
|
1707
|
+
const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers, reservationId, ++retryAttempt);
|
|
1417
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
|
+
}
|
|
1418
1715
|
this.logger.warn(`Rate limited. Retrying in ${delayMs}ms...`);
|
|
1419
1716
|
await this.sleep(delayMs);
|
|
1420
1717
|
continue;
|
|
1421
1718
|
}
|
|
1422
1719
|
}
|
|
1720
|
+
this.rateLimiter.updateFromHeaders(response.headers, reservationId);
|
|
1423
1721
|
if (!response.ok) {
|
|
1424
1722
|
const errorText = await response.text();
|
|
1425
1723
|
throw new EtsyApiError(`Etsy API error: ${response.status} ${response.statusText}`, response.status, errorText);
|
|
1426
1724
|
}
|
|
1427
|
-
this.rateLimiter.updateFromHeaders(response.headers);
|
|
1428
1725
|
this.rateLimiter.resetRetryCount();
|
|
1429
1726
|
if (response.status === 204) {
|
|
1430
1727
|
return undefined;
|
|
@@ -1727,9 +2024,87 @@ class EtsyClient {
|
|
|
1727
2024
|
async updateListingInventory(listingId, params, options) {
|
|
1728
2025
|
const legacy = options?.legacy;
|
|
1729
2026
|
const query = legacy !== undefined ? `?legacy=${legacy}` : '';
|
|
2027
|
+
if (!Array.isArray(params.products) || params.products.length === 0) {
|
|
2028
|
+
throw new TypeError('Inventory update must contain at least one product.');
|
|
2029
|
+
}
|
|
2030
|
+
const body = {
|
|
2031
|
+
products: params.products.map((product, productIndex) => {
|
|
2032
|
+
if (product.is_deleted === true) {
|
|
2033
|
+
throw new TypeError(`Inventory product ${productIndex} is deleted and cannot be submitted for replacement.`);
|
|
2034
|
+
}
|
|
2035
|
+
if (!Array.isArray(product.offerings) || product.offerings.length === 0) {
|
|
2036
|
+
throw new TypeError(`Inventory product ${productIndex} must contain at least one offering.`);
|
|
2037
|
+
}
|
|
2038
|
+
if (!Array.isArray(product.property_values)) {
|
|
2039
|
+
throw new TypeError(`Inventory product ${productIndex} must include property_values; pass [] when it has no variation properties.`);
|
|
2040
|
+
}
|
|
2041
|
+
if (product.sku !== undefined && product.sku !== null && typeof product.sku !== 'string') {
|
|
2042
|
+
throw new TypeError(`Inventory product ${productIndex} sku must be a string or null.`);
|
|
2043
|
+
}
|
|
2044
|
+
return {
|
|
2045
|
+
...(product.sku !== undefined ? { sku: product.sku } : {}),
|
|
2046
|
+
property_values: product.property_values.map((value, valueIndex) => {
|
|
2047
|
+
if (!Number.isInteger(value.property_id) || value.property_id < 1) {
|
|
2048
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_id must be a positive integer.`);
|
|
2049
|
+
}
|
|
2050
|
+
if (!Array.isArray(value.value_ids) || value.value_ids.some((id) => !Number.isInteger(id))) {
|
|
2051
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].value_ids must be an integer array.`);
|
|
2052
|
+
}
|
|
2053
|
+
if (!Array.isArray(value.values) || value.values.some((entry) => typeof entry !== 'string')) {
|
|
2054
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].values must be a string array.`);
|
|
2055
|
+
}
|
|
2056
|
+
if (value.property_name !== undefined && typeof value.property_name !== 'string') {
|
|
2057
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_name must be a string.`);
|
|
2058
|
+
}
|
|
2059
|
+
if (value.scale_id !== undefined && value.scale_id !== null &&
|
|
2060
|
+
(!Number.isInteger(value.scale_id) || value.scale_id < 1)) {
|
|
2061
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].scale_id must be a positive integer or null.`);
|
|
2062
|
+
}
|
|
2063
|
+
return {
|
|
2064
|
+
property_id: value.property_id,
|
|
2065
|
+
...(value.property_name !== undefined ? { property_name: value.property_name } : {}),
|
|
2066
|
+
...(value.scale_id !== undefined ? { scale_id: value.scale_id } : {}),
|
|
2067
|
+
value_ids: value.value_ids,
|
|
2068
|
+
values: value.values,
|
|
2069
|
+
};
|
|
2070
|
+
}),
|
|
2071
|
+
offerings: product.offerings.map((offering, offeringIndex) => {
|
|
2072
|
+
if (offering.is_deleted === true) {
|
|
2073
|
+
throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is deleted and cannot be submitted for replacement.`);
|
|
2074
|
+
}
|
|
2075
|
+
if (typeof offering.price !== 'number' || !Number.isFinite(offering.price)) {
|
|
2076
|
+
throw new TypeError('Inventory offering price must be a finite decimal number in the listing currency.');
|
|
2077
|
+
}
|
|
2078
|
+
if (!Number.isInteger(offering.quantity)) {
|
|
2079
|
+
throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} quantity must be an integer.`);
|
|
2080
|
+
}
|
|
2081
|
+
if (typeof offering.is_enabled !== 'boolean') {
|
|
2082
|
+
throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is_enabled must be a boolean.`);
|
|
2083
|
+
}
|
|
2084
|
+
if (offering.readiness_state_id === undefined ||
|
|
2085
|
+
(offering.readiness_state_id !== null &&
|
|
2086
|
+
(!Number.isInteger(offering.readiness_state_id) || offering.readiness_state_id < 1))) {
|
|
2087
|
+
throw new TypeError('Inventory offering readiness_state_id is required; use null when not set.');
|
|
2088
|
+
}
|
|
2089
|
+
return {
|
|
2090
|
+
price: offering.price,
|
|
2091
|
+
quantity: offering.quantity,
|
|
2092
|
+
is_enabled: offering.is_enabled,
|
|
2093
|
+
readiness_state_id: offering.readiness_state_id,
|
|
2094
|
+
};
|
|
2095
|
+
}),
|
|
2096
|
+
};
|
|
2097
|
+
}),
|
|
2098
|
+
...(params.price_on_property !== undefined ? { price_on_property: params.price_on_property } : {}),
|
|
2099
|
+
...(params.quantity_on_property !== undefined ? { quantity_on_property: params.quantity_on_property } : {}),
|
|
2100
|
+
...(params.sku_on_property !== undefined ? { sku_on_property: params.sku_on_property } : {}),
|
|
2101
|
+
...(params.readiness_state_on_property !== undefined
|
|
2102
|
+
? { readiness_state_on_property: params.readiness_state_on_property }
|
|
2103
|
+
: {}),
|
|
2104
|
+
};
|
|
1730
2105
|
return this.makeRequest(`/listings/${listingId}/inventory${query}`, {
|
|
1731
2106
|
method: 'PUT',
|
|
1732
|
-
body: JSON.stringify(
|
|
2107
|
+
body: JSON.stringify(body)
|
|
1733
2108
|
}, false);
|
|
1734
2109
|
}
|
|
1735
2110
|
async uploadListingImage(shopId, listingId, imageData, params) {
|
|
@@ -1744,9 +2119,8 @@ class EtsyClient {
|
|
|
1744
2119
|
if (params?.alt_text)
|
|
1745
2120
|
formData.append('alt_text', params.alt_text);
|
|
1746
2121
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/images`;
|
|
1747
|
-
await this.rateLimiter.waitForRateLimit();
|
|
1748
2122
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
1749
|
-
const response = await this.
|
|
2123
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
1750
2124
|
method: 'POST',
|
|
1751
2125
|
headers: {
|
|
1752
2126
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -1941,10 +2315,11 @@ class EtsyClient {
|
|
|
1941
2315
|
}
|
|
1942
2316
|
async getShopPayment(shopId, paymentId) {
|
|
1943
2317
|
const payments = await this.getPayments(shopId, [Number(paymentId)]);
|
|
1944
|
-
|
|
2318
|
+
const payment = payments[0];
|
|
2319
|
+
if (!payment) {
|
|
1945
2320
|
throw new EtsyApiError('Payment not found', 404);
|
|
1946
2321
|
}
|
|
1947
|
-
return
|
|
2322
|
+
return payment;
|
|
1948
2323
|
}
|
|
1949
2324
|
async getBuyerTaxonomyNodes() {
|
|
1950
2325
|
const response = await this.makeRequest('/buyer-taxonomy/nodes');
|
|
@@ -1967,9 +2342,8 @@ class EtsyClient {
|
|
|
1967
2342
|
body.append('scale_id', scaleId.toString());
|
|
1968
2343
|
}
|
|
1969
2344
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/properties/${propertyId}`;
|
|
1970
|
-
await this.rateLimiter.waitForRateLimit();
|
|
1971
2345
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
1972
|
-
const response = await this.
|
|
2346
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
1973
2347
|
method: 'PUT',
|
|
1974
2348
|
headers: {
|
|
1975
2349
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -1978,8 +2352,6 @@ class EtsyClient {
|
|
|
1978
2352
|
},
|
|
1979
2353
|
body: body.toString()
|
|
1980
2354
|
});
|
|
1981
|
-
this.rateLimiter.updateFromHeaders(response.headers);
|
|
1982
|
-
this.rateLimiter.resetRetryCount();
|
|
1983
2355
|
if (!response.ok) {
|
|
1984
2356
|
const errorText = await response.text();
|
|
1985
2357
|
throw new EtsyApiError(`Failed to update listing property: ${response.status} ${response.statusText}`, response.status, errorText);
|
|
@@ -2079,9 +2451,8 @@ class EtsyClient {
|
|
|
2079
2451
|
if (params?.listing_file_id !== undefined)
|
|
2080
2452
|
formData.append('listing_file_id', params.listing_file_id.toString());
|
|
2081
2453
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/files`;
|
|
2082
|
-
await this.rateLimiter.waitForRateLimit();
|
|
2083
2454
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
2084
|
-
const response = await this.
|
|
2455
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
2085
2456
|
method: 'POST',
|
|
2086
2457
|
headers: {
|
|
2087
2458
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -2113,9 +2484,8 @@ class EtsyClient {
|
|
|
2113
2484
|
if (params?.video_id !== undefined)
|
|
2114
2485
|
formData.append('video_id', params.video_id.toString());
|
|
2115
2486
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/videos`;
|
|
2116
|
-
await this.rateLimiter.waitForRateLimit();
|
|
2117
2487
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
2118
|
-
const response = await this.
|
|
2488
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
2119
2489
|
method: 'POST',
|
|
2120
2490
|
headers: {
|
|
2121
2491
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -2537,6 +2907,7 @@ class AuthHelper {
|
|
|
2537
2907
|
assertFetchSupport();
|
|
2538
2908
|
const response = await fetch('https://api.etsy.com/v3/public/oauth/token', {
|
|
2539
2909
|
method: 'POST',
|
|
2910
|
+
redirect: 'error',
|
|
2540
2911
|
headers: {
|
|
2541
2912
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
2542
2913
|
'Accept': 'application/json'
|
|
@@ -2629,18 +3000,17 @@ const COMMON_SCOPE_COMBINATIONS = {
|
|
|
2629
3000
|
]
|
|
2630
3001
|
};
|
|
2631
3002
|
|
|
3003
|
+
const QPD_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
2632
3004
|
class GlobalRequestQueue {
|
|
2633
3005
|
constructor() {
|
|
2634
3006
|
this.queue = [];
|
|
2635
3007
|
this.processing = false;
|
|
2636
3008
|
this.rateLimits = new Map();
|
|
2637
|
-
this.
|
|
2638
|
-
this.dailyReset = new Date();
|
|
3009
|
+
this.requestTimestamps = [];
|
|
2639
3010
|
this.lastRequestTime = 0;
|
|
2640
3011
|
this.maxRequestsPerDay = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY;
|
|
2641
3012
|
this.maxRequestsPerSecond = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND;
|
|
2642
3013
|
this.minRequestInterval = ETSY_RATE_LIMITS.MIN_REQUEST_INTERVAL;
|
|
2643
|
-
this.setNextDailyReset();
|
|
2644
3014
|
}
|
|
2645
3015
|
static getInstance() {
|
|
2646
3016
|
if (!this.instance) {
|
|
@@ -2672,11 +3042,13 @@ class GlobalRequestQueue {
|
|
|
2672
3042
|
});
|
|
2673
3043
|
}
|
|
2674
3044
|
getStatus() {
|
|
3045
|
+
const now = Date.now();
|
|
3046
|
+
this.pruneRequestTimestamps(now);
|
|
2675
3047
|
return {
|
|
2676
3048
|
queueLength: this.queue.length,
|
|
2677
3049
|
processing: this.processing,
|
|
2678
|
-
remainingRequests: Math.max(0, this.maxRequestsPerDay - this.
|
|
2679
|
-
resetTime: this.
|
|
3050
|
+
remainingRequests: Math.max(0, this.maxRequestsPerDay - this.requestTimestamps.length),
|
|
3051
|
+
resetTime: this.getNextRequestExpiry(now),
|
|
2680
3052
|
};
|
|
2681
3053
|
}
|
|
2682
3054
|
clear() {
|
|
@@ -2692,16 +3064,13 @@ class GlobalRequestQueue {
|
|
|
2692
3064
|
this.processing = true;
|
|
2693
3065
|
try {
|
|
2694
3066
|
while (this.queue.length > 0) {
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
const timeUntilReset = this.dailyReset.getTime() - Date.now();
|
|
2701
|
-
console.warn(`Daily rate limit reached. Waiting ${Math.ceil(timeUntilReset / 1000 / 60)} minutes until reset.`);
|
|
3067
|
+
const now = Date.now();
|
|
3068
|
+
this.pruneRequestTimestamps(now);
|
|
3069
|
+
if (this.requestTimestamps.length >= this.maxRequestsPerDay) {
|
|
3070
|
+
const timeUntilReset = Math.max(0, this.getNextRequestExpiry(now).getTime() - now);
|
|
3071
|
+
console.warn(`Daily rate limit reached. Waiting ${Math.ceil(timeUntilReset / 1000 / 60)} minutes for the oldest request to leave the rolling 24-hour window.`);
|
|
2702
3072
|
await this.delay(timeUntilReset);
|
|
2703
|
-
|
|
2704
|
-
this.setNextDailyReset();
|
|
3073
|
+
continue;
|
|
2705
3074
|
}
|
|
2706
3075
|
await this.waitForRateLimit();
|
|
2707
3076
|
this.queue.sort((a, b) => {
|
|
@@ -2736,7 +3105,7 @@ class GlobalRequestQueue {
|
|
|
2736
3105
|
}
|
|
2737
3106
|
});
|
|
2738
3107
|
try {
|
|
2739
|
-
result = await Promise.race([item.request
|
|
3108
|
+
result = await Promise.race([this.startRequest(item.request), timeoutPromise]);
|
|
2740
3109
|
}
|
|
2741
3110
|
finally {
|
|
2742
3111
|
if (timeoutId) {
|
|
@@ -2745,11 +3114,9 @@ class GlobalRequestQueue {
|
|
|
2745
3114
|
}
|
|
2746
3115
|
}
|
|
2747
3116
|
else {
|
|
2748
|
-
result = await item.request
|
|
3117
|
+
result = await this.startRequest(item.request);
|
|
2749
3118
|
}
|
|
2750
3119
|
item.resolve(result);
|
|
2751
|
-
this.requestCount++;
|
|
2752
|
-
this.lastRequestTime = Date.now();
|
|
2753
3120
|
}
|
|
2754
3121
|
catch (error) {
|
|
2755
3122
|
if (error instanceof EtsyRateLimitError) {
|
|
@@ -2769,7 +3136,7 @@ class GlobalRequestQueue {
|
|
|
2769
3136
|
const globalRateLimit = this.rateLimits.get('global');
|
|
2770
3137
|
if (globalRateLimit && now < globalRateLimit.resetAt) {
|
|
2771
3138
|
const waitTime = globalRateLimit.resetAt - now;
|
|
2772
|
-
console.
|
|
3139
|
+
console.warn(`Global rate limit active. Waiting ${waitTime}ms`);
|
|
2773
3140
|
await this.delay(waitTime);
|
|
2774
3141
|
}
|
|
2775
3142
|
const timeSinceLastRequest = now - this.lastRequestTime;
|
|
@@ -2787,11 +3154,23 @@ class GlobalRequestQueue {
|
|
|
2787
3154
|
});
|
|
2788
3155
|
}
|
|
2789
3156
|
}
|
|
2790
|
-
|
|
2791
|
-
const
|
|
2792
|
-
this.
|
|
2793
|
-
|
|
2794
|
-
|
|
3157
|
+
pruneRequestTimestamps(now) {
|
|
3158
|
+
const oldestAllowed = now - QPD_WINDOW_MS;
|
|
3159
|
+
while (this.requestTimestamps[0] !== undefined && this.requestTimestamps[0] <= oldestAllowed) {
|
|
3160
|
+
this.requestTimestamps.shift();
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
getNextRequestExpiry(now) {
|
|
3164
|
+
this.pruneRequestTimestamps(now);
|
|
3165
|
+
const oldestRequest = this.requestTimestamps[0] ?? now;
|
|
3166
|
+
return new Date(oldestRequest + QPD_WINDOW_MS);
|
|
3167
|
+
}
|
|
3168
|
+
startRequest(request) {
|
|
3169
|
+
const startedAt = Date.now();
|
|
3170
|
+
this.pruneRequestTimestamps(startedAt);
|
|
3171
|
+
this.requestTimestamps.push(startedAt);
|
|
3172
|
+
this.lastRequestTime = startedAt;
|
|
3173
|
+
return request();
|
|
2795
3174
|
}
|
|
2796
3175
|
generateId() {
|
|
2797
3176
|
return `${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
@@ -4302,7 +4681,7 @@ function createTokenManager(config, storage) {
|
|
|
4302
4681
|
function createRateLimiter(config) {
|
|
4303
4682
|
return new EtsyRateLimiter(config);
|
|
4304
4683
|
}
|
|
4305
|
-
const VERSION = '
|
|
4684
|
+
const VERSION = '3.0.1';
|
|
4306
4685
|
const LIBRARY_NAME = 'etsy-v3-api-client';
|
|
4307
4686
|
function getLibraryInfo() {
|
|
4308
4687
|
return {
|