*/ private const EXEMPT_ROUTES = array( '/aicommerce/v1/status', '/aicommerce/v1/manifest', '/aicommerce/v1/schema', ); /** * Filter callback for rest_pre_dispatch. * * Returns null to allow the request to proceed, or a WP_REST_Response * with 429 status to block it. * * @param mixed $result Response to replace the requested version with (null by default). * @param \WP_REST_Server $server Server instance. * @param \WP_REST_Request $request Request used to generate the response. * * @return mixed|WP_REST_Response */ public function maybe_limit( $result, \WP_REST_Server $server, \WP_REST_Request $request ) { // Don't interfere if something already short-circuited. if ( null !== $result ) { return $result; } $route = $request->get_route(); // Only limit our own namespace. if ( ! str_starts_with( $route, '/' . self::REST_NAMESPACE ) ) { return $result; } // Exempt specific routes. foreach ( self::EXEMPT_ROUTES as $exempt ) { if ( $route === $exempt ) { return $result; } } $identifier = $this->get_identifier(); $bucket = (string) floor( time() / self::WINDOW_SECONDS ); $key = 'aicommerce_rl_' . md5( $identifier . $bucket ); $count = (int) get_transient( $key ); $remaining = max( 0, self::MAX_REQUESTS - $count - 1 ); $reset = ( (int) $bucket + 1 ) * self::WINDOW_SECONDS; if ( $count >= self::MAX_REQUESTS ) { $retry_after = $reset - time(); $response = ErrorFormatter::rate_limited( max( 1, $retry_after ) ); $response->header( 'X-RateLimit-Limit', (string) self::MAX_REQUESTS ); $response->header( 'X-RateLimit-Remaining', '0' ); $response->header( 'X-RateLimit-Reset', (string) $reset ); return $response; } // Increment counter. set_transient( $key, $count + 1, self::WINDOW_SECONDS ); // Add rate limit headers via filter on the response. $max = self::MAX_REQUESTS; add_filter( 'rest_post_dispatch', static function ( \WP_REST_Response $response ) use ( $max, $remaining, $reset ): \WP_REST_Response { $response->header( 'X-RateLimit-Limit', (string) $max ); $response->header( 'X-RateLimit-Remaining', (string) $remaining ); $response->header( 'X-RateLimit-Reset', (string) $reset ); return $response; } ); return $result; } /** * Get the identifier for rate limiting. * * Uses user ID for authenticated requests, IP for anonymous. * * @return string */ private function get_identifier(): string { $user_id = get_current_user_id(); if ( $user_id > 0 ) { return 'user_' . $user_id; } $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; return 'ip_' . $ip; } }