@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.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$1 = typeof process !== 'undefined' && !!process.versions?.node;
|
|
@@ -319,9 +339,12 @@ class MemoryTokenStorage {
|
|
|
319
339
|
class TokenManager {
|
|
320
340
|
constructor(config, storage, rotationConfig) {
|
|
321
341
|
this.currentTokens = null;
|
|
342
|
+
this.tokenMutationGeneration = 0;
|
|
322
343
|
this.keystring = config.keystring;
|
|
323
|
-
this.refreshCallback = config.refreshSave;
|
|
344
|
+
this.refreshCallback = config.refreshSaveAsync ?? config.refreshSave;
|
|
345
|
+
this.refreshClearRequired = config.refreshSaveAsync !== undefined;
|
|
324
346
|
this.storage = storage;
|
|
347
|
+
this.clearCallback = config.refreshClearAsync;
|
|
325
348
|
this.rotationConfig = rotationConfig;
|
|
326
349
|
this.currentTokens = {
|
|
327
350
|
access_token: config.accessToken,
|
|
@@ -358,21 +381,82 @@ class TokenManager {
|
|
|
358
381
|
if (!this.currentTokens) {
|
|
359
382
|
throw new EtsyAuthError('No tokens available to refresh', 'NO_REFRESH_TOKEN');
|
|
360
383
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
this.
|
|
365
|
-
|
|
366
|
-
|
|
384
|
+
const generation = this.tokenMutationGeneration;
|
|
385
|
+
const refreshPromise = (this.pendingTokens
|
|
386
|
+
? this.retryPendingTokenPersistence()
|
|
387
|
+
: this.performTokenRefresh().then(async (newTokens) => {
|
|
388
|
+
this.throwIfSuperseded(generation);
|
|
389
|
+
this.pendingTokens = newTokens;
|
|
390
|
+
return this.persistRefreshedTokens(newTokens, generation);
|
|
391
|
+
})).finally(() => {
|
|
392
|
+
this.refreshPromise = undefined;
|
|
393
|
+
});
|
|
394
|
+
this.refreshPromise = refreshPromise;
|
|
395
|
+
return refreshPromise;
|
|
396
|
+
}
|
|
397
|
+
throwIfSuperseded(generation) {
|
|
398
|
+
if (generation !== this.tokenMutationGeneration) {
|
|
399
|
+
throw new EtsyAuthError('Token refresh was superseded by a token update or clear', 'TOKEN_REFRESH_SUPERSEDED');
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
async repairPersistenceToCurrentTokens() {
|
|
403
|
+
while (true) {
|
|
404
|
+
const generation = this.tokenMutationGeneration;
|
|
405
|
+
const current = this.currentTokens ? { ...this.currentTokens } : null;
|
|
406
|
+
try {
|
|
407
|
+
if (current) {
|
|
408
|
+
if (this.storage)
|
|
409
|
+
await this.storage.save(current);
|
|
410
|
+
if (this.refreshCallback) {
|
|
411
|
+
await this.refreshCallback(current.access_token, current.refresh_token, current.expires_at);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
if (this.storage)
|
|
416
|
+
await this.storage.clear();
|
|
417
|
+
if (this.clearCallback)
|
|
418
|
+
await this.clearCallback();
|
|
419
|
+
else if (this.refreshClearRequired) {
|
|
420
|
+
throw new EtsyAuthError('A refresh persistence callback is configured without refreshClearAsync; durable tokens could not be cleared safely', 'TOKEN_CLEAR_CALLBACK_REQUIRED');
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
if (generation !== this.tokenMutationGeneration)
|
|
426
|
+
continue;
|
|
427
|
+
throw error;
|
|
367
428
|
}
|
|
368
|
-
if (this.
|
|
369
|
-
|
|
429
|
+
if (generation === this.tokenMutationGeneration)
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
async persistRefreshedTokens(newTokens, generation) {
|
|
434
|
+
this.throwIfSuperseded(generation);
|
|
435
|
+
if (this.storage) {
|
|
436
|
+
await this.storage.save(newTokens);
|
|
437
|
+
if (generation !== this.tokenMutationGeneration) {
|
|
438
|
+
await this.repairPersistenceToCurrentTokens();
|
|
439
|
+
this.throwIfSuperseded(generation);
|
|
370
440
|
}
|
|
371
|
-
return newTokens;
|
|
372
441
|
}
|
|
373
|
-
|
|
374
|
-
|
|
442
|
+
this.throwIfSuperseded(generation);
|
|
443
|
+
if (this.refreshCallback) {
|
|
444
|
+
await this.refreshCallback(newTokens.access_token, newTokens.refresh_token, newTokens.expires_at);
|
|
445
|
+
if (generation !== this.tokenMutationGeneration) {
|
|
446
|
+
await this.repairPersistenceToCurrentTokens();
|
|
447
|
+
this.throwIfSuperseded(generation);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
this.throwIfSuperseded(generation);
|
|
451
|
+
this.currentTokens = newTokens;
|
|
452
|
+
this.pendingTokens = undefined;
|
|
453
|
+
return newTokens;
|
|
454
|
+
}
|
|
455
|
+
async retryPendingTokenPersistence() {
|
|
456
|
+
if (!this.pendingTokens) {
|
|
457
|
+
throw new EtsyAuthError('No rotated tokens are pending persistence', 'NO_PENDING_TOKENS');
|
|
375
458
|
}
|
|
459
|
+
return this.persistRefreshedTokens(this.pendingTokens, this.tokenMutationGeneration);
|
|
376
460
|
}
|
|
377
461
|
async performTokenRefresh() {
|
|
378
462
|
if (!this.currentTokens) {
|
|
@@ -386,6 +470,7 @@ class TokenManager {
|
|
|
386
470
|
try {
|
|
387
471
|
const response = await this.fetch('https://api.etsy.com/v3/public/oauth/token', {
|
|
388
472
|
method: 'POST',
|
|
473
|
+
redirect: 'error',
|
|
389
474
|
headers: {
|
|
390
475
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
391
476
|
'Accept': 'application/json'
|
|
@@ -415,7 +500,9 @@ class TokenManager {
|
|
|
415
500
|
return this.currentTokens ? { ...this.currentTokens } : null;
|
|
416
501
|
}
|
|
417
502
|
updateTokens(tokens) {
|
|
503
|
+
this.tokenMutationGeneration += 1;
|
|
418
504
|
this.currentTokens = { ...tokens };
|
|
505
|
+
this.pendingTokens = undefined;
|
|
419
506
|
}
|
|
420
507
|
isTokenExpired() {
|
|
421
508
|
if (!this.currentTokens) {
|
|
@@ -435,10 +522,19 @@ class TokenManager {
|
|
|
435
522
|
return now.getTime() >= (expiresAt.getTime() - bufferTime);
|
|
436
523
|
}
|
|
437
524
|
async clearTokens() {
|
|
525
|
+
this.tokenMutationGeneration += 1;
|
|
438
526
|
this.currentTokens = null;
|
|
527
|
+
this.pendingTokens = undefined;
|
|
528
|
+
if (this.refreshPromise)
|
|
529
|
+
await this.refreshPromise.catch(() => undefined);
|
|
439
530
|
if (this.storage) {
|
|
440
531
|
await this.storage.clear();
|
|
441
532
|
}
|
|
533
|
+
if (this.clearCallback)
|
|
534
|
+
await this.clearCallback();
|
|
535
|
+
else if (this.refreshClearRequired) {
|
|
536
|
+
throw new EtsyAuthError('A refresh persistence callback is configured without refreshClearAsync; durable tokens could not be cleared safely', 'TOKEN_CLEAR_CALLBACK_REQUIRED');
|
|
537
|
+
}
|
|
442
538
|
}
|
|
443
539
|
getTimeUntilExpiration() {
|
|
444
540
|
if (!this.currentTokens) {
|
|
@@ -678,13 +774,24 @@ const ETSY_RATE_LIMITS = {
|
|
|
678
774
|
MAX_REQUESTS_PER_SECOND: 5,
|
|
679
775
|
MIN_REQUEST_INTERVAL: 200,
|
|
680
776
|
};
|
|
777
|
+
const QPD_WINDOW_MS$1 = 24 * 60 * 60 * 1000;
|
|
681
778
|
class EtsyRateLimiter {
|
|
682
779
|
constructor(config) {
|
|
683
|
-
this.
|
|
684
|
-
this.
|
|
780
|
+
this.requestTimestamps = [];
|
|
781
|
+
this.uncertainRequestTimestamps = [];
|
|
685
782
|
this.lastRequestTime = 0;
|
|
686
783
|
this.isHeaderBasedLimiting = false;
|
|
784
|
+
this.headerQuotaAvailableAt = 0;
|
|
785
|
+
this.retryNotBefore = 0;
|
|
786
|
+
this.quotaProbeBackoffMs = 60000;
|
|
787
|
+
this.nextReservationId = 0;
|
|
788
|
+
this.latestHeaderReservationId = 0;
|
|
789
|
+
this.inFlightReservations = new Map();
|
|
790
|
+
this.quotaProbeInFlight = false;
|
|
791
|
+
this.stateWaiters = new Set();
|
|
687
792
|
this.currentRetryCount = 0;
|
|
793
|
+
this.reservationQueue = Promise.resolve();
|
|
794
|
+
const overrides = Object.fromEntries(Object.entries(config ?? {}).filter(([, value]) => value !== undefined));
|
|
688
795
|
this.config = {
|
|
689
796
|
maxRequestsPerDay: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
|
|
690
797
|
maxRequestsPerSecond: ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
|
|
@@ -695,21 +802,155 @@ class EtsyRateLimiter {
|
|
|
695
802
|
jitter: 0.1,
|
|
696
803
|
qpdWarningThreshold: 80,
|
|
697
804
|
onApproachingLimit: undefined,
|
|
698
|
-
...
|
|
805
|
+
...overrides
|
|
699
806
|
};
|
|
700
|
-
this.setNextDailyReset();
|
|
701
807
|
}
|
|
702
|
-
|
|
703
|
-
const
|
|
704
|
-
this.
|
|
705
|
-
|
|
706
|
-
|
|
808
|
+
pruneRequestTimestamps(now) {
|
|
809
|
+
const oldestAllowed = now - QPD_WINDOW_MS$1;
|
|
810
|
+
while (this.requestTimestamps[0] !== undefined && this.requestTimestamps[0] <= oldestAllowed) {
|
|
811
|
+
this.requestTimestamps.shift();
|
|
812
|
+
}
|
|
813
|
+
while (this.uncertainRequestTimestamps[0] !== undefined && this.uncertainRequestTimestamps[0] <= oldestAllowed) {
|
|
814
|
+
this.uncertainRequestTimestamps.shift();
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
getLocalResetTime(now) {
|
|
818
|
+
this.pruneRequestTimestamps(now);
|
|
819
|
+
const oldestRequest = this.requestTimestamps[0] ?? now;
|
|
820
|
+
return new Date(oldestRequest + QPD_WINDOW_MS$1);
|
|
821
|
+
}
|
|
822
|
+
getLocalRemainingRequests(now) {
|
|
823
|
+
this.pruneRequestTimestamps(now);
|
|
824
|
+
const localLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
|
|
825
|
+
return Math.max(0, localLimit - this.requestTimestamps.length);
|
|
826
|
+
}
|
|
827
|
+
getEffectiveRemainingRequests(now) {
|
|
828
|
+
this.pruneRequestTimestamps(now);
|
|
829
|
+
if (this.headerRemainingToday !== undefined) {
|
|
830
|
+
return Math.max(0, this.headerRemainingToday - this.inFlightReservations.size - this.uncertainRequestTimestamps.length);
|
|
831
|
+
}
|
|
832
|
+
return this.getLocalRemainingRequests(now);
|
|
833
|
+
}
|
|
834
|
+
isQuotaProbeReady(now) {
|
|
835
|
+
const exhausted = this.headerRemainingToday === 0;
|
|
836
|
+
const uncertainBudgetExhausted = this.inFlightReservations.size === 0 &&
|
|
837
|
+
this.uncertainRequestTimestamps.length > 0 &&
|
|
838
|
+
this.getEffectiveRemainingRequests(now) === 0;
|
|
839
|
+
return (exhausted || uncertainBudgetExhausted) &&
|
|
840
|
+
!this.quotaProbeInFlight &&
|
|
841
|
+
this.inFlightReservations.size === 0 &&
|
|
842
|
+
this.headerQuotaAvailableAt <= now;
|
|
843
|
+
}
|
|
844
|
+
refreshExpiredHeaderQuota(now) {
|
|
845
|
+
if (this.headerRemainingToday === 0 && this.headerQuotaAvailableAt > 0 && now >= this.headerQuotaAvailableAt) {
|
|
846
|
+
this.headerQuotaAvailableAt = 0;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
notifyStateWaiters() {
|
|
850
|
+
for (const resolve of this.stateWaiters)
|
|
851
|
+
resolve();
|
|
852
|
+
this.stateWaiters.clear();
|
|
853
|
+
}
|
|
854
|
+
scheduleQuotaProbe() {
|
|
855
|
+
this.headerQuotaAvailableAt = Date.now() + this.quotaProbeBackoffMs;
|
|
856
|
+
this.quotaProbeBackoffMs = Math.min(this.quotaProbeBackoffMs * 2, 60 * 60 * 1000);
|
|
857
|
+
}
|
|
858
|
+
waitForStateChange() {
|
|
859
|
+
return new Promise((resolve) => this.stateWaiters.add(resolve));
|
|
860
|
+
}
|
|
861
|
+
finishReservation(reservationId, receivedQuotaHeader) {
|
|
862
|
+
const wasQuotaProbe = this.inFlightReservations.get(reservationId);
|
|
863
|
+
if (wasQuotaProbe === undefined)
|
|
864
|
+
return;
|
|
865
|
+
this.inFlightReservations.delete(reservationId);
|
|
866
|
+
if (wasQuotaProbe.isQuotaProbe) {
|
|
867
|
+
this.quotaProbeInFlight = false;
|
|
868
|
+
if (this.headerRemainingToday === 0 && !receivedQuotaHeader) {
|
|
869
|
+
this.scheduleQuotaProbe();
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
this.notifyStateWaiters();
|
|
873
|
+
}
|
|
874
|
+
async acquireRequestSlot() {
|
|
875
|
+
const previousReservation = this.reservationQueue;
|
|
876
|
+
let releaseReservation = () => undefined;
|
|
877
|
+
this.reservationQueue = new Promise((resolve) => {
|
|
878
|
+
releaseReservation = resolve;
|
|
879
|
+
});
|
|
880
|
+
await previousReservation;
|
|
881
|
+
try {
|
|
882
|
+
while (true) {
|
|
883
|
+
const now = Date.now();
|
|
884
|
+
this.pruneRequestTimestamps(now);
|
|
885
|
+
this.refreshExpiredHeaderQuota(now);
|
|
886
|
+
let isQuotaProbe = false;
|
|
887
|
+
if (this.headerRemainingToday === 0 || this.isQuotaProbeReady(now)) {
|
|
888
|
+
if (this.quotaProbeInFlight || this.inFlightReservations.size > 0) {
|
|
889
|
+
await this.waitForStateChange();
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
const probeWait = this.headerQuotaAvailableAt - now;
|
|
893
|
+
if (probeWait > 0) {
|
|
894
|
+
await new Promise(resolve => setTimeout(resolve, probeWait));
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
isQuotaProbe = true;
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
const effectiveRemaining = this.getEffectiveRemainingRequests(now);
|
|
901
|
+
if (effectiveRemaining <= 0) {
|
|
902
|
+
if (this.inFlightReservations.size > 0 && this.headerRemainingToday !== undefined) {
|
|
903
|
+
await this.waitForStateChange();
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
const effectiveDailyLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
|
|
907
|
+
const localResetTime = this.getLocalResetTime(now);
|
|
908
|
+
const timeUntilReset = Math.max(0, localResetTime.getTime() - now);
|
|
909
|
+
throw new EtsyRateLimitError(`Daily rate limit exhausted (${effectiveDailyLimit} requests in the local rolling 24-hour window). ` +
|
|
910
|
+
`The oldest local request expires in approximately ${Math.ceil(timeUntilReset / 1000 / 60)} minutes.`, Math.ceil(timeUntilReset / 1000), 'qpd_exhausted');
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
const intervalWait = Math.max(0, this.getEffectiveMinInterval() - (now - this.lastRequestTime));
|
|
914
|
+
const cooldownWait = Math.max(0, this.retryNotBefore - now);
|
|
915
|
+
const waitTime = Math.max(intervalWait, cooldownWait);
|
|
916
|
+
if (waitTime > 0) {
|
|
917
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
const requestStartedAt = Date.now();
|
|
921
|
+
const reservationId = ++this.nextReservationId;
|
|
922
|
+
this.requestTimestamps.push(requestStartedAt);
|
|
923
|
+
this.lastRequestTime = requestStartedAt;
|
|
924
|
+
this.inFlightReservations.set(reservationId, { isQuotaProbe, startedAt: requestStartedAt });
|
|
925
|
+
if (isQuotaProbe)
|
|
926
|
+
this.quotaProbeInFlight = true;
|
|
927
|
+
return reservationId;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
finally {
|
|
931
|
+
releaseReservation();
|
|
932
|
+
}
|
|
707
933
|
}
|
|
708
|
-
|
|
934
|
+
releaseRequestSlot(reservationId) {
|
|
935
|
+
const reservation = this.inFlightReservations.get(reservationId);
|
|
936
|
+
if (!reservation)
|
|
937
|
+
return;
|
|
938
|
+
this.uncertainRequestTimestamps.push(reservation.startedAt);
|
|
939
|
+
this.finishReservation(reservationId, false);
|
|
940
|
+
}
|
|
941
|
+
updateFromHeaders(headers, reservationId) {
|
|
942
|
+
const matchedReservationId = reservationId;
|
|
943
|
+
const isQuotaProbe = matchedReservationId !== undefined &&
|
|
944
|
+
this.inFlightReservations.get(matchedReservationId)?.isQuotaProbe === true;
|
|
709
945
|
if (!headers) {
|
|
946
|
+
if (matchedReservationId !== undefined)
|
|
947
|
+
this.finishReservation(matchedReservationId, false);
|
|
710
948
|
return;
|
|
711
949
|
}
|
|
712
950
|
const parsed = this.parseRateLimitHeaders(headers);
|
|
951
|
+
if (matchedReservationId !== undefined) {
|
|
952
|
+
this.finishReservation(matchedReservationId, parsed.remainingToday !== undefined);
|
|
953
|
+
}
|
|
713
954
|
if (parsed.limitPerSecond !== undefined) {
|
|
714
955
|
this.headerLimitPerSecond = parsed.limitPerSecond;
|
|
715
956
|
this.isHeaderBasedLimiting = true;
|
|
@@ -722,9 +963,39 @@ class EtsyRateLimiter {
|
|
|
722
963
|
this.isHeaderBasedLimiting = true;
|
|
723
964
|
}
|
|
724
965
|
if (parsed.remainingToday !== undefined) {
|
|
725
|
-
|
|
966
|
+
const observationId = matchedReservationId ?? this.nextReservationId;
|
|
967
|
+
if (isQuotaProbe && parsed.remainingToday > 0) {
|
|
968
|
+
this.uncertainRequestTimestamps = [];
|
|
969
|
+
}
|
|
970
|
+
const exhaustedByEarlierObservation = this.headerRemainingToday === 0 &&
|
|
971
|
+
parsed.remainingToday > 0 && matchedReservationId !== undefined && !isQuotaProbe;
|
|
972
|
+
const exhaustedByUncorrelatedObservation = this.headerRemainingToday === 0 &&
|
|
973
|
+
parsed.remainingToday > 0 && matchedReservationId === undefined;
|
|
974
|
+
if (exhaustedByEarlierObservation || exhaustedByUncorrelatedObservation) ;
|
|
975
|
+
else if (isQuotaProbe || this.headerRemainingToday === undefined) {
|
|
976
|
+
this.headerRemainingToday = parsed.remainingToday;
|
|
977
|
+
this.latestHeaderReservationId = Math.max(this.latestHeaderReservationId, observationId);
|
|
978
|
+
}
|
|
979
|
+
else if (parsed.remainingToday < this.headerRemainingToday) {
|
|
980
|
+
this.headerRemainingToday = parsed.remainingToday;
|
|
981
|
+
this.latestHeaderReservationId = Math.max(this.latestHeaderReservationId, observationId);
|
|
982
|
+
}
|
|
983
|
+
if (this.headerRemainingToday !== undefined && this.headerRemainingToday > 0) {
|
|
984
|
+
this.headerQuotaAvailableAt = 0;
|
|
985
|
+
this.quotaProbeBackoffMs = 60000;
|
|
986
|
+
}
|
|
987
|
+
else if (this.headerQuotaAvailableAt === 0) {
|
|
988
|
+
this.scheduleQuotaProbe();
|
|
989
|
+
}
|
|
726
990
|
this.checkApproachingLimit();
|
|
727
991
|
}
|
|
992
|
+
else if (matchedReservationId !== undefined && this.headerRemainingToday !== undefined) {
|
|
993
|
+
this.headerRemainingToday = Math.max(0, this.headerRemainingToday - 1);
|
|
994
|
+
if (this.headerRemainingToday === 0 && this.headerQuotaAvailableAt === 0) {
|
|
995
|
+
this.scheduleQuotaProbe();
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
this.notifyStateWaiters();
|
|
728
999
|
}
|
|
729
1000
|
parseRateLimitHeaders(headers) {
|
|
730
1001
|
const getHeader = (name) => {
|
|
@@ -764,18 +1035,27 @@ class EtsyRateLimiter {
|
|
|
764
1035
|
this.config.onApproachingLimit(remaining, limit, percentageUsed);
|
|
765
1036
|
}
|
|
766
1037
|
}
|
|
767
|
-
async handleRateLimitResponse(headers) {
|
|
1038
|
+
async handleRateLimitResponse(headers, reservationId, retryAttempt) {
|
|
768
1039
|
const parsed = headers ? this.parseRateLimitHeaders(headers) : {};
|
|
769
|
-
this.updateFromHeaders(headers);
|
|
770
|
-
if (
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1040
|
+
this.updateFromHeaders(headers, reservationId);
|
|
1041
|
+
if (parsed.remainingToday === 0) {
|
|
1042
|
+
if (parsed.retryAfter !== undefined) {
|
|
1043
|
+
this.headerQuotaAvailableAt = Date.now() + parsed.retryAfter * 1000;
|
|
1044
|
+
this.retryNotBefore = Math.max(this.retryNotBefore, this.headerQuotaAvailableAt);
|
|
1045
|
+
}
|
|
775
1046
|
this.currentRetryCount = 0;
|
|
1047
|
+
throw new EtsyRateLimitError('Daily rate limit exhausted according to Etsy response headers.', parsed.retryAfter, 'qpd_exhausted');
|
|
1048
|
+
}
|
|
1049
|
+
const attempt = retryAttempt ?? this.currentRetryCount + 1;
|
|
1050
|
+
if (retryAttempt === undefined)
|
|
1051
|
+
this.currentRetryCount = attempt;
|
|
1052
|
+
const delayMs = this.calculateBackoffDelay(attempt, parsed.retryAfter);
|
|
1053
|
+
this.retryNotBefore = Math.max(this.retryNotBefore, Date.now() + delayMs);
|
|
1054
|
+
if (attempt > this.config.maxRetries) {
|
|
1055
|
+
if (retryAttempt === undefined)
|
|
1056
|
+
this.currentRetryCount = 0;
|
|
776
1057
|
throw new EtsyRateLimitError(`Max retries (${this.config.maxRetries}) exceeded for rate limit`, parsed.retryAfter, 'qps_exhausted');
|
|
777
1058
|
}
|
|
778
|
-
const delayMs = this.calculateBackoffDelay(this.currentRetryCount, parsed.retryAfter);
|
|
779
1059
|
return { shouldRetry: true, delayMs };
|
|
780
1060
|
}
|
|
781
1061
|
calculateBackoffDelay(attempt, retryAfterSeconds) {
|
|
@@ -788,7 +1068,7 @@ class EtsyRateLimiter {
|
|
|
788
1068
|
const randomJitter = Math.random() * jitterAmount * 2 - jitterAmount;
|
|
789
1069
|
delay += randomJitter;
|
|
790
1070
|
}
|
|
791
|
-
return Math.max(0, Math.floor(delay));
|
|
1071
|
+
return Math.max(serverSuggestedMs, 0, Math.floor(delay));
|
|
792
1072
|
}
|
|
793
1073
|
resetRetryCount() {
|
|
794
1074
|
this.currentRetryCount = 0;
|
|
@@ -806,41 +1086,26 @@ class EtsyRateLimiter {
|
|
|
806
1086
|
return this.config.minRequestInterval;
|
|
807
1087
|
}
|
|
808
1088
|
async waitForRateLimit() {
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
const effectiveDailyLimit = this.headerLimitPerDay ?? this.config.maxRequestsPerDay;
|
|
815
|
-
const effectiveRemaining = this.headerRemainingToday ??
|
|
816
|
-
(effectiveDailyLimit - this.requestCount);
|
|
817
|
-
if (effectiveRemaining <= 0) {
|
|
818
|
-
const timeUntilReset = this.dailyReset.getTime() - now;
|
|
819
|
-
throw new EtsyRateLimitError(`Daily rate limit exhausted (${effectiveDailyLimit} requests). ` +
|
|
820
|
-
`Reset in approximately ${Math.ceil(timeUntilReset / 1000 / 60)} minutes.`, Math.ceil(timeUntilReset / 1000), 'qpd_exhausted');
|
|
821
|
-
}
|
|
822
|
-
const minInterval = this.getEffectiveMinInterval();
|
|
823
|
-
const timeSinceLastRequest = now - this.lastRequestTime;
|
|
824
|
-
if (timeSinceLastRequest < minInterval) {
|
|
825
|
-
const waitTime = minInterval - timeSinceLastRequest;
|
|
826
|
-
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
827
|
-
}
|
|
828
|
-
this.requestCount++;
|
|
829
|
-
this.lastRequestTime = Date.now();
|
|
1089
|
+
const reservationId = await this.acquireRequestSlot();
|
|
1090
|
+
this.finishReservation(reservationId, false);
|
|
1091
|
+
}
|
|
1092
|
+
async waitForRateLimitWithReservation() {
|
|
1093
|
+
return this.acquireRequestSlot();
|
|
830
1094
|
}
|
|
831
1095
|
getRateLimitStatus() {
|
|
832
1096
|
const now = Date.now();
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
const effectiveRemaining = this.headerRemainingToday ??
|
|
838
|
-
Math.max(0, this.config.maxRequestsPerDay - this.requestCount);
|
|
1097
|
+
this.pruneRequestTimestamps(now);
|
|
1098
|
+
this.refreshExpiredHeaderQuota(now);
|
|
1099
|
+
const effectiveRemaining = this.getEffectiveRemainingRequests(now);
|
|
1100
|
+
const qpdProbeReady = this.isQuotaProbeReady(now);
|
|
839
1101
|
return {
|
|
840
1102
|
remainingRequests: effectiveRemaining,
|
|
841
|
-
resetTime: this.
|
|
842
|
-
|
|
843
|
-
|
|
1103
|
+
resetTime: this.headerRemainingToday === 0
|
|
1104
|
+
? new Date(this.headerQuotaAvailableAt || now)
|
|
1105
|
+
: this.getLocalResetTime(now),
|
|
1106
|
+
canMakeRequest: (effectiveRemaining > 0 || qpdProbeReady) &&
|
|
1107
|
+
(now - this.lastRequestTime) >= this.getEffectiveMinInterval() &&
|
|
1108
|
+
now >= this.retryNotBefore,
|
|
844
1109
|
isFromHeaders: this.isHeaderBasedLimiting,
|
|
845
1110
|
limitPerSecond: this.headerLimitPerSecond,
|
|
846
1111
|
remainingThisSecond: this.headerRemainingThisSecond,
|
|
@@ -848,41 +1113,47 @@ class EtsyRateLimiter {
|
|
|
848
1113
|
};
|
|
849
1114
|
}
|
|
850
1115
|
getRemainingRequests() {
|
|
851
|
-
|
|
852
|
-
|
|
1116
|
+
const now = Date.now();
|
|
1117
|
+
this.pruneRequestTimestamps(now);
|
|
1118
|
+
this.refreshExpiredHeaderQuota(now);
|
|
1119
|
+
return this.getEffectiveRemainingRequests(now);
|
|
853
1120
|
}
|
|
854
1121
|
reset() {
|
|
855
|
-
this.
|
|
1122
|
+
this.requestTimestamps = [];
|
|
856
1123
|
this.lastRequestTime = 0;
|
|
857
1124
|
this.currentRetryCount = 0;
|
|
1125
|
+
this.quotaProbeBackoffMs = 60000;
|
|
1126
|
+
this.headerQuotaAvailableAt = 0;
|
|
1127
|
+
this.retryNotBefore = 0;
|
|
858
1128
|
this.headerLimitPerSecond = undefined;
|
|
859
1129
|
this.headerRemainingThisSecond = undefined;
|
|
860
1130
|
this.headerLimitPerDay = undefined;
|
|
861
1131
|
this.headerRemainingToday = undefined;
|
|
862
1132
|
this.isHeaderBasedLimiting = false;
|
|
863
|
-
this.
|
|
1133
|
+
this.nextReservationId = 0;
|
|
1134
|
+
this.latestHeaderReservationId = 0;
|
|
1135
|
+
this.inFlightReservations.clear();
|
|
1136
|
+
this.uncertainRequestTimestamps = [];
|
|
1137
|
+
this.quotaProbeInFlight = false;
|
|
1138
|
+
this.notifyStateWaiters();
|
|
864
1139
|
}
|
|
865
1140
|
canMakeRequest() {
|
|
866
1141
|
const now = Date.now();
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
(this.config.maxRequestsPerDay - this.requestCount);
|
|
873
|
-
if (effectiveRemaining <= 0) {
|
|
1142
|
+
this.pruneRequestTimestamps(now);
|
|
1143
|
+
this.refreshExpiredHeaderQuota(now);
|
|
1144
|
+
const effectiveRemaining = this.getEffectiveRemainingRequests(now);
|
|
1145
|
+
const qpdProbeReady = this.isQuotaProbeReady(now);
|
|
1146
|
+
if (effectiveRemaining <= 0 && !qpdProbeReady) {
|
|
874
1147
|
return false;
|
|
875
1148
|
}
|
|
876
|
-
return (now - this.lastRequestTime) >= this.getEffectiveMinInterval()
|
|
1149
|
+
return (now - this.lastRequestTime) >= this.getEffectiveMinInterval() &&
|
|
1150
|
+
now >= this.retryNotBefore;
|
|
877
1151
|
}
|
|
878
1152
|
getTimeUntilNextRequest() {
|
|
879
1153
|
const now = Date.now();
|
|
880
1154
|
const timeSinceLastRequest = now - this.lastRequestTime;
|
|
881
1155
|
const minInterval = this.getEffectiveMinInterval();
|
|
882
|
-
|
|
883
|
-
return 0;
|
|
884
|
-
}
|
|
885
|
-
return minInterval - timeSinceLastRequest;
|
|
1156
|
+
return Math.max(0, minInterval - timeSinceLastRequest, this.retryNotBefore - now);
|
|
886
1157
|
}
|
|
887
1158
|
getConfig() {
|
|
888
1159
|
return { ...this.config };
|
|
@@ -1084,10 +1355,10 @@ class FieldValidator {
|
|
|
1084
1355
|
const value = data[this.field];
|
|
1085
1356
|
if (value === undefined || value === null)
|
|
1086
1357
|
return null;
|
|
1087
|
-
if (typeof value !== 'number' || Number.
|
|
1358
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
1088
1359
|
return {
|
|
1089
1360
|
field: this.field,
|
|
1090
|
-
message: options.message || `${this.field} must be a number`,
|
|
1361
|
+
message: options.message || `${this.field} must be a finite number`,
|
|
1091
1362
|
value
|
|
1092
1363
|
};
|
|
1093
1364
|
}
|
|
@@ -1192,28 +1463,10 @@ const CreateListingSchema = new Validator()
|
|
|
1192
1463
|
.rule(field('title').string({ min: 1, max: 140, message: 'title must be 1-140 characters' }))
|
|
1193
1464
|
.rule(field('description').string({ max: 65535, message: 'description must be less than 65535 characters' }))
|
|
1194
1465
|
.rule(field('price').required())
|
|
1195
|
-
.rule(field('price').number({
|
|
1466
|
+
.rule(field('price').number({ positive: true, message: 'price must be a finite positive number' }))
|
|
1196
1467
|
.rule(field('who_made').enum(['i_did', 'someone_else', 'collective'], 'who_made must be one of: i_did, someone_else, collective'))
|
|
1197
|
-
.rule(field('when_made').
|
|
1198
|
-
'made_to_order',
|
|
1199
|
-
'2020_2024',
|
|
1200
|
-
'2010_2019',
|
|
1201
|
-
'2005_2009',
|
|
1202
|
-
'2000_2004',
|
|
1203
|
-
'1990s',
|
|
1204
|
-
'1980s',
|
|
1205
|
-
'1970s',
|
|
1206
|
-
'1960s',
|
|
1207
|
-
'1950s',
|
|
1208
|
-
'1940s',
|
|
1209
|
-
'1930s',
|
|
1210
|
-
'1920s',
|
|
1211
|
-
'1910s',
|
|
1212
|
-
'1900s',
|
|
1213
|
-
'1800s',
|
|
1214
|
-
'1700s',
|
|
1215
|
-
'before_1700'
|
|
1216
|
-
]))
|
|
1468
|
+
.rule(field('when_made').required())
|
|
1469
|
+
.rule(field('when_made').enum(['made_to_order', ...ETSY_WHEN_MADE_VALUES]))
|
|
1217
1470
|
.rule(field('taxonomy_id').required())
|
|
1218
1471
|
.rule(field('taxonomy_id').number({ integer: true, positive: true, message: 'taxonomy_id must be a positive integer' }));
|
|
1219
1472
|
const UpdateListingSchema = new Validator()
|
|
@@ -1350,7 +1603,8 @@ class EtsyClient {
|
|
|
1350
1603
|
this.logger = new DefaultLogger();
|
|
1351
1604
|
this.keystring = config.keystring;
|
|
1352
1605
|
this.sharedSecret = config.sharedSecret;
|
|
1353
|
-
|
|
1606
|
+
this.rateLimitingEnabled = config.rateLimiting?.enabled !== false;
|
|
1607
|
+
if (this.rateLimitingEnabled) {
|
|
1354
1608
|
this.rateLimiter = new EtsyRateLimiter({
|
|
1355
1609
|
maxRequestsPerDay: config.rateLimiting?.maxRequestsPerDay || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY,
|
|
1356
1610
|
maxRequestsPerSecond: config.rateLimiting?.maxRequestsPerSecond || ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND,
|
|
@@ -1375,11 +1629,39 @@ class EtsyClient {
|
|
|
1375
1629
|
stopOnError: false
|
|
1376
1630
|
});
|
|
1377
1631
|
}
|
|
1632
|
+
async acquireRateLimitSlot() {
|
|
1633
|
+
return this.rateLimitingEnabled
|
|
1634
|
+
? this.rateLimiter.acquireRequestSlot()
|
|
1635
|
+
: undefined;
|
|
1636
|
+
}
|
|
1637
|
+
async fetchOnceWithRateLimit(url, options) {
|
|
1638
|
+
const reservationId = await this.acquireRateLimitSlot();
|
|
1639
|
+
let response;
|
|
1640
|
+
try {
|
|
1641
|
+
response = await this.fetch(url, options);
|
|
1642
|
+
}
|
|
1643
|
+
catch (error) {
|
|
1644
|
+
if (reservationId !== undefined)
|
|
1645
|
+
this.rateLimiter.releaseRequestSlot(reservationId);
|
|
1646
|
+
throw error;
|
|
1647
|
+
}
|
|
1648
|
+
if (response.status === 429) {
|
|
1649
|
+
await this.rateLimiter.handleRateLimitResponse(response.headers, reservationId);
|
|
1650
|
+
this.rateLimiter.resetRetryCount();
|
|
1651
|
+
}
|
|
1652
|
+
else {
|
|
1653
|
+
this.rateLimiter.updateFromHeaders(response.headers, reservationId);
|
|
1654
|
+
if (response.ok)
|
|
1655
|
+
this.rateLimiter.resetRetryCount();
|
|
1656
|
+
}
|
|
1657
|
+
return response;
|
|
1658
|
+
}
|
|
1378
1659
|
async makeRequest(endpoint, options = {}, useCache = true) {
|
|
1379
1660
|
const url = `${this.baseUrl}${endpoint}`;
|
|
1380
1661
|
const requestOptions = {
|
|
1381
1662
|
method: 'GET',
|
|
1382
|
-
...options
|
|
1663
|
+
...options,
|
|
1664
|
+
redirect: 'error'
|
|
1383
1665
|
};
|
|
1384
1666
|
const cacheKey = `${url}:${JSON.stringify(requestOptions)}`;
|
|
1385
1667
|
if (useCache && this.cache && requestOptions.method === 'GET') {
|
|
@@ -1393,7 +1675,6 @@ class EtsyClient {
|
|
|
1393
1675
|
}
|
|
1394
1676
|
}
|
|
1395
1677
|
}
|
|
1396
|
-
await this.rateLimiter.waitForRateLimit();
|
|
1397
1678
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
1398
1679
|
const headers = {
|
|
1399
1680
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -1405,22 +1686,38 @@ class EtsyClient {
|
|
|
1405
1686
|
return this.executeWithRetry(url, { ...requestOptions, headers }, cacheKey, useCache);
|
|
1406
1687
|
}
|
|
1407
1688
|
async executeWithRetry(url, options, cacheKey, useCache) {
|
|
1689
|
+
let retryAttempt = 0;
|
|
1408
1690
|
while (true) {
|
|
1409
1691
|
try {
|
|
1410
|
-
const
|
|
1692
|
+
const reservationId = await this.acquireRateLimitSlot();
|
|
1693
|
+
let response;
|
|
1694
|
+
try {
|
|
1695
|
+
response = await this.fetch(url, options);
|
|
1696
|
+
}
|
|
1697
|
+
catch (error) {
|
|
1698
|
+
if (reservationId !== undefined)
|
|
1699
|
+
this.rateLimiter.releaseRequestSlot(reservationId);
|
|
1700
|
+
throw error;
|
|
1701
|
+
}
|
|
1411
1702
|
if (response.status === 429) {
|
|
1412
|
-
const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers);
|
|
1703
|
+
const { shouldRetry, delayMs } = await this.rateLimiter.handleRateLimitResponse(response.headers, reservationId, ++retryAttempt);
|
|
1413
1704
|
if (shouldRetry) {
|
|
1705
|
+
const method = (options.method ?? 'GET').toUpperCase();
|
|
1706
|
+
if (method !== 'GET') {
|
|
1707
|
+
this.rateLimiter.resetRetryCount();
|
|
1708
|
+
const errorText = await response.text();
|
|
1709
|
+
throw new EtsyApiError(`Etsy API error: 429 ${response.statusText}; automatic retry skipped for ${method} requests`, 429, errorText);
|
|
1710
|
+
}
|
|
1414
1711
|
this.logger.warn(`Rate limited. Retrying in ${delayMs}ms...`);
|
|
1415
1712
|
await this.sleep(delayMs);
|
|
1416
1713
|
continue;
|
|
1417
1714
|
}
|
|
1418
1715
|
}
|
|
1716
|
+
this.rateLimiter.updateFromHeaders(response.headers, reservationId);
|
|
1419
1717
|
if (!response.ok) {
|
|
1420
1718
|
const errorText = await response.text();
|
|
1421
1719
|
throw new EtsyApiError(`Etsy API error: ${response.status} ${response.statusText}`, response.status, errorText);
|
|
1422
1720
|
}
|
|
1423
|
-
this.rateLimiter.updateFromHeaders(response.headers);
|
|
1424
1721
|
this.rateLimiter.resetRetryCount();
|
|
1425
1722
|
if (response.status === 204) {
|
|
1426
1723
|
return undefined;
|
|
@@ -1723,9 +2020,87 @@ class EtsyClient {
|
|
|
1723
2020
|
async updateListingInventory(listingId, params, options) {
|
|
1724
2021
|
const legacy = options?.legacy;
|
|
1725
2022
|
const query = legacy !== undefined ? `?legacy=${legacy}` : '';
|
|
2023
|
+
if (!Array.isArray(params.products) || params.products.length === 0) {
|
|
2024
|
+
throw new TypeError('Inventory update must contain at least one product.');
|
|
2025
|
+
}
|
|
2026
|
+
const body = {
|
|
2027
|
+
products: params.products.map((product, productIndex) => {
|
|
2028
|
+
if (product.is_deleted === true) {
|
|
2029
|
+
throw new TypeError(`Inventory product ${productIndex} is deleted and cannot be submitted for replacement.`);
|
|
2030
|
+
}
|
|
2031
|
+
if (!Array.isArray(product.offerings) || product.offerings.length === 0) {
|
|
2032
|
+
throw new TypeError(`Inventory product ${productIndex} must contain at least one offering.`);
|
|
2033
|
+
}
|
|
2034
|
+
if (!Array.isArray(product.property_values)) {
|
|
2035
|
+
throw new TypeError(`Inventory product ${productIndex} must include property_values; pass [] when it has no variation properties.`);
|
|
2036
|
+
}
|
|
2037
|
+
if (product.sku !== undefined && product.sku !== null && typeof product.sku !== 'string') {
|
|
2038
|
+
throw new TypeError(`Inventory product ${productIndex} sku must be a string or null.`);
|
|
2039
|
+
}
|
|
2040
|
+
return {
|
|
2041
|
+
...(product.sku !== undefined ? { sku: product.sku } : {}),
|
|
2042
|
+
property_values: product.property_values.map((value, valueIndex) => {
|
|
2043
|
+
if (!Number.isInteger(value.property_id) || value.property_id < 1) {
|
|
2044
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_id must be a positive integer.`);
|
|
2045
|
+
}
|
|
2046
|
+
if (!Array.isArray(value.value_ids) || value.value_ids.some((id) => !Number.isInteger(id))) {
|
|
2047
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].value_ids must be an integer array.`);
|
|
2048
|
+
}
|
|
2049
|
+
if (!Array.isArray(value.values) || value.values.some((entry) => typeof entry !== 'string')) {
|
|
2050
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].values must be a string array.`);
|
|
2051
|
+
}
|
|
2052
|
+
if (value.property_name !== undefined && typeof value.property_name !== 'string') {
|
|
2053
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].property_name must be a string.`);
|
|
2054
|
+
}
|
|
2055
|
+
if (value.scale_id !== undefined && value.scale_id !== null &&
|
|
2056
|
+
(!Number.isInteger(value.scale_id) || value.scale_id < 1)) {
|
|
2057
|
+
throw new TypeError(`Inventory product ${productIndex} property_values[${valueIndex}].scale_id must be a positive integer or null.`);
|
|
2058
|
+
}
|
|
2059
|
+
return {
|
|
2060
|
+
property_id: value.property_id,
|
|
2061
|
+
...(value.property_name !== undefined ? { property_name: value.property_name } : {}),
|
|
2062
|
+
...(value.scale_id !== undefined ? { scale_id: value.scale_id } : {}),
|
|
2063
|
+
value_ids: value.value_ids,
|
|
2064
|
+
values: value.values,
|
|
2065
|
+
};
|
|
2066
|
+
}),
|
|
2067
|
+
offerings: product.offerings.map((offering, offeringIndex) => {
|
|
2068
|
+
if (offering.is_deleted === true) {
|
|
2069
|
+
throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is deleted and cannot be submitted for replacement.`);
|
|
2070
|
+
}
|
|
2071
|
+
if (typeof offering.price !== 'number' || !Number.isFinite(offering.price)) {
|
|
2072
|
+
throw new TypeError('Inventory offering price must be a finite decimal number in the listing currency.');
|
|
2073
|
+
}
|
|
2074
|
+
if (!Number.isInteger(offering.quantity)) {
|
|
2075
|
+
throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} quantity must be an integer.`);
|
|
2076
|
+
}
|
|
2077
|
+
if (typeof offering.is_enabled !== 'boolean') {
|
|
2078
|
+
throw new TypeError(`Inventory product ${productIndex} offering ${offeringIndex} is_enabled must be a boolean.`);
|
|
2079
|
+
}
|
|
2080
|
+
if (offering.readiness_state_id === undefined ||
|
|
2081
|
+
(offering.readiness_state_id !== null &&
|
|
2082
|
+
(!Number.isInteger(offering.readiness_state_id) || offering.readiness_state_id < 1))) {
|
|
2083
|
+
throw new TypeError('Inventory offering readiness_state_id is required; use null when not set.');
|
|
2084
|
+
}
|
|
2085
|
+
return {
|
|
2086
|
+
price: offering.price,
|
|
2087
|
+
quantity: offering.quantity,
|
|
2088
|
+
is_enabled: offering.is_enabled,
|
|
2089
|
+
readiness_state_id: offering.readiness_state_id,
|
|
2090
|
+
};
|
|
2091
|
+
}),
|
|
2092
|
+
};
|
|
2093
|
+
}),
|
|
2094
|
+
...(params.price_on_property !== undefined ? { price_on_property: params.price_on_property } : {}),
|
|
2095
|
+
...(params.quantity_on_property !== undefined ? { quantity_on_property: params.quantity_on_property } : {}),
|
|
2096
|
+
...(params.sku_on_property !== undefined ? { sku_on_property: params.sku_on_property } : {}),
|
|
2097
|
+
...(params.readiness_state_on_property !== undefined
|
|
2098
|
+
? { readiness_state_on_property: params.readiness_state_on_property }
|
|
2099
|
+
: {}),
|
|
2100
|
+
};
|
|
1726
2101
|
return this.makeRequest(`/listings/${listingId}/inventory${query}`, {
|
|
1727
2102
|
method: 'PUT',
|
|
1728
|
-
body: JSON.stringify(
|
|
2103
|
+
body: JSON.stringify(body)
|
|
1729
2104
|
}, false);
|
|
1730
2105
|
}
|
|
1731
2106
|
async uploadListingImage(shopId, listingId, imageData, params) {
|
|
@@ -1740,9 +2115,8 @@ class EtsyClient {
|
|
|
1740
2115
|
if (params?.alt_text)
|
|
1741
2116
|
formData.append('alt_text', params.alt_text);
|
|
1742
2117
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/images`;
|
|
1743
|
-
await this.rateLimiter.waitForRateLimit();
|
|
1744
2118
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
1745
|
-
const response = await this.
|
|
2119
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
1746
2120
|
method: 'POST',
|
|
1747
2121
|
headers: {
|
|
1748
2122
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -1937,10 +2311,11 @@ class EtsyClient {
|
|
|
1937
2311
|
}
|
|
1938
2312
|
async getShopPayment(shopId, paymentId) {
|
|
1939
2313
|
const payments = await this.getPayments(shopId, [Number(paymentId)]);
|
|
1940
|
-
|
|
2314
|
+
const payment = payments[0];
|
|
2315
|
+
if (!payment) {
|
|
1941
2316
|
throw new EtsyApiError('Payment not found', 404);
|
|
1942
2317
|
}
|
|
1943
|
-
return
|
|
2318
|
+
return payment;
|
|
1944
2319
|
}
|
|
1945
2320
|
async getBuyerTaxonomyNodes() {
|
|
1946
2321
|
const response = await this.makeRequest('/buyer-taxonomy/nodes');
|
|
@@ -1963,9 +2338,8 @@ class EtsyClient {
|
|
|
1963
2338
|
body.append('scale_id', scaleId.toString());
|
|
1964
2339
|
}
|
|
1965
2340
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/properties/${propertyId}`;
|
|
1966
|
-
await this.rateLimiter.waitForRateLimit();
|
|
1967
2341
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
1968
|
-
const response = await this.
|
|
2342
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
1969
2343
|
method: 'PUT',
|
|
1970
2344
|
headers: {
|
|
1971
2345
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -1974,8 +2348,6 @@ class EtsyClient {
|
|
|
1974
2348
|
},
|
|
1975
2349
|
body: body.toString()
|
|
1976
2350
|
});
|
|
1977
|
-
this.rateLimiter.updateFromHeaders(response.headers);
|
|
1978
|
-
this.rateLimiter.resetRetryCount();
|
|
1979
2351
|
if (!response.ok) {
|
|
1980
2352
|
const errorText = await response.text();
|
|
1981
2353
|
throw new EtsyApiError(`Failed to update listing property: ${response.status} ${response.statusText}`, response.status, errorText);
|
|
@@ -2075,9 +2447,8 @@ class EtsyClient {
|
|
|
2075
2447
|
if (params?.listing_file_id !== undefined)
|
|
2076
2448
|
formData.append('listing_file_id', params.listing_file_id.toString());
|
|
2077
2449
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/files`;
|
|
2078
|
-
await this.rateLimiter.waitForRateLimit();
|
|
2079
2450
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
2080
|
-
const response = await this.
|
|
2451
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
2081
2452
|
method: 'POST',
|
|
2082
2453
|
headers: {
|
|
2083
2454
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -2109,9 +2480,8 @@ class EtsyClient {
|
|
|
2109
2480
|
if (params?.video_id !== undefined)
|
|
2110
2481
|
formData.append('video_id', params.video_id.toString());
|
|
2111
2482
|
const url = `${this.baseUrl}/shops/${shopId}/listings/${listingId}/videos`;
|
|
2112
|
-
await this.rateLimiter.waitForRateLimit();
|
|
2113
2483
|
const accessToken = await this.tokenManager.getAccessToken();
|
|
2114
|
-
const response = await this.
|
|
2484
|
+
const response = await this.fetchOnceWithRateLimit(url, {
|
|
2115
2485
|
method: 'POST',
|
|
2116
2486
|
headers: {
|
|
2117
2487
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -2533,6 +2903,7 @@ class AuthHelper {
|
|
|
2533
2903
|
assertFetchSupport();
|
|
2534
2904
|
const response = await fetch('https://api.etsy.com/v3/public/oauth/token', {
|
|
2535
2905
|
method: 'POST',
|
|
2906
|
+
redirect: 'error',
|
|
2536
2907
|
headers: {
|
|
2537
2908
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
2538
2909
|
'Accept': 'application/json'
|
|
@@ -2625,18 +2996,17 @@ const COMMON_SCOPE_COMBINATIONS = {
|
|
|
2625
2996
|
]
|
|
2626
2997
|
};
|
|
2627
2998
|
|
|
2999
|
+
const QPD_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
2628
3000
|
class GlobalRequestQueue {
|
|
2629
3001
|
constructor() {
|
|
2630
3002
|
this.queue = [];
|
|
2631
3003
|
this.processing = false;
|
|
2632
3004
|
this.rateLimits = new Map();
|
|
2633
|
-
this.
|
|
2634
|
-
this.dailyReset = new Date();
|
|
3005
|
+
this.requestTimestamps = [];
|
|
2635
3006
|
this.lastRequestTime = 0;
|
|
2636
3007
|
this.maxRequestsPerDay = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_DAY;
|
|
2637
3008
|
this.maxRequestsPerSecond = ETSY_RATE_LIMITS.MAX_REQUESTS_PER_SECOND;
|
|
2638
3009
|
this.minRequestInterval = ETSY_RATE_LIMITS.MIN_REQUEST_INTERVAL;
|
|
2639
|
-
this.setNextDailyReset();
|
|
2640
3010
|
}
|
|
2641
3011
|
static getInstance() {
|
|
2642
3012
|
if (!this.instance) {
|
|
@@ -2668,11 +3038,13 @@ class GlobalRequestQueue {
|
|
|
2668
3038
|
});
|
|
2669
3039
|
}
|
|
2670
3040
|
getStatus() {
|
|
3041
|
+
const now = Date.now();
|
|
3042
|
+
this.pruneRequestTimestamps(now);
|
|
2671
3043
|
return {
|
|
2672
3044
|
queueLength: this.queue.length,
|
|
2673
3045
|
processing: this.processing,
|
|
2674
|
-
remainingRequests: Math.max(0, this.maxRequestsPerDay - this.
|
|
2675
|
-
resetTime: this.
|
|
3046
|
+
remainingRequests: Math.max(0, this.maxRequestsPerDay - this.requestTimestamps.length),
|
|
3047
|
+
resetTime: this.getNextRequestExpiry(now),
|
|
2676
3048
|
};
|
|
2677
3049
|
}
|
|
2678
3050
|
clear() {
|
|
@@ -2688,16 +3060,13 @@ class GlobalRequestQueue {
|
|
|
2688
3060
|
this.processing = true;
|
|
2689
3061
|
try {
|
|
2690
3062
|
while (this.queue.length > 0) {
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
const timeUntilReset = this.dailyReset.getTime() - Date.now();
|
|
2697
|
-
console.warn(`Daily rate limit reached. Waiting ${Math.ceil(timeUntilReset / 1000 / 60)} minutes until reset.`);
|
|
3063
|
+
const now = Date.now();
|
|
3064
|
+
this.pruneRequestTimestamps(now);
|
|
3065
|
+
if (this.requestTimestamps.length >= this.maxRequestsPerDay) {
|
|
3066
|
+
const timeUntilReset = Math.max(0, this.getNextRequestExpiry(now).getTime() - now);
|
|
3067
|
+
console.warn(`Daily rate limit reached. Waiting ${Math.ceil(timeUntilReset / 1000 / 60)} minutes for the oldest request to leave the rolling 24-hour window.`);
|
|
2698
3068
|
await this.delay(timeUntilReset);
|
|
2699
|
-
|
|
2700
|
-
this.setNextDailyReset();
|
|
3069
|
+
continue;
|
|
2701
3070
|
}
|
|
2702
3071
|
await this.waitForRateLimit();
|
|
2703
3072
|
this.queue.sort((a, b) => {
|
|
@@ -2732,7 +3101,7 @@ class GlobalRequestQueue {
|
|
|
2732
3101
|
}
|
|
2733
3102
|
});
|
|
2734
3103
|
try {
|
|
2735
|
-
result = await Promise.race([item.request
|
|
3104
|
+
result = await Promise.race([this.startRequest(item.request), timeoutPromise]);
|
|
2736
3105
|
}
|
|
2737
3106
|
finally {
|
|
2738
3107
|
if (timeoutId) {
|
|
@@ -2741,11 +3110,9 @@ class GlobalRequestQueue {
|
|
|
2741
3110
|
}
|
|
2742
3111
|
}
|
|
2743
3112
|
else {
|
|
2744
|
-
result = await item.request
|
|
3113
|
+
result = await this.startRequest(item.request);
|
|
2745
3114
|
}
|
|
2746
3115
|
item.resolve(result);
|
|
2747
|
-
this.requestCount++;
|
|
2748
|
-
this.lastRequestTime = Date.now();
|
|
2749
3116
|
}
|
|
2750
3117
|
catch (error) {
|
|
2751
3118
|
if (error instanceof EtsyRateLimitError) {
|
|
@@ -2765,7 +3132,7 @@ class GlobalRequestQueue {
|
|
|
2765
3132
|
const globalRateLimit = this.rateLimits.get('global');
|
|
2766
3133
|
if (globalRateLimit && now < globalRateLimit.resetAt) {
|
|
2767
3134
|
const waitTime = globalRateLimit.resetAt - now;
|
|
2768
|
-
console.
|
|
3135
|
+
console.warn(`Global rate limit active. Waiting ${waitTime}ms`);
|
|
2769
3136
|
await this.delay(waitTime);
|
|
2770
3137
|
}
|
|
2771
3138
|
const timeSinceLastRequest = now - this.lastRequestTime;
|
|
@@ -2783,11 +3150,23 @@ class GlobalRequestQueue {
|
|
|
2783
3150
|
});
|
|
2784
3151
|
}
|
|
2785
3152
|
}
|
|
2786
|
-
|
|
2787
|
-
const
|
|
2788
|
-
this.
|
|
2789
|
-
|
|
2790
|
-
|
|
3153
|
+
pruneRequestTimestamps(now) {
|
|
3154
|
+
const oldestAllowed = now - QPD_WINDOW_MS;
|
|
3155
|
+
while (this.requestTimestamps[0] !== undefined && this.requestTimestamps[0] <= oldestAllowed) {
|
|
3156
|
+
this.requestTimestamps.shift();
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
getNextRequestExpiry(now) {
|
|
3160
|
+
this.pruneRequestTimestamps(now);
|
|
3161
|
+
const oldestRequest = this.requestTimestamps[0] ?? now;
|
|
3162
|
+
return new Date(oldestRequest + QPD_WINDOW_MS);
|
|
3163
|
+
}
|
|
3164
|
+
startRequest(request) {
|
|
3165
|
+
const startedAt = Date.now();
|
|
3166
|
+
this.pruneRequestTimestamps(startedAt);
|
|
3167
|
+
this.requestTimestamps.push(startedAt);
|
|
3168
|
+
this.lastRequestTime = startedAt;
|
|
3169
|
+
return request();
|
|
2791
3170
|
}
|
|
2792
3171
|
generateId() {
|
|
2793
3172
|
return `${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
@@ -4298,7 +4677,7 @@ function createTokenManager(config, storage) {
|
|
|
4298
4677
|
function createRateLimiter(config) {
|
|
4299
4678
|
return new EtsyRateLimiter(config);
|
|
4300
4679
|
}
|
|
4301
|
-
const VERSION = '
|
|
4680
|
+
const VERSION = '3.0.1';
|
|
4302
4681
|
const LIBRARY_NAME = 'etsy-v3-api-client';
|
|
4303
4682
|
function getLibraryInfo() {
|
|
4304
4683
|
return {
|