Async at Scale

Rate Limiting AI Traffic at Scale

⏱ 17 min

Course 4’s rate-limiting lesson built one transient-based counter, bucketed by user and a fixed window, and wired it into a single ability’s permission_callback. That’s the right building block, and it’s still correct. What it doesn’t cover is what happens at real production scale: a limit that only looks at one user misses the case where a hundred different users each stay under their own limit while collectively hammering your model provider into a bill you didn’t plan for. And a hard rejection once a limit is hit is a blunt tool, a queued retry or a cached fallback is often the better answer than a flat error. This lesson extends the pattern in both directions: a global ceiling alongside the per-user one, and graceful degradation instead of a wall.

What you'll learn in this lesson
Per-user and global limits, checked together
Two independent transient counters, both consulted before a call is allowed.
Why a global ceiling matters even when every user is individually within limits
Aggregate cost and provider throughput are shared resources, not per-user ones.
Graceful degradation over a hard 429
Falling back to a cached response, or queueing via Action Scheduler, instead of just refusing.
Communicating the degraded state honestly
Telling the caller (or the user) what actually happened, not silently serving something else.
Prerequisites

Course 4’s rate-limiting lesson (the transient-counter pattern and wiring it into permission_callback), the caching lessons earlier in this course, and Action Scheduler from the previous lesson, since graceful degradation in Step 3 uses both.

Step 1: One counter isn’t enough at real traffic volume

The Course 4 counter answers “has this user exceeded their own limit.” It says nothing about the site as a whole. A hundred users each making nineteen calls inside their twenty-call limit never trips that check even once, while the site as a whole just made 1,900 model calls in that window, a real cost and a real load on whatever the model provider allows concurrently. Production rate limiting needs both a per-user bucket and a site-wide one, checked together:

// File: my-plugin/class-rate-limits.php
function my_plugin_check_rate_limits( int $user_id, string $bucket_name, int $user_limit, int $global_limit, int $window_seconds ) {
	$window_key = floor( time() / $window_seconds );

	$user_key   = "my_plugin_rl_user_{$bucket_name}_{$user_id}_{$window_key}";
	$global_key = "my_plugin_rl_global_{$bucket_name}_{$window_key}";

	$user_count   = (int) get_transient( $user_key );
	$global_count = (int) get_transient( $global_key );

	if ( $user_count >= $user_limit ) {
		return 'user_limited';
	}

	if ( $global_count >= $global_limit ) {
		return 'global_limited';
	}

	set_transient( $user_key, $user_count + 1, $window_seconds );
	set_transient( $global_key, $global_count + 1, $window_seconds );

	return 'allowed';
}

Returning a distinct reason string, rather than just true/false, matters for Step 3: a user hitting their own limit and the whole site hitting its shared ceiling call for different responses.

Same fixed-window approach as Course 4, extended, not replaced

This is still a fixed time-bucket counter, the same practical tradeoff Course 4 chose over a true sliding-window log. Adding a global bucket alongside the per-user one is a scope change, not a mechanism change, the underlying transient-counter pattern is unchanged.

Step 2: Wire both checks into the call site

// File: my-plugin.php
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'my-plugin/generate-product-copy',
		array(
			// ...label, input_schema...
			'permission_callback' => function () {
				if ( ! current_user_can( 'my_plugin_generate_copy' ) ) {
					return false;
				}

				$status = my_plugin_check_rate_limits(
					get_current_user_id(),
					'product_copy',
					20,   // 20 calls per user
					500,  // 500 calls site-wide
					60    // per 60-second window
				);

				if ( 'allowed' !== $status ) {
					return new WP_Error(
						'rate_limited',
						'user_limited' === $status
							? 'You have exceeded your call limit for this action. Try again shortly.'
							: 'This feature is at capacity site-wide right now. Try again shortly.',
						array( 'status' => 429 )
					);
				}

				return true;
			},
			'execute_callback' => function ( $input ) {
				// generation logic
			},
		)
	);
} );

The distinct error messages matter for whoever’s debugging a complaint later: a user hitting their own cap is a different situation from the whole site being saturated, and conflating them into one generic message hides which one actually happened.

Step 3: Degrade gracefully instead of only rejecting

A flat rejection is the right response when there’s genuinely nothing better to do. But for some features, there’s a reasonable fallback that’s better than a hard error. Two practical options, building directly on earlier lessons in this course:

Serve a cached response if one exists, even a slightly stale one
For a feature already using the caching pattern from Lesson 1, an over-limit request can check the cache one more time before failing outright, since a slightly-stale cached answer usually beats an error.
Queue the request instead of rejecting it
For a feature that does not need an immediate answer, use as_enqueue_async_action() from the previous lesson to run the call once the window resets, rather than making the caller retry manually.
// File: my-plugin/class-rate-limits.php
function my_plugin_generate_with_degradation( string $feature, string $prompt, string $system_instruction, int $user_id ) {
	$status = my_plugin_check_rate_limits( $user_id, $feature, 20, 500, 60 );

	if ( 'allowed' === $status ) {
		return my_plugin_generate_text_cached( $prompt, $system_instruction, DAY_IN_SECONDS );
	}

	// Over limit: try the cache one more time before giving up.
	$key    = my_plugin_ai_cache_key( $prompt, $system_instruction );
	$cached = my_plugin_get_cached_ai_response( $key );

	if ( null !== $cached ) {
		return $cached;
	}

	// No cached fallback available: queue it for later instead of a hard failure.
	as_enqueue_async_action( 'my_plugin_generate_deferred', array( $feature, $prompt, $system_instruction, $user_id ), 'my-plugin-ai' );

	return new WP_Error(
		'rate_limited_deferred',
		'This request has been queued and will complete shortly rather than right now.',
		array( 'status' => 202 )
	);
}

A 202 Accepted with a clear message is a materially better experience than a flat 429, for the class of feature where the caller doesn’t need the answer in this exact request.

Graceful degradation must still be honest about what happened

Never silently serve a stale cached response and pretend it’s fresh, and never accept a queued request without telling the caller it was deferred. An MCP client or a human user making a decision based on a response deserves to know whether what they got is live, cached, or still pending, since that materially affects how much they should trust it in the moment.

Test it

Push a bucket over its per-user limit and confirm the response distinguishes it from a global limit, then push a separate low per-user, low global limit to confirm the global branch fires too:

wp-env run cli wp eval '
for ( $i = 0; $i < 25; $i++ ) {
	$status = my_plugin_check_rate_limits( 1, "test_bucket", 20, 500, 60 );
	echo $i . ": " . $status . "\n";
}
'

You should see allowed for the first 20 calls and user_limited after that. Lower the global limit below 20 in a second run to confirm global_limited fires instead once the shared ceiling, not the per-user one, is what’s actually exceeded.

Recap

A single per-user counter, the Course 4 pattern, misses aggregate load across many users who are each individually within limits. Checking a per-user bucket and a site-wide bucket together closes that gap, and returning a distinct reason for each keeps the response meaningful instead of a single generic denial. Where a reasonable fallback exists, a cached response one lesson back, a queued job two lessons back, degrade gracefully rather than only rejecting, but always tell the caller honestly what actually happened.

Resources & further reading

← Async and Background Processing With Action Scheduler Real Performance Monitoring Under Agent Traffic →