Rate-Limit Incoming WordPress API Calls

Rate-limit WordPress api calls

Create a custom plugin to control how frequently your WordPress API gets hit by external clients. Useful if someone is making too many API calls to your site, and you want to force them to slow down a bit. Reduce your costs by taking control of your site’s bandwidth and CPU usage.

Understand the problem

A client of mine’s WooCommerce site is connected to an external order-processing system. This external system polls the site’s REST API for product info, but we don’t have control of how often the system makes requests. The external service was polling our API roughly every 5 seconds, which is way too frequently.

Each API response is about 1MB. So with one API request & response every 5 seconds, we were sending…

  • 12MB per minute
  • 720MB per hour
  • 17GB per day!

This was causing a huge volume of upload traffic which lead to increased hosting costs for the client. Not good 🙁

So we had to create some code to…

  • detect when an API request is coming in
  • find out where the API call has come from (the client IP address)
  • if the client IP address is subject to rate-limiting, then…
    • if the client has made an API call within the last 10 seconds (configurable), then…
      • return HTTP Response 429 (Too many requests)
    • else…
      • let the API call request run normally
      • create a short-term WordPress transient that lasts for 10 seconds, tied to this client’s IP address

Create a small WordPress plugin

On your computer, create a folder called “api-rate-limiter”, go into this folder and create a file called “api-rate-limiter.php”. Paste the following code into it:

<?php

/**
 * Plugin Name:  API Rate Limiter
 * Plugin URI:   https://wp-tutorials.tech/optimise-wordpress/rate-limit-wordpress-api-calls/
 * description:  Rate-limit API calls by Client IP address
 * Version:      1.0.1
 * Author:       Headwall WP Tutorials
 * Author URI:   https://wp-tutorials.tech/
 * License:      GPLv2 or later
 * License URI:  http://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:  api-rate-limiter
 */

defined('WPINC') || die();

/**
 * How many seconds should there be between successive API calls from
 * rate-limited clients.
 */
const WPTARL_SECONDS_BETWEEN_GUEST_API_CALLS = 10;

/**
 * A list of client IP addresses that should be rate-limited. If you want to
 * rate-limit all (non-logged-in) API calls then set this to an empty array.
 */
const WPTARL_RATE_LIMITED_IPS = array('123.123.123.123'); // << CHANGE THIS

/**
 * IP addresses that should never be rate-limited (localhost).
 */
const WPTARL_NEVER_RATE_LIMITED_IPS = array(
	'127.0.0.1',
	'::1'
);

/**
 * Check multiple $_SERVER elements to find the remote client's IP address.
 *
 * @return null|string Remote client IP address
 */
function wptarl_client_ip() {
	global $wptarl_client_ip;

	if (is_null($wptarl_client_ip)) {
		$server_vars = array(
			'HTTP_CLIENT_IP',
			'HTTP_X_FORWARDED_FOR',
			'REMOTE_ADDR',
		);

		foreach ($server_vars as $server_var) {
			if (!array_key_exists($server_var, $_SERVER)) {
				// The server variable isn't set - do nothing.
			} elseif (empty($wptarl_client_ip = filter_var($_SERVER[$server_var], FILTER_VALIDATE_IP))) {
				// The IP address is not valid - do nothing.
			} else {
				// We've got a valid IP address in the global variable $wptarl_client_ip,
				// so we can "break" out of the foreach(...) loop here.
				break;
			}
		}

		// Make sure we don't leave something like an empty string or "false"
		// in $wptarl_client_ip
		if (empty($wptarl_client_ip)) {
			$wptarl_client_ip = null;
		}
	}

	return $wptarl_client_ip;
}

/**
 * When the WordPress REST API is initialised, check to see if the request
 * should be blocked, or allowed to execute normally.
 */
function wptarl_rest_api_init(WP_REST_Server $wp_rest_server) {
	$is_client_rate_limited = false;
	$transient_key = null;

	// Determine if the client that's making the API requests is
	// subject to rate-limiting.
	if (empty($client_ip = wptarl_client_ip())) {
		// We don't know the client's IP address so we probably don't want to do
		// anything here.
	} elseif (!empty(WPTARL_NEVER_RATE_LIMITED_IPS) && in_array($client_ip, WPTARL_NEVER_RATE_LIMITED_IPS)) {
		// Never rate-limit IP addresses in the WPTARL_NEVER_RATE_LIMITED_IPS array.
	} else {
		$transient_key = 'wptarl_' . $client_ip;
		$rate_limited_ips = apply_filters('wptarl_rate_limited_ips', WPTARL_RATE_LIMITED_IPS);

		if (!empty($rate_limited_ips)) {
			$is_client_rate_limited = in_array($client_ip, $rate_limited_ips);
		} else {
			$is_client_rate_limited = !is_user_logged_in();
		}

		$is_client_rate_limited = (bool)apply_filters('wptarl_is_client_rate_limited', $is_client_rate_limited);
	}

	if (!$is_client_rate_limited) {
		// The client is not rate-limited - do nothing
	} elseif (empty($transient_key)) {
		// If we couldn't figure out the transient key - do nothing
	} elseif (empty(get_transient($transient_key))) {
		// This client IP does not have a transient record, so it has not made
		// an API call recently - let the API call execute normally.

		// Create a transient record that will expire in a few seconds time.
		$seconds_between_api_calls = intval(apply_filters('wptarl_seconds_between_api_calls', WPTARL_SECONDS_BETWEEN_GUEST_API_CALLS, $client_ip));
		if ($seconds_between_api_calls > 0) {
			// We only need the transient record to exist, we don't actually
			// care what the value of the record is, so we've set it to '1'
			set_transient(
				$transient_key,
				'1',
				$seconds_between_api_calls
			);
		}
	} else {
		// A JSON message to send back to the client.
		$response = array(
			'clientIp' => $client_ip,
			'message' => 'Slow down your API calls',
		);

		// HTTP Response 429 => "Too many requests"
		wp_send_json(
			$response,
			429
		);
	}

}
add_action('rest_api_init', 'wptarl_rest_api_init', 10, 1);

The key to blocking API calls for a short time (like 10 seconds) is to use a transient. This is a short-term site-wide global object identified by a unique “key”. So if we detect an incoming API request from a client with the IP address “123.123.123.123”, we create a key called “wptarl_123.123.123.123” and set it to a value of “1”. We’re not going to use the actual value so you can actually set it to anything you want. We just need the transient record to exist for 10 seconds and then disappear. If we detect that the transient already exists, the client must have made an API call within the last 10 seconds… so we can block the request and return an appropriate response code.

When a server returns data to a client (e.g. a browser), there’s a HTTP Response Code that describes the type of response. Common response codes are:

  • 200 – OK
  • 301 – Moved Permanently
  • 404 – Not Found
  • 429 – Too Many Requests
  • 503 – Service Unavailable

When we block a rate-limited client for making too many requests, we’re going to return 429 – Too Many Requests.

Upload & activate the plugin

Now we need to zip up the plugin and upload it to the site.

  1. Right-click the “api-rate-limiter” folder and create a zip file called “api-rate-limiter-1.0.0.zip
  2. In the back-end of your site, go to plugins > Add New then upload your zip file
  3. After uploading the zip file, activate the plugin
Upload the API Rate Limiter plugin
Upload and activate your custom plugin

Configure rate-limiting

There are two “modes” the plugin can work in:

  1. All requests from one or more IP addresses are rate-limited.
    Set the IP addresses in the WPTARL_RATE_LIMITED_IPS array at the top of the file
  2. All non-logged-in API requests are rate-limited.
    Set WPTARL_RATE_LIMITED_IPS to an empty array

After you’ve defined WPTARL_RATE_LIMITED_IPS, choose how many seconds you want to leave between API call requests (for rate-limited clients).

// No more than one API call per minute
const WPTARL_SECONDS_BETWEEN_GUEST_API_CALLS = MINUTE_IN_SECONDS;
// No more than one API call every ten seconds
const WPTARL_SECONDS_BETWEEN_GUEST_API_CALLS = 10;

Test the API rate-limiting

To make sure rate-limiting is working properly, we’ll use a command-line tool called HTTPie. It’s a bit like cUrl but I prefer the syntax, and the documentation is really slick. When you’ve installed it, there are two new command-lines tools you can use, “http” and “https”. These let you fetch any URL with or without the response headers.

For this test, set WPTARL_RATE_LIMITED_IPS to an empty array so that all guest REST API requests are subject to rate-limiting. If your project only needs to rate-limit specific IP addresses, add them back into the array when you’ve finished testing.

For the REST API URL, we’ll test against the built-in WordPress endpoint to enumerate users like this:

# Request a list of website users via the built-in WordPress REST API
# The -h parameters tells HTTPie that we only want the response headers (i.e. we don't want the response body)
https -h wp-tutorials.tech/wp-json/wp/v2/users

Note: If you try to enumerate the users on this site you’ll get a 404 response, because I block user enumeration API calls.

You should see something like this as your response headers:

REST API HTTP Response 200 OK
HTTP Response 200 OK

Now if you try that again in quick succession, you should see the rate-limiting working:

HTTP/1.1 429 Too Many Requests

Wait for 10 seconds (or whatever you’ve set in WPTARL_SECONDS_BETWEEN_GUEST_API_CALLS) and try again. You should get a regular “200 OK” response again.

That’s all there is to it – retake control of your site’s bandwidth 😎 👍

Like This Tutorial?

Let us know

WordPress plugins for developers

3 thoughts on “Rate-Limit Incoming WordPress API Calls”

  1. Hello Paul. This one is great tutorial! And great logic behind!
    As always love to see your code and analyze it, seeking new ways to implement some missing WordPress default stuff. There is always some way to tweak it to your needs. Said that just little personal consideration after 3 years spent on WordPress: I’m started to use laravel for really custom made stuff and it’s incredibly powerfull since many important things comes as modules (Redis cache / Auth / locales (language parts)). You basically can develop everything without paying a single euro for a plugin and documentation is awesome. Said that with your knowledge of php and server configuration I truly wanna see your deep article laravel vs WordPress as user and developer. I’m sure you can cover it in the best possible way.
    BTW I have developed something really great for WordPress to send you in pvt when back from sea 🙂
    With respect
    Richard

    Reply
  2. HI Richard. It’s good to hear from you. I’ve actually never used Laravel, and I use WordPress because… well… that’s where the money is. But I do sometimes need to create REST APIs, and I don’t use WordPress for that because it’s too slow. To create REST APIs I use NodeJS+Express. It’s easy to use and the API calls execute very quickly. If I get some free time, I must look at Laravel because everybody says it’s great. One day…

    Reply
  3. Yeah Paul you definitely should take a look. I think you will end up to use laravel instead of WordPress basically for everything. From my little experience (I ended to develop a custom theme with integration of tailwind and alpine and other fancy js libraries for example D3.js wich is crazy!!) ditching completely bootstrap for it’s size of css file, once you made a starting point for your backend in laravel (it’s actually time consuming) you will have a great shop – e-commerce that is another planet respect woocommerce. because what is home made is always better and you have a perfect control instead of tweaking woocomerce schema, I know it’s kinda hard sometimes from what I heard from friends of mine. Also payment systems are perfectly integrated granting you 0 plugins update headache. So what’s regarding your future clients projects could be more robust and deeply customized. When you take a look I know your consideration gonna be way different then mine. So my 2 cents are: laravel (redis cache+ tailwind + livewire (once you start using it you gonna be amazed!! It’s already includes alpine.js approach) or vue.js (for most robust ui haven’t tried it yet). It’s different and I think I will mainly focus from now on this stack (my php knowledge is greatly increased in few weeks only). Have a great end of August!

    Reply

Leave a comment