Skip to content

Conversation

@alexjoverm
Copy link
Contributor

@alexjoverm alexjoverm commented Nov 20, 2025

Note

Implements a multi-queue throttle manager with dynamic rate limits (CDN/Management), parses server rate limit headers, integrates across client methods, and updates docs/tests.

  • Core rate limiting:
    • Introduces ThrottleQueueManager to run separate throttle queues per rate (e.g., 1000, 50, 15, 10, 6 req/s).
    • Adds rateLimit.ts with determineRateLimit, parseRateLimitHeaders, createRateLimitConfig, and MANAGEMENT_API_DEFAULT_RATE_LIMIT.
    • New constants: DEFAULT_PER_PAGE, PER_PAGE_THRESHOLDS for tiered uncached limits.
  • Client integration (src/index.ts):
    • Replaces single throttle with throttleManager and per-request rate selection via determineRateLimit.
    • Applies rate limiting to get (via cacheResponse) and Management API methods (post, put, delete).
    • Parses X-RateLimit(-Policy) response headers to adjust future limits.
    • For published requests, uses in-memory cache to bypass rate limiting when cached.
  • Documentation (README):
    • Adds detailed Rate Limiting section; updates rateLimit config option description and behavior notes.
  • Tests:
    • Adds comprehensive tests for rate limit tiers, header parsing, and multi-queue behavior (rateLimit.test.ts, throttleQueueManager.test.ts).
    • Updates existing tests to use throttleManager.execute; adds timing-based throttling scenarios in index.test.ts.
    • Minor test tweak for optional abort in throttlePromise.test.ts.

Written by Cursor Bugbot for commit 227642a. This will update automatically on new commits. Configure here.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is being reviewed by Cursor Bugbot

Details

Your team is on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle for each member of your team.

To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

const hasFindBy = 'find_by' in params;

return (isCdnStories && hasSpecificPath) || hasFindBy;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Trailing slash incorrectly identified as single story

The isSingleStoryRequest function incorrectly identifies listing endpoints with trailing slashes (like /cdn/stories/) as single story requests. When the URL is /cdn/stories/, the function returns true because url.split('/').length is 4 and !url.endsWith('/cdn/stories') is true (it ends with /cdn/stories/ not /cdn/stories). This causes listings to use the 50 req/s single-story rate limit instead of the appropriate tier based on per_page, potentially causing rate limit issues for users who include trailing slashes in their API calls.

Fix in Cursor Fix in Web

Copy link
Contributor

@maoberlehner maoberlehner left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some comments are technically wrong and some other minor comments. I let you decide if you want to fix them or not.

}

// For listings, determine tier based on per_page
const perPage = params!.per_page || 25; // Default per_page is 25
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (non-blocking): do we have this magic number 25 in some constant already that we could use here?

SINGLE_OR_SMALL: 50, // Single entries or listings ≤25 entries
MEDIUM: 15, // 25-50 entries
LARGE: 10, // 50-75 entries
VERY_LARGE: 6, // 75-100 entries
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (non-blocking): I think it should be 26-50, 51-75, and 76-100

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And it would be nice to have the values as constants here, too, instead of hardcoded below. Comments can get old, constants not.


// Should have created multiple queues for different rate limits (1000, 50, 15)
// @ts-expect-error - accessing private property for testing
expect(client.throttleManager.getQueueCount()).toBe(3);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): instead of inspecting the throttleManager itself, we could use vi.advanceTimersByTime(N) and check how often get was actually called for the different kinds of requests. That way we remove the coupling of the test to the concrete implementation.

it.only('should independently throttle different request types to their specific limits', async () => {
  vi.useFakeTimers();
  const limits = {
    published: 1000,
    small: 50,
    medium: 15,
    large: 10,
    veryLarge: 6,
  } as const;
  const attempts = {
    published: limits.published + 10,
    small: limits.small + 10,
    medium: limits.medium + 10,
    large: limits.large + 10,
    veryLarge: limits.veryLarge + 10,
  } as const;

  const promises = [];
  for (let i = 0; i < attempts.veryLarge; i++) {
    promises.push(
      client.get('cdn/stories', { version: 'draft', per_page: 76 }).catch(() => {}),
    );
  }
  for (let i = 0; i < attempts.large; i++) {
    promises.push(
      client.get('cdn/stories', { version: 'draft', per_page: 51 }).catch(() => {}),
    );
  }
  for (let i = 0; i < attempts.medium; i++) {
    promises.push(
      client.get('cdn/stories', { version: 'draft', per_page: 26 }).catch(() => {}),
    );
  }
  for (let i = 0; i < attempts.small; i++) {
    promises.push(
      client.get('cdn/stories', { version: 'draft', per_page: 25 }).catch(() => {}),
    );
  }
  for (let i = 0; i < attempts.published; i++) {
    promises.push(
      client.get('cdn/stories', { version: 'published' }).catch(() => {}),
    );
  }

  await vi.advanceTimersByTimeAsync(999);

  const countCallsContaining = (param: string, value: string | number) => {
    return client.client.get.mock.calls.filter(call => call[1][param] === value).length;
  };

  const veryLargeCalls = countCallsContaining('per_page', 76);
  expect(veryLargeCalls).toBe(limits.veryLarge);

  const largeCalls = countCallsContaining('per_page', 51);
  expect(largeCalls).toBe(limits.large);

  const mediumCalls = countCallsContaining('per_page', 26);
  expect(mediumCalls).toBe(limits.medium);

  const smallCalls = countCallsContaining('per_page', 25);
  expect(smallCalls).toBe(limits.small);

  const medium = countCallsContaining('version', 'published');
  expect(medium).toBe(limits.published);

  vi.useRealTimers();
});

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally agree. Def better when they're more input-output based. Thanks for the suggestion

- (`region` String, optional)
- (`https` Boolean, optional)
- (`rateLimit` Integer, optional, defaults to 3 for management api and 5 for cdn api)
- (`rateLimit` Integer, optional - Custom rate limit for **uncached requests only** (requests with `version=draft`). When not provided, the client automatically uses optimal rate limits based on the request type: 50 req/s for single entries or small listings (≤25 items), 15 req/s for medium listings (25-50 items), 10 req/s for large listings (50-75 items), and 6 req/s for very large listings (75-100 items). Cached requests (version=published) always use 1000 req/s. The maximum rate limit is capped at 1000 req/s. For Management API with `oauthToken`, defaults to 3 req/s for backward compatibility.)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (non-blocking): I think the ranges here are also not quite correct (26-50 and so on).

@pkg-pr-new
Copy link

pkg-pr-new bot commented Nov 24, 2025

Open in StackBlitz

@storyblok/astro

npm i https://pkg.pr.new/@storyblok/astro@380

storyblok

npm i https://pkg.pr.new/storyblok@380

@storyblok/eslint-config

npm i https://pkg.pr.new/@storyblok/eslint-config@380

@storyblok/js

npm i https://pkg.pr.new/@storyblok/js@380

storyblok-js-client

npm i https://pkg.pr.new/storyblok-js-client@380

@storyblok/management-api-client

npm i https://pkg.pr.new/@storyblok/management-api-client@380

@storyblok/nuxt

npm i https://pkg.pr.new/@storyblok/nuxt@380

@storyblok/react

npm i https://pkg.pr.new/@storyblok/react@380

@storyblok/region-helper

npm i https://pkg.pr.new/@storyblok/region-helper@380

@storyblok/richtext

npm i https://pkg.pr.new/@storyblok/richtext@380

@storyblok/svelte

npm i https://pkg.pr.new/@storyblok/svelte@380

@storyblok/vue

npm i https://pkg.pr.new/@storyblok/vue@380

commit: 227642a

@alexjoverm alexjoverm merged commit c8e42d7 into main Nov 24, 2025
6 checks passed
@alexjoverm alexjoverm deleted the feature/multi-queued-rate-limit branch November 24, 2025 11:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants