@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 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 maxTokens;
617
- private readonly refillRate;
618
- private tokens;
619
- private lastRefill;
620
- constructor(maxTokens: number, refillRate: number);
621
- private refill;
622
- tryConsume(): boolean;
623
- consume(): Promise<void>;
624
- getAvailableTokens(): number;
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
- const response = await fetch(url, fetchOptions);
268
- if (!response.ok) {
269
- throw await parseErrorResponse(response);
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
- return response.json();
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";