The more complex our systems get, the more resource intensive our processes and requests become. Shifting from human-prone errors, we will find ourselves dealing with the unexpected ways users interact with our system or limitations placed on the external API our projects interact with. Additionally, we might opt to control how users interact with these resources.
This tutorial shows you how to build a request-throttling API. We will implement a blog application with Strapi to show how to limit client requests to specific endpoints with Redis. Along the way, we will discover and use Redis, understand what request objects contain, and intercept the requests made against our application.
For a practical approach to request throttling, we shall implement a blog application with Strapi and Redis. Thus, before we start, you should have the following installed locally:
Request throttling is the process of limiting the number of requests that a client can make to a server within a given amount of time. It is a means of control of the utilization of resources used by a specific client, where the server takes precedence while the client obliges - the server before the client. We do this for several reasons:
We, therefore, handle the multiple concurrent requests as a 429, meaning too many requests have been made within the time frame.
a) Fixed window algorithm: In plain terms, within a given period, say, 0800 - 0805HRS, our algorithm will limit the number of requests against the 5 minutes provided regardless of whether the requests started at 0800 or 0804HRS. b) Sliding window algorithm: When a request is made against our server, we count the number of times against the initial request. Rather than placing all our trust in the timestamp, we start the time counter when the initial request is made.
Along with the aforementioned, we have the leaky counter or token bucket algorithm, which can be used as a throttling algorithm but fall out of the scope of this piece.
Redis is an in-memory data store often used as a cache or database. It is designed for high performance and can store and retrieve data quickly. We can store session data in web applications, cache frequently accessed data, and implement real-time features such as chat and messaging. It is also commonly used as a message broker, much like RabbitMQ, in distributed systems.
Let us go ahead and scaffold the project.
npx create-strapi-app blog-api --quickstart
# OR
yarn create strapi-app blog-api --quick start
Using either of the above will generate a Strapi project, blog-api
in the current working directory and launch our Nodejs project on the default port, http://localhost:1337/admin..
Go ahead, create your administrative account and open your dashboard.
Because this is a simple blog application, we will create a schema, a collection type, that holds an article. Our article will have a title and its content, both of type text.
For the field, such as the title, hit continue and add the name. You can modify the title from the Advanced Settings tab to be unique and required.
We do the same for the content field, only this time, we specify it as a long text field. We should end up with the below article content type.
Hit save, wait for the server to restart and create an article.
By default, Strapi creates a User collection type(next to the article collection type). Based on this, let us create a new entry and, for this guide, add this user to the Authenticated role.
To fetch articles using the API and as an authenticated user, we need to give the administrator API access to articles.
Navigate to Settings, User & Permission plugin > Roles and modify the Authenticated role to have access to all articles and a single one both in read mode.
All said and done, launch your Postman or whatever client you have for testing API.
Create a collection and name it Strapi tests.
Hit save, and navigate to your created collection to create a request. We shall create two endpoints: 1. Login with the URL http://localhost:1337/api/auth/local. 2. Articles with the URL: http://localhost:1337/api/articles.
For the login authentication, our params will look like below, where the identifier and password are the username and password, respectively. Go ahead and authenticate your user.
Afterward, hit the article API to see your articles using the jwt
from your user as a bearer token.
Middleware is code that sits between your business logic, your back-end, and your client application. Its purpose is to perform tasks such as logging (which user is doing what), caching, or request limiting, as we are about to do.
In Strapi, we have two kinds of middleware: the global Strapi middleware and the Route level middleware. More details of this difference can be found here.
Let’s focus on route middleware and cap how many times users get to query for articles from our page.
npx strapi generate
The result would look as below:
Select middleware, and proceed to fill in its details as below:
The file generated should look as below:
1 // src/api/article/middlewares/request-limiter.js
2 'use strict';
3
4 /**
5 * `request-limiter` middleware
6 */
7
8 module.exports = (config, { strapi }) => {
9 // Add your own logic here.
10 return async (ctx, next) => {
11 strapi.log.info('In request-limiter middleware.');
12
13 await next();
14 };
15 };
Before our requests hit the database, we shall place our middleware, rate-limiter, on our API route for article. So we transform our route src/api/article/routes/article.js from its default:
1 "use strict";
2
3 /**
4 * article router
5 */
6
7 const { createCoreRouter } = require("@strapi/strapi").factories;
8
9 module.exports = createCoreRouter("api::article.article");
To a version that includes our middleware:
1 "use strict";
2
3 /**
4 * article router
5 * src/api/article/routes/article.js
6 */
7
8 const { createCoreRouter } = require("@strapi/strapi").factories;
9
10 module.exports = createCoreRouter("api::article.article", {
11 config: {
12 find: {
13 /*
14 where the array below specifies the path of the request-limiter file,
15 src/api/article/middlewares/request-limiter.js , exclulding
16 the middleware section of the path
17 */
18 middlewares: ["api::article.request-limiter"],
19 },
20 },
21 });
The above implies that every time our API calls find
to return a list of articles, our custom middleware will be called. You can modify this to work on any other methods, such as findOne
, create
, update
, and delete
, which are all part of the core routers.
You can also find out the path of your middleware using the CLI that comes along with Strapi.
npx strapi middlewares:list
Repeating our call to api/articles will show the info log: In request-limiter middleware on our terminal.
With Redis installed, we need a client between it and the Node application. Thus, in comes ioredis, a community-driven Node client for Redis. We also go ahead and install Moment.js, a JavaScript library around time.
npm i ioredis moment
A typical request will contain the header, method, body, params, and user object, to mention a few details. In Strapi, this is attached to the ctx.request
object and is called from either the controller or policies (more on this can be seen here).
Back to the request-limiter, our focal point will be ctx
, a context-object with all the requested information. Within this, we will look for the user attached to a request and modify the response object based on the count of requests our API will allow. We will answer:
We will use Redis to store the user id against their requests count. To boilerplate, we try to get the user based on their ID from Redis and perform operations around that. In the event something fails, we catch the error.
1 'use strict';
2
3 /**
4 * `request-limiter` middleware
5 */
6
7 const redis = require("ioredis");
8 const redisClient = redis.createClient();
9 const moment = require("moment");
10
11 module.exports = (config, { strapi }) => {
12 return async (ctx, next) => {
13 try {
14 strapi.log.info("In request-limiter middleware.");
15 // check if redis key exists
16 // will return 1 or 0
17 const userExists = await redisClient.exists(ctx.state.user.id);
18 if (userExists === 1) {
19 strapi.log.info("User exists in redis.");
20 }
21 else {
22 strapi.log.info( "User does not exist in redis.");
23 }
24
25 await next();
26 } catch (err) {
27 strapi.log.error(err);
28 throw err;
29 }
30
31 };
32 };
Note the following line, where we use Redis to check for the existence of a user based on their id.
const userExists = await redisClient.exists(ctx.state.user.id)
Once a user hits the server, we check whether we have a record of them doing so. If we do, we check the count of requests they have within the time period (1 minute). Let us begin with what happens when the user exists.
For this implementation, we will use the fixed-window algorithm; a user can hit our server X times within a given period, no matter when they start.
1 strapi.log.info("User exists in redis.");
2 const reply = await redisClient.get(ctx.state.user.id);
3 const requestDetails = JSON.parse(reply);
4 const currentTime = moment().unix();
5 const time_difference = (currentTime - requestDetails.startTime) / 60;
6
7 // reset the count if the difference is greater than 1 minute
8 if (time_difference >= 1) {
9 const body = {
10 count: 1,
11 startTime: moment().unix(),
12 };
13 await redisClient.set(ctx.state.user.id, JSON.stringify(body));
14 next();
15 // increment the count if the difference is less than 1 minute
16 // where 10 is the number of requests we allow within the time frame
17 } else if (time_difference < 1 && requestDetails.count <= 10) {
18 requestDetails.count++;
19 await redisClient.set(
20 ctx.state.user.id,
21 JSON.stringify(requestDetails)
22 );
23 next();
24 // return error if the difference is less than 1 minute and count is greater than 3
25 } else {
26 strapi.log.error("throttled limit exceeded...");
27 ctx.response.status = 429;
28 ctx.response.body = {
29 error: "Unable to process request",
30 message: "throttled limit exceeded...",
31 };
32 return;
33 }
Now that our user is known to exist, we fetch and parse the request history attached to them. We also use momentjs
to get the current time. Based on this, we get the difference from the start time of their initial request and divide it by 60 to convert it to seconds.
There are three ways this can pan out: 1. The time between the initial and current requests is over one minute. 2. The time between the initial and current requests is less than a minute, and the count of requests is also less than the threshold. 3. If all the above conditions are not met, the user has hit our endpoint more than the threshold we allow in the period set (1 minute).
In the first case, we reset the user's request information and start counting from 1 once more. In the second, we will simply update the request information of this user and update their request count; in the third, we let them know that they have reached the limit.
What if our user does not exist? Now, we have to account for the instance where Redis, our in-memory store, has no record of this user ever making a request. In this case, we create a new key-value pair with their id, count of requests (starting at one as this is their first request), and the current Unix time as the start time.
// user does not exist. Add a new key-value pair with count as 1
const body = { count: 1, startTime: moment().unix() };
await redisClient.set(ctx.state.user.id, JSON.stringify(body));
Altogether, our limiter will look as below, having refactored the request count to a global constant.
1 'use strict';
2
3 /**
4 * `request-limiter` middleware
5 */
6
7 const THROTTLE_LIMIT = 3;
8 const redis = require("ioredis");
9 const redisClient = redis.createClient();
10 const moment = require("moment");
11
12 module.exports = (config, { strapi }) => {
13 // Add your own logic here.
14 return async (ctx, next) => {
15 try {
16 // check if redis key exists
17 const userExists = await redisClient.exists(ctx.state.user.id);
18 if (userExists === 1) {
19 const reply = await redisClient.get(ctx.state.user.id);
20 const requestDetails = JSON.parse(reply);
21 const currentTime = moment().unix();
22 const time_difference = (currentTime - requestDetails.startTime) / 60;
23
24 // reset the count if the difference is greater than 1 minute
25 if (time_difference >= 1) {
26 const body = {
27 count: 1,
28 startTime: moment().unix(),
29 };
30 await redisClient.set(ctx.state.user.id, JSON.stringify(body));
31 next();
32 // increment the count if the time_difference is less than 1 minute
33 } else if (time_difference < 1 && requestDetails.count <= THROTTLE_LIMIT) {
34 requestDetails.count++;
35 await redisClient.set(
36 ctx.state.user.id,
37 JSON.stringify(requestDetails)
38 );
39 next();
40 // return error if the time_difference is less than 1 minute and count is greater than 3
41 } else {
42 strapi.log.error("Throttled limit exceeded...");
43 ctx.response.status = 429;
44 ctx.response.body = {
45 error: 1,
46 message: "Throttled limit exceeded...",
47 };
48 return;
49 }
50 }
51 else {
52 strapi.log.info("User does not exist in redis.");
53 const body = {
54 count: 1,
55 startTime: moment().unix(),
56 };
57 await redisClient.set(ctx.state.user.id, JSON.stringify(body));
58 next();
59 }
60 } catch (err) {
61 strapi.log.error(err);
62 throw err;
63 }
64
65 };
66 };
You can navigate to Postman, authenticate and hit the /api/articles endpoint again. With multiple requests exceeding the limit and within the same minute, you’ll get an error message below with the status 429.
1 {
2 "error": "Unable to process request",
3 "message": "throttled limit exceeded..."
4 }
We have created a blog API, authenticated a user, looked through the request object, created custom middleware to intercept their request, and limited concurrent requests from this user.
For reference, you can read through the code for this article on marvinkweyu/blog-api.
Learn more here:
I code, read, and stay exceptionally weird. Join me in building the next SaaS, or find me in startup conferences discussing how to scale.