Skip to content

ThrottleMiddleware

Viames Marino edited this page Oct 19, 2026 · 7 revisions

Pair framework: ThrottleMiddleware

Pair\Api\ThrottleMiddleware applies API rate limiting through RateLimiter.

It is the middleware you use when an endpoint must be protected from:

  • burst traffic
  • client retry storms
  • abuse on public or semi-public routes

ApiController already registers one default throttle middleware when PAIR_API_RATE_LIMIT_ENABLED=true.

Constructor

new ThrottleMiddleware(int $maxAttempts = 60, int $decaySeconds = 60)

Example:

/**
 * Replace the default throttle with one controller-specific policy.
 */
protected function registerDefaultMiddleware(): void {
	$this->middleware(new \Pair\Api\ThrottleMiddleware(120, 60));
}

Main method

handle(Request $request, callable $next): mixed

This is the main method of the class.

Current flow:

  1. resolves the best available identity key
  2. calls RateLimiter::attempt() atomically
  3. applies X-RateLimit-* headers
  4. if blocked, returns an explicit ApiErrorResponse with TOO_MANY_REQUESTS and HTTP 429
  5. otherwise forwards the request to the next middleware or action

Because the headers are applied before the decision is returned, clients receive rate-limit metadata on both successful and blocked requests.

Identity strategy

The middleware builds the key from the most stable identity available:

  1. sid query parameter or X-Pair-Session header
  2. bearer token
  3. authenticated user
  4. client IP (Request::ip())

Sensitive credentials such as sid and bearer tokens are hashed before they are used in the storage key.

Examples of generated logical key shapes:

  • throttle:session:<sha256>
  • throttle:bearer:<sha256>
  • throttle:user:42
  • throttle:ip:203.0.113.7

Integration example

protected function _init(): void
{
    parent::_init();

    // The default throttle is already present after parent::_init().
    $this->middleware(new \Pair\Api\CorsMiddleware());
}

/**
 * Return users after the runtime performs the throttle check once.
 */
public function usersAction(): \Pair\Http\JsonResponse {
	return $this->jsonResponse(['users' => []]);
}

Use a stricter policy for a sensitive controller

/**
 * Register one stricter throttle for this controller.
 */
protected function registerDefaultMiddleware(): void {
	$this->middleware(new \Pair\Api\ThrottleMiddleware(10, 60));
}

This replaces the default limiter. Built-in throttle instances share identity keys, so appending a second one consumes multiple hits from the same bucket. Independent global and route limits require custom middleware with distinct key scopes.

Pair's runtime already executes middleware before invoking the action. Do not call runMiddleware() again inside the action.

Custom ordering example

If you need CORS to run before throttling, override the default middleware registration order:

protected function registerDefaultMiddleware(): void
{
    // Runs CORS first for this controller family.
    $this->middleware(new \Pair\Api\CorsMiddleware());

    // Runs the throttle after CORS.
    $this->middleware(new \Pair\Api\ThrottleMiddleware(60, 60));
}

Example 429 response

HTTP/1.1 429 Too Many Requests
Retry-After: 15
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1710842892

Example client handling

// Performs one request against a throttled endpoint.
const response = await fetch('/api/users', {
  headers: { 'X-Pair-Session': sessionId }
});

if (response.status === 429) {
  // Reads the recommended retry delay in seconds.
  const retryAfter = response.headers.get('Retry-After');

  // Reads the reset timestamp for diagnostics.
  const resetAt = response.headers.get('X-RateLimit-Reset');
}

Secondary notes worth knowing

Most of the throttling behavior is delegated to RateLimiter, but these implementation details matter:

  • the middleware resolves the key privately through resolveKey(...)
  • successful requests still receive the rate-limit headers
  • authenticated users use user:<id> keys instead of hashed ids
  • API sessions can be keyed from either the legacy sid query parameter or the safer X-Pair-Session header

Notes and caveats

  • Redis is used automatically when configured.
  • File storage remains the fallback for single-node or degraded mode.
  • Request::ip() is proxy-aware only when trusted proxies are configured correctly.
  • Replace the default throttle when changing its policy; additional built-in instances share its storage key and count hits again.

See also: RateLimiter, AuthAttemptLimiter, Middleware, MiddlewarePipeline, Request, CorsMiddleware.

Clone this wiki locally