@se-studio/contentful-rest-api 1.0.20 → 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 +190 -134
- 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,19 +360,15 @@ function buildQueryString(query) {
|
|
|
212
360
|
});
|
|
213
361
|
return params.toString();
|
|
214
362
|
}
|
|
215
|
-
function
|
|
363
|
+
function sleep2(ms) {
|
|
216
364
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
217
365
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
const jitter = Math.random() * baseDelay;
|
|
226
|
-
return Math.min(baseDelay + jitter, 15e3);
|
|
227
|
-
}
|
|
366
|
+
var RETRY_CONFIG = {
|
|
367
|
+
maxRetries: 10,
|
|
368
|
+
initialDelay: 1e3,
|
|
369
|
+
maxDelay: 15e3,
|
|
370
|
+
backoffMultiplier: 2
|
|
371
|
+
};
|
|
228
372
|
function parseRetryAfter(response) {
|
|
229
373
|
const retryAfterHeader = response.headers.get("X-Contentful-RateLimit-Reset") || response.headers.get("Retry-After");
|
|
230
374
|
if (retryAfterHeader) {
|
|
@@ -264,14 +408,25 @@ async function parseErrorResponse(response) {
|
|
|
264
408
|
var ContentfulFetchClient = class {
|
|
265
409
|
baseUrl;
|
|
266
410
|
accessToken;
|
|
411
|
+
rateLimiter;
|
|
412
|
+
isPreview;
|
|
267
413
|
constructor(config, preview = false) {
|
|
268
414
|
const host = config.host || (preview ? "preview.contentful.com" : "cdn.contentful.com");
|
|
269
415
|
const environment = config.environment || "master";
|
|
270
416
|
this.baseUrl = `https://${host}/spaces/${config.spaceId}/environments/${environment}`;
|
|
271
417
|
this.accessToken = config.accessToken;
|
|
418
|
+
this.isPreview = preview;
|
|
419
|
+
this.rateLimiter = getRateLimiter(preview);
|
|
272
420
|
}
|
|
273
421
|
/**
|
|
274
|
-
* Fetches entries from Contentful with
|
|
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.
|
|
275
430
|
*/
|
|
276
431
|
async getEntries(query, options) {
|
|
277
432
|
const queryString = buildQueryString(query);
|
|
@@ -285,26 +440,34 @@ var ContentfulFetchClient = class {
|
|
|
285
440
|
if (options?.next) {
|
|
286
441
|
fetchOptions.next = options.next;
|
|
287
442
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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);
|
|
298
453
|
console.warn(
|
|
299
|
-
`[Contentful] Rate limited, retrying in ${delay3}ms (attempt ${attempt + 1}/${maxRetries})`
|
|
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"}`
|
|
300
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;
|
|
301
465
|
}
|
|
302
|
-
|
|
303
|
-
|
|
466
|
+
console.error("[Contentful] Unexpected error in request", error);
|
|
467
|
+
throw new ContentfulError("Unexpected error in request", 500);
|
|
304
468
|
}
|
|
305
|
-
throw await parseErrorResponse(response);
|
|
306
469
|
}
|
|
307
|
-
throw new ContentfulError("
|
|
470
|
+
throw new ContentfulError("Max retries exceeded", 500);
|
|
308
471
|
}
|
|
309
472
|
};
|
|
310
473
|
function createContentfulClient(config) {
|
|
@@ -661,113 +824,6 @@ function safeDate(date) {
|
|
|
661
824
|
return new Date(date);
|
|
662
825
|
}
|
|
663
826
|
|
|
664
|
-
// src/utils/retry.ts
|
|
665
|
-
var DEFAULT_RETRY_CONFIG = {
|
|
666
|
-
maxRetries: 3,
|
|
667
|
-
initialDelay: 1e3,
|
|
668
|
-
// 1 second
|
|
669
|
-
maxDelay: 3e4,
|
|
670
|
-
// 30 seconds
|
|
671
|
-
backoffMultiplier: 2
|
|
672
|
-
};
|
|
673
|
-
function sleep2(ms) {
|
|
674
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
675
|
-
}
|
|
676
|
-
function calculateBackoffDelay(attempt, config, retryAfter) {
|
|
677
|
-
if (retryAfter !== void 0) {
|
|
678
|
-
const result2 = Math.min(retryAfter * 1e3, config.maxDelay);
|
|
679
|
-
console.log(
|
|
680
|
-
`Calculated backoff delay: ${result2}ms (attempt ${attempt + 1}), retryAfter: ${retryAfter}`
|
|
681
|
-
);
|
|
682
|
-
return result2;
|
|
683
|
-
}
|
|
684
|
-
const exponentialDelay = config.initialDelay * config.backoffMultiplier ** attempt;
|
|
685
|
-
const jitter = Math.random() * exponentialDelay;
|
|
686
|
-
const result = Math.min(exponentialDelay + jitter, config.maxDelay);
|
|
687
|
-
console.log(
|
|
688
|
-
`Calculated backoff delay: ${result}ms (attempt ${attempt + 1}), exponentialDelay: ${exponentialDelay}, jitter: ${jitter}`
|
|
689
|
-
);
|
|
690
|
-
return result;
|
|
691
|
-
}
|
|
692
|
-
async function withRetry(fn, config) {
|
|
693
|
-
const retryConfig = {
|
|
694
|
-
...DEFAULT_RETRY_CONFIG,
|
|
695
|
-
...config
|
|
696
|
-
};
|
|
697
|
-
let lastError;
|
|
698
|
-
for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt++) {
|
|
699
|
-
try {
|
|
700
|
-
return await fn();
|
|
701
|
-
} catch (error) {
|
|
702
|
-
lastError = error;
|
|
703
|
-
if (attempt === retryConfig.maxRetries) {
|
|
704
|
-
break;
|
|
705
|
-
}
|
|
706
|
-
if (!isRetryableError(error)) {
|
|
707
|
-
throw error;
|
|
708
|
-
}
|
|
709
|
-
const retryAfter = getRetryAfter(error);
|
|
710
|
-
const delay3 = calculateBackoffDelay(attempt, retryConfig, retryAfter);
|
|
711
|
-
if (typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
|
|
712
|
-
console.warn(
|
|
713
|
-
`Retry attempt ${attempt + 1}/${retryConfig.maxRetries} after ${delay3}ms`,
|
|
714
|
-
error
|
|
715
|
-
);
|
|
716
|
-
}
|
|
717
|
-
await sleep2(delay3);
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
throw lastError;
|
|
721
|
-
}
|
|
722
|
-
var RateLimiter = class {
|
|
723
|
-
constructor(maxTokens, refillRate) {
|
|
724
|
-
this.maxTokens = maxTokens;
|
|
725
|
-
this.refillRate = refillRate;
|
|
726
|
-
this.tokens = maxTokens;
|
|
727
|
-
this.lastRefill = Date.now();
|
|
728
|
-
}
|
|
729
|
-
tokens;
|
|
730
|
-
lastRefill;
|
|
731
|
-
/**
|
|
732
|
-
* Refills tokens based on time elapsed
|
|
733
|
-
*/
|
|
734
|
-
refill() {
|
|
735
|
-
const now = Date.now();
|
|
736
|
-
const timePassed = (now - this.lastRefill) / 1e3;
|
|
737
|
-
const tokensToAdd = timePassed * this.refillRate;
|
|
738
|
-
this.tokens = Math.min(this.maxTokens, this.tokens + tokensToAdd);
|
|
739
|
-
this.lastRefill = now;
|
|
740
|
-
}
|
|
741
|
-
/**
|
|
742
|
-
* Attempts to consume a token
|
|
743
|
-
* @returns true if token was consumed, false if rate limited
|
|
744
|
-
*/
|
|
745
|
-
tryConsume() {
|
|
746
|
-
this.refill();
|
|
747
|
-
if (this.tokens >= 1) {
|
|
748
|
-
this.tokens -= 1;
|
|
749
|
-
return true;
|
|
750
|
-
}
|
|
751
|
-
return false;
|
|
752
|
-
}
|
|
753
|
-
/**
|
|
754
|
-
* Waits until a token is available and consumes it
|
|
755
|
-
*/
|
|
756
|
-
async consume() {
|
|
757
|
-
while (!this.tryConsume()) {
|
|
758
|
-
const waitTime = 1 / this.refillRate * 1e3;
|
|
759
|
-
await sleep2(waitTime);
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
/**
|
|
763
|
-
* Gets current number of available tokens
|
|
764
|
-
*/
|
|
765
|
-
getAvailableTokens() {
|
|
766
|
-
this.refill();
|
|
767
|
-
return Math.floor(this.tokens);
|
|
768
|
-
}
|
|
769
|
-
};
|
|
770
|
-
|
|
771
827
|
// src/api/helpers.ts
|
|
772
828
|
var PAGE_LINK_FIELDS = "sys,fields.cmsLabel,fields.title,fields.slug,fields.featuredImage,fields.backgroundColour,fields.textColour,fields.indexed,fields.hidden,fields.tags";
|
|
773
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";
|