@se-studio/contentful-rest-api 1.0.20 → 1.0.22

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,19 +360,15 @@ function buildQueryString(query) {
212
360
  });
213
361
  return params.toString();
214
362
  }
215
- function sleep(ms) {
363
+ function sleep2(ms) {
216
364
  return new Promise((resolve) => setTimeout(resolve, ms));
217
365
  }
218
- function calculateRetryDelay(attempt, retryAfterSeconds) {
219
- let baseDelay;
220
- if (retryAfterSeconds !== void 0 && retryAfterSeconds > 0) {
221
- baseDelay = retryAfterSeconds * 1e3;
222
- } else {
223
- baseDelay = 1e3 * 2 ** attempt;
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 automatic retry on rate limit errors
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
- const maxRetries = 3;
289
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
290
- const response = await fetch(url, fetchOptions);
291
- if (response.ok) {
292
- return response.json();
293
- }
294
- if (response.status === 429 && attempt < maxRetries) {
295
- const retryAfter = parseRetryAfter(response);
296
- const delay3 = calculateRetryDelay(attempt, retryAfter);
297
- if (typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
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
- await sleep(delay3);
303
- continue;
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("Unexpected error in retry loop", 500);
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";
@@ -1671,6 +1727,34 @@ function baseCustomTypeConverter(context, entry) {
1671
1727
  };
1672
1728
  return customType;
1673
1729
  }
1730
+ function baseCustomTypeLinkConverter(context, entry) {
1731
+ const {
1732
+ sys: { id, contentType },
1733
+ fields
1734
+ } = entry;
1735
+ if (contentType.sys.id !== "customType") {
1736
+ throw new Error(`Invalid content type: expected "customType", got "${contentType.sys.id}"`);
1737
+ }
1738
+ return createInternalLink(
1739
+ id,
1740
+ {
1741
+ cmsLabel: fields.name,
1742
+ title: fields.name,
1743
+ featuredImage: fields.featuredImage,
1744
+ backgroundColour: fields.backgroundColour,
1745
+ textColour: fields.textColour,
1746
+ indexed: fields.indexed,
1747
+ hidden: fields.hidden,
1748
+ slug: fields.indexPageSlug
1749
+ },
1750
+ context,
1751
+ calculateCustomTypeHref(fields.indexPageSlug),
1752
+ "CustomType"
1753
+ );
1754
+ }
1755
+ function calculateCustomTypeHref(slug) {
1756
+ return `/${slug}/`;
1757
+ }
1674
1758
 
1675
1759
  // src/converters/link.ts
1676
1760
  function baseLinkConverter(context, entry) {
@@ -1960,6 +2044,7 @@ function createBaseConverterContext() {
1960
2044
  linkResolver.set("person", basePersonLinkConverter);
1961
2045
  linkResolver.set("pageVariant", basePageVariantLinkConverter);
1962
2046
  linkResolver.set("link", baseLinkConverter);
2047
+ linkResolver.set("customType", baseCustomTypeLinkConverter);
1963
2048
  const contentResolver = /* @__PURE__ */ new Map();
1964
2049
  contentResolver.set("collection", baseCollectionConverter);
1965
2050
  contentResolver.set("component", baseComponentConverter);