@se-studio/contentful-rest-api 1.0.19 → 1.0.21
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/dist/index.d.ts +14 -9
- package/dist/index.js +204 -112
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -452,6 +452,8 @@ interface ContentfulQuery {
|
|
|
452
452
|
declare class ContentfulFetchClient {
|
|
453
453
|
private readonly baseUrl;
|
|
454
454
|
private readonly accessToken;
|
|
455
|
+
private readonly rateLimiter;
|
|
456
|
+
private readonly isPreview;
|
|
455
457
|
constructor(config: ContentfulConfig, preview?: boolean);
|
|
456
458
|
getEntries<T = any>(query: ContentfulQuery, options?: FetchOptions): Promise<ContentfulResponse<T>>;
|
|
457
459
|
}
|
|
@@ -613,15 +615,18 @@ declare function getRetryAfter(error: unknown): number | undefined;
|
|
|
613
615
|
declare function calculateBackoffDelay(attempt: number, config: Required<RetryConfig>, retryAfter?: number): number;
|
|
614
616
|
declare function withRetry<T>(fn: () => Promise<T>, config?: RetryConfig): Promise<T>;
|
|
615
617
|
declare class RateLimiter {
|
|
616
|
-
private readonly
|
|
617
|
-
private
|
|
618
|
-
private
|
|
619
|
-
private
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
618
|
+
private readonly intervalMs;
|
|
619
|
+
private nextAvailableTime;
|
|
620
|
+
private readonly queue;
|
|
621
|
+
private processing;
|
|
622
|
+
private pauseUntil;
|
|
623
|
+
constructor(requestsPerSecond: number, _refillRate?: number);
|
|
624
|
+
private getWaitTime;
|
|
625
|
+
private processQueue;
|
|
626
|
+
acquire(): Promise<void>;
|
|
627
|
+
getQueueLength(): number;
|
|
628
|
+
pause(seconds: number): void;
|
|
629
|
+
getIntervalMs(): number;
|
|
625
630
|
}
|
|
626
631
|
|
|
627
632
|
export { AllTags, ArticleTag, ArticleTypeTag, AssetTag, AuthenticationError, BannerTag, type BaseConverterContext, type CmsError, type CmsResponse, type ContentResolverFunction, ContentfulFetchClient as ContentfulClient, type ContentfulConfig, ContentfulError, ContentfulFetchClient, type ConverterContext, CustomTypeTag, type DefaultChainModifier, EntryNotFoundError, type FetchOptions, GlobalTag, type IContentfulCollection, type IContentfulComponent, type IContentfulPerson, type IContentfulRichText, type IFetchedTemplate, type ISitemapEntry, LocationTag, NavigationTag, PageTag, PersonTag, RateLimitError, RateLimiter, type RelatedArticlesOptions, type RetryConfig, type RevalidationConfig, type SitemapChangeFrequency, type SitemapConfig, type SitemapContentTypeConfig, type SitemapEntryProvider, TagTag, TemplateTag, ValidationError, arrayOrUndefined, articleTag, articleTypeIndexTag, articleTypeTag, assetTag, basePageConverter, calculateBackoffDelay, contentfulAllArticleLinks, contentfulAllArticleTypeLinks, contentfulAllPageLinks, contentfulAllPersonLinks, contentfulAllTagLinks, contentfulArticleRest, contentfulArticleSitemapEntries, contentfulArticleTypeRest, contentfulArticleTypeSitemapEntries, contentfulCustomTypeRest, contentfulPageRest, contentfulPageSitemapEntries, contentfulPersonSitemapEntries, contentfulTagRest, contentfulTagSitemapEntries, contentfulTemplateRest, createBaseConverterContext, createContentfulClient, createContentfulPreviewClient, createResponsiveVisual, createRevalidationHandler, createSitemapProvider, customTypeTag, filterRelatedArticles, getAllSitemapEntries, getCacheTags, getCacheTagsForPreview, getCacheTagsForProduction, getContentfulClient, getRetryAfter, isContentfulError, isRateLimitError, isRetryableError, isValidDate, locationTag, lookupAsset, notEmpty, pageTag, personTag, resolveLink, resolveLinks, resolveRichTextDocument, revalidateSingleTag, revalidateTags, safeDate, tagTag, templateTag, withRetry };
|
package/dist/index.js
CHANGED
|
@@ -202,6 +202,154 @@ function getRetryAfter(error) {
|
|
|
202
202
|
return void 0;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
// src/utils/retry.ts
|
|
206
|
+
var DEFAULT_RETRY_CONFIG = {
|
|
207
|
+
maxRetries: 3,
|
|
208
|
+
initialDelay: 1e3,
|
|
209
|
+
// 1 second
|
|
210
|
+
maxDelay: 3e4,
|
|
211
|
+
// 30 seconds
|
|
212
|
+
backoffMultiplier: 2
|
|
213
|
+
};
|
|
214
|
+
function sleep(ms) {
|
|
215
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
216
|
+
}
|
|
217
|
+
function calculateBackoffDelay(attempt, config, retryAfter) {
|
|
218
|
+
if (retryAfter !== void 0) {
|
|
219
|
+
return Math.min(retryAfter * 1e3, config.maxDelay);
|
|
220
|
+
}
|
|
221
|
+
const exponentialDelay = config.initialDelay * config.backoffMultiplier ** attempt;
|
|
222
|
+
const jitter = Math.random() * exponentialDelay;
|
|
223
|
+
return Math.min(exponentialDelay + jitter, config.maxDelay);
|
|
224
|
+
}
|
|
225
|
+
async function withRetry(fn, config) {
|
|
226
|
+
const retryConfig = {
|
|
227
|
+
...DEFAULT_RETRY_CONFIG,
|
|
228
|
+
...config
|
|
229
|
+
};
|
|
230
|
+
let lastError;
|
|
231
|
+
for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt++) {
|
|
232
|
+
try {
|
|
233
|
+
return await fn();
|
|
234
|
+
} catch (error) {
|
|
235
|
+
lastError = error;
|
|
236
|
+
if (attempt === retryConfig.maxRetries) {
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
if (!isRetryableError(error)) {
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
const retryAfter = getRetryAfter(error);
|
|
243
|
+
const delay3 = calculateBackoffDelay(attempt, retryConfig, retryAfter);
|
|
244
|
+
if (typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
|
|
245
|
+
console.warn(
|
|
246
|
+
`Retry attempt ${attempt + 1}/${retryConfig.maxRetries} after ${delay3}ms`,
|
|
247
|
+
error
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
await sleep(delay3);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
throw lastError;
|
|
254
|
+
}
|
|
255
|
+
var RateLimiter = class {
|
|
256
|
+
/** Minimum interval between requests in milliseconds */
|
|
257
|
+
intervalMs;
|
|
258
|
+
/** Timestamp when the next request can be made */
|
|
259
|
+
nextAvailableTime = 0;
|
|
260
|
+
/** Queue of pending requests */
|
|
261
|
+
queue = [];
|
|
262
|
+
/** Whether the queue processor is running */
|
|
263
|
+
processing = false;
|
|
264
|
+
/** Additional pause time from 429 responses (ms) */
|
|
265
|
+
pauseUntil = 0;
|
|
266
|
+
constructor(requestsPerSecond, _refillRate) {
|
|
267
|
+
this.intervalMs = Math.ceil(1e3 / (requestsPerSecond * 0.8));
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Calculates how long to wait before the next request can be made
|
|
271
|
+
*/
|
|
272
|
+
getWaitTime() {
|
|
273
|
+
const now = Date.now();
|
|
274
|
+
const waitForInterval = Math.max(0, this.nextAvailableTime - now);
|
|
275
|
+
const waitForPause = Math.max(0, this.pauseUntil - now);
|
|
276
|
+
return Math.max(waitForInterval, waitForPause);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Processes the queue, releasing requests one at a time with proper spacing
|
|
280
|
+
*/
|
|
281
|
+
async processQueue() {
|
|
282
|
+
if (this.processing) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
this.processing = true;
|
|
286
|
+
while (this.queue.length > 0) {
|
|
287
|
+
const waitTime = this.getWaitTime();
|
|
288
|
+
if (waitTime > 0) {
|
|
289
|
+
await sleep(waitTime);
|
|
290
|
+
}
|
|
291
|
+
this.nextAvailableTime = Date.now() + this.intervalMs;
|
|
292
|
+
const entry = this.queue.shift();
|
|
293
|
+
entry?.resolve();
|
|
294
|
+
}
|
|
295
|
+
this.processing = false;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Acquires permission to make a request.
|
|
299
|
+
* Returns a promise that resolves when the request can proceed.
|
|
300
|
+
* Requests are processed in FIFO order with minimum interval spacing.
|
|
301
|
+
*
|
|
302
|
+
* @returns Promise that resolves when rate limit allows the request
|
|
303
|
+
*/
|
|
304
|
+
async acquire() {
|
|
305
|
+
const waitTime = this.getWaitTime();
|
|
306
|
+
if (waitTime === 0 && this.queue.length === 0) {
|
|
307
|
+
this.nextAvailableTime = Date.now() + this.intervalMs;
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
return new Promise((resolve) => {
|
|
311
|
+
this.queue.push({ resolve });
|
|
312
|
+
this.processQueue();
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Gets the number of requests currently waiting in the queue
|
|
317
|
+
*/
|
|
318
|
+
getQueueLength() {
|
|
319
|
+
return this.queue.length;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Pauses the rate limiter for a specified duration.
|
|
323
|
+
* All requests (current and queued) will wait until pause expires.
|
|
324
|
+
* Does NOT block - just sets the pause time and returns.
|
|
325
|
+
*
|
|
326
|
+
* @param seconds - Duration to pause in seconds
|
|
327
|
+
*/
|
|
328
|
+
pause(seconds) {
|
|
329
|
+
const pauseUntil = Date.now() + seconds * 1e3;
|
|
330
|
+
if (pauseUntil > this.pauseUntil) {
|
|
331
|
+
this.pauseUntil = pauseUntil;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Gets the current interval between requests in milliseconds
|
|
336
|
+
*/
|
|
337
|
+
getIntervalMs() {
|
|
338
|
+
return this.intervalMs;
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
var CONTENTFUL_RATE_LIMITS = {
|
|
342
|
+
/** Content Delivery API: 55 requests/second */
|
|
343
|
+
delivery: 55,
|
|
344
|
+
/** Content Preview API: 14 requests/second */
|
|
345
|
+
preview: 14
|
|
346
|
+
};
|
|
347
|
+
var deliveryRateLimiter = new RateLimiter(CONTENTFUL_RATE_LIMITS.delivery);
|
|
348
|
+
var previewRateLimiter = new RateLimiter(CONTENTFUL_RATE_LIMITS.preview);
|
|
349
|
+
function getRateLimiter(preview) {
|
|
350
|
+
return preview ? previewRateLimiter : deliveryRateLimiter;
|
|
351
|
+
}
|
|
352
|
+
|
|
205
353
|
// src/client.ts
|
|
206
354
|
function buildQueryString(query) {
|
|
207
355
|
const params = new URLSearchParams();
|
|
@@ -212,6 +360,23 @@ function buildQueryString(query) {
|
|
|
212
360
|
});
|
|
213
361
|
return params.toString();
|
|
214
362
|
}
|
|
363
|
+
function sleep2(ms) {
|
|
364
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
365
|
+
}
|
|
366
|
+
var RETRY_CONFIG = {
|
|
367
|
+
maxRetries: 10,
|
|
368
|
+
initialDelay: 1e3,
|
|
369
|
+
maxDelay: 15e3,
|
|
370
|
+
backoffMultiplier: 2
|
|
371
|
+
};
|
|
372
|
+
function parseRetryAfter(response) {
|
|
373
|
+
const retryAfterHeader = response.headers.get("X-Contentful-RateLimit-Reset") || response.headers.get("Retry-After");
|
|
374
|
+
if (retryAfterHeader) {
|
|
375
|
+
const parsed = Number.parseInt(retryAfterHeader, 10);
|
|
376
|
+
return Number.isNaN(parsed) ? void 0 : parsed;
|
|
377
|
+
}
|
|
378
|
+
return void 0;
|
|
379
|
+
}
|
|
215
380
|
async function parseErrorResponse(response) {
|
|
216
381
|
const statusCode = response.status;
|
|
217
382
|
let errorData;
|
|
@@ -243,14 +408,25 @@ async function parseErrorResponse(response) {
|
|
|
243
408
|
var ContentfulFetchClient = class {
|
|
244
409
|
baseUrl;
|
|
245
410
|
accessToken;
|
|
411
|
+
rateLimiter;
|
|
412
|
+
isPreview;
|
|
246
413
|
constructor(config, preview = false) {
|
|
247
414
|
const host = config.host || (preview ? "preview.contentful.com" : "cdn.contentful.com");
|
|
248
415
|
const environment = config.environment || "master";
|
|
249
416
|
this.baseUrl = `https://${host}/spaces/${config.spaceId}/environments/${environment}`;
|
|
250
417
|
this.accessToken = config.accessToken;
|
|
418
|
+
this.isPreview = preview;
|
|
419
|
+
this.rateLimiter = getRateLimiter(preview);
|
|
251
420
|
}
|
|
252
421
|
/**
|
|
253
|
-
* Fetches entries from Contentful
|
|
422
|
+
* Fetches entries from Contentful with proactive rate limiting
|
|
423
|
+
* and automatic retry on rate limit errors.
|
|
424
|
+
*
|
|
425
|
+
* Rate limits are applied proactively:
|
|
426
|
+
* - Preview API: 14 requests/second
|
|
427
|
+
* - Delivery API: 55 requests/second
|
|
428
|
+
*
|
|
429
|
+
* If a 429 is still received (e.g., due to other processes), retries with backoff.
|
|
254
430
|
*/
|
|
255
431
|
async getEntries(query, options) {
|
|
256
432
|
const queryString = buildQueryString(query);
|
|
@@ -264,11 +440,34 @@ var ContentfulFetchClient = class {
|
|
|
264
440
|
if (options?.next) {
|
|
265
441
|
fetchOptions.next = options.next;
|
|
266
442
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
443
|
+
for (let attempt = 0; attempt <= RETRY_CONFIG.maxRetries; attempt++) {
|
|
444
|
+
try {
|
|
445
|
+
await this.rateLimiter.acquire();
|
|
446
|
+
const response = await fetch(url, fetchOptions);
|
|
447
|
+
if (response.ok) {
|
|
448
|
+
return response.json();
|
|
449
|
+
}
|
|
450
|
+
if (response.status === 429 && attempt < RETRY_CONFIG.maxRetries) {
|
|
451
|
+
const retryAfter = parseRetryAfter(response);
|
|
452
|
+
const delay3 = calculateBackoffDelay(attempt, RETRY_CONFIG, retryAfter);
|
|
453
|
+
console.warn(
|
|
454
|
+
`[Contentful] Rate limited (429), retrying in ${delay3}ms (attempt ${attempt + 1}/${RETRY_CONFIG.maxRetries}). Queue: ${this.rateLimiter.getQueueLength()} waiting. API: ${this.isPreview ? "preview" : "delivery"}`
|
|
455
|
+
);
|
|
456
|
+
const pauseSeconds = Math.max(retryAfter ?? 2, 2);
|
|
457
|
+
this.rateLimiter.pause(pauseSeconds);
|
|
458
|
+
await sleep2(delay3);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
throw await parseErrorResponse(response);
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (error instanceof ContentfulError) {
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
console.error("[Contentful] Unexpected error in request", error);
|
|
467
|
+
throw new ContentfulError("Unexpected error in request", 500);
|
|
468
|
+
}
|
|
270
469
|
}
|
|
271
|
-
|
|
470
|
+
throw new ContentfulError("Max retries exceeded", 500);
|
|
272
471
|
}
|
|
273
472
|
};
|
|
274
473
|
function createContentfulClient(config) {
|
|
@@ -625,113 +824,6 @@ function safeDate(date) {
|
|
|
625
824
|
return new Date(date);
|
|
626
825
|
}
|
|
627
826
|
|
|
628
|
-
// src/utils/retry.ts
|
|
629
|
-
var DEFAULT_RETRY_CONFIG = {
|
|
630
|
-
maxRetries: 3,
|
|
631
|
-
initialDelay: 1e3,
|
|
632
|
-
// 1 second
|
|
633
|
-
maxDelay: 3e4,
|
|
634
|
-
// 30 seconds
|
|
635
|
-
backoffMultiplier: 2
|
|
636
|
-
};
|
|
637
|
-
function sleep(ms) {
|
|
638
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
639
|
-
}
|
|
640
|
-
function calculateBackoffDelay(attempt, config, retryAfter) {
|
|
641
|
-
if (retryAfter !== void 0) {
|
|
642
|
-
const result2 = Math.min(retryAfter * 1e3, config.maxDelay);
|
|
643
|
-
console.log(
|
|
644
|
-
`Calculated backoff delay: ${result2}ms (attempt ${attempt + 1}), retryAfter: ${retryAfter}`
|
|
645
|
-
);
|
|
646
|
-
return result2;
|
|
647
|
-
}
|
|
648
|
-
const exponentialDelay = config.initialDelay * config.backoffMultiplier ** attempt;
|
|
649
|
-
const jitter = Math.random() * exponentialDelay;
|
|
650
|
-
const result = Math.min(exponentialDelay + jitter, config.maxDelay);
|
|
651
|
-
console.log(
|
|
652
|
-
`Calculated backoff delay: ${result}ms (attempt ${attempt + 1}), exponentialDelay: ${exponentialDelay}, jitter: ${jitter}`
|
|
653
|
-
);
|
|
654
|
-
return result;
|
|
655
|
-
}
|
|
656
|
-
async function withRetry(fn, config) {
|
|
657
|
-
const retryConfig = {
|
|
658
|
-
...DEFAULT_RETRY_CONFIG,
|
|
659
|
-
...config
|
|
660
|
-
};
|
|
661
|
-
let lastError;
|
|
662
|
-
for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt++) {
|
|
663
|
-
try {
|
|
664
|
-
return await fn();
|
|
665
|
-
} catch (error) {
|
|
666
|
-
lastError = error;
|
|
667
|
-
if (attempt === retryConfig.maxRetries) {
|
|
668
|
-
break;
|
|
669
|
-
}
|
|
670
|
-
if (!isRetryableError(error)) {
|
|
671
|
-
throw error;
|
|
672
|
-
}
|
|
673
|
-
const retryAfter = getRetryAfter(error);
|
|
674
|
-
const delay3 = calculateBackoffDelay(attempt, retryConfig, retryAfter);
|
|
675
|
-
if (typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
|
|
676
|
-
console.warn(
|
|
677
|
-
`Retry attempt ${attempt + 1}/${retryConfig.maxRetries} after ${delay3}ms`,
|
|
678
|
-
error
|
|
679
|
-
);
|
|
680
|
-
}
|
|
681
|
-
await sleep(delay3);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
throw lastError;
|
|
685
|
-
}
|
|
686
|
-
var RateLimiter = class {
|
|
687
|
-
constructor(maxTokens, refillRate) {
|
|
688
|
-
this.maxTokens = maxTokens;
|
|
689
|
-
this.refillRate = refillRate;
|
|
690
|
-
this.tokens = maxTokens;
|
|
691
|
-
this.lastRefill = Date.now();
|
|
692
|
-
}
|
|
693
|
-
tokens;
|
|
694
|
-
lastRefill;
|
|
695
|
-
/**
|
|
696
|
-
* Refills tokens based on time elapsed
|
|
697
|
-
*/
|
|
698
|
-
refill() {
|
|
699
|
-
const now = Date.now();
|
|
700
|
-
const timePassed = (now - this.lastRefill) / 1e3;
|
|
701
|
-
const tokensToAdd = timePassed * this.refillRate;
|
|
702
|
-
this.tokens = Math.min(this.maxTokens, this.tokens + tokensToAdd);
|
|
703
|
-
this.lastRefill = now;
|
|
704
|
-
}
|
|
705
|
-
/**
|
|
706
|
-
* Attempts to consume a token
|
|
707
|
-
* @returns true if token was consumed, false if rate limited
|
|
708
|
-
*/
|
|
709
|
-
tryConsume() {
|
|
710
|
-
this.refill();
|
|
711
|
-
if (this.tokens >= 1) {
|
|
712
|
-
this.tokens -= 1;
|
|
713
|
-
return true;
|
|
714
|
-
}
|
|
715
|
-
return false;
|
|
716
|
-
}
|
|
717
|
-
/**
|
|
718
|
-
* Waits until a token is available and consumes it
|
|
719
|
-
*/
|
|
720
|
-
async consume() {
|
|
721
|
-
while (!this.tryConsume()) {
|
|
722
|
-
const waitTime = 1 / this.refillRate * 1e3;
|
|
723
|
-
await sleep(waitTime);
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
/**
|
|
727
|
-
* Gets current number of available tokens
|
|
728
|
-
*/
|
|
729
|
-
getAvailableTokens() {
|
|
730
|
-
this.refill();
|
|
731
|
-
return Math.floor(this.tokens);
|
|
732
|
-
}
|
|
733
|
-
};
|
|
734
|
-
|
|
735
827
|
// src/api/helpers.ts
|
|
736
828
|
var PAGE_LINK_FIELDS = "sys,fields.cmsLabel,fields.title,fields.slug,fields.featuredImage,fields.backgroundColour,fields.textColour,fields.indexed,fields.hidden,fields.tags";
|
|
737
829
|
var ARTICLE_LINK_FIELDS = "sys,fields.cmsLabel,fields.title,fields.slug,fields.featuredImage,fields.backgroundColour,fields.textColour,fields.indexed,fields.hidden,fields.tags,fields.articleType,fields.date,fields.author";
|