Skip to content

[add] http/rate-limit/simple example #31

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1349,6 +1349,117 @@ Checking:
127.0.0.2 [22/Nov/2021:18:20:24 +0000] 1
127.0.0.2 [22/Nov/2021:18:20:25 +0000] 2

Shared Dictionary
-----------------

HTTP Rate limit[http/rate-limit/simple]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

In this example `js_shared_dict_zone <https://nginx.org/en/docs/http/ngx_http_js_module.html#js_shared_dict_zone>`_ is used to implement a simple rate limit and can be set in different contexts.
The rate limit is implemented using a shared dictionary zone and a simple javascript function that is called for each request and increments the counter for the current window.
If the counter exceeds the limit, the function returns the number of seconds until the end of the window. The function is called using
`js_set <https://nginx.org/en/docs/http/ngx_http_js_module.html#js_set>`_ and the result is stored in a variable that is used to return a 429 response if the limit is exceeded.

nginx.conf:

.. code-block:: nginx

http {
js_path "/etc/nginx/njs/";
js_import main from http/rate-limit/simple.js;
# optionally set timeout so NJS resets and deletes all data for ratelimit counters
js_shared_dict_zone zone=kv:1M timeout=3600s evict;

server {
listen 80;
server_name www.example.com;
# access_log off;
js_var $rl_zone_name kv; # shared dict zone name; requred variable
js_var $rl_windows_ms 30000; # optional window in miliseconds; default 1 minute window if not set
js_var $rl_limit 10; # optional limit for the window; default 10 requests if not set
js_var $rl_key $remote_addr; # rate limit key; default remote_addr if not set
js_set $rl_result main.ratelimit; # call ratelimit function that returns retry-after value if limit is exceeded

location = / {
# test rate limit result
if ($rl_result != "0") {
add_header Retry-After $rl_result always;
return 429 "Too Many Requests.";
}
# Your normal processing here
return 200 "hello world";
}
}
}

example.js:

.. code-block:: js

const defaultResponse = "0";
function ratelimit(r) {
const zone = r.variables['rl_zone_name'];
const kv = zone && ngx.shared && ngx.shared[zone];
if (!kv) {
r.log(`ratelimit: ${zone} js_shared_dict_zone not found`);
return defaultResponse;
}

const key = r.variables['rl_key'] || r.variables['remote_addr'];
const window = Number(r.variables['rl_windows_ms']) || 60000;
const limit = Number(r.variables['rl_limit']) || 10;
const now = Date.now();

let requestData = kv.get(key);
if (requestData === undefined || requestData.length === 0) {
requestData = { timestamp: now, count: 1 }
kv.set(key, JSON.stringify(requestData));
return defaultResponse;
}
try {
requestData = JSON.parse(requestData);
} catch (e) {
requestData = { timestamp: now, count: 1 }
kv.set(key, JSON.stringify(requestData));
return defaultResponse;
}
if (!requestData) {
requestData = { timestamp: now, count: 1 }
kv.set(key, JSON.stringify(requestData));
return defaultResponse;
}
if (now - requestData.timestamp >= window) {
requestData.timestamp = now;
requestData.count = 1;
} else {
requestData.count++;
}
const elapsed = now - requestData.timestamp;
r.log(`limit: ${limit} window: ${window} elapsed: ${elapsed} count: ${requestData.count} timestamp: ${requestData.timestamp}`)
let retryAfter = 0;
if (requestData.count > limit) {
retryAfter = Math.ceil((window - elapsed) / 1000);
}
kv.set(key, JSON.stringify(requestData));
return retryAfter.toString();
}

export default { ratelimit };


.. code-block:: shell

curl http://localhost
200 hello world

curl http://localhost
200 hello world

# 3rd request should fail according to the rate limit $rl_limit=2
curl http://localhost
429 rate limit exceeded


NGINX-PLUS API
--------------

Expand Down
33 changes: 33 additions & 0 deletions conf/http/rate-limit/simple.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
load_module modules/ngx_http_js_module.so;

error_log /dev/stdout debug;

events { }

http {
js_path "/etc/nginx/njs/";
js_import main from http/rate-limit/simple.js;
# optionally set timeout so NJS resets and deletes all data for ratelimit counters
js_shared_dict_zone zone=kv:1M timeout=3600s evict;

server {
listen 80;
server_name www.example.com;
# access_log off;
js_var $rl_zone_name kv; # shared dict zone name; requred variable
js_var $rl_windows_ms 30000; # optional window in miliseconds; default 1 minute window if not set
js_var $rl_limit 10; # optional limit for the window; default 10 requests if not set
js_var $rl_key $remote_addr; # rate limit key; default remote_addr if not set
js_set $rl_result main.ratelimit; # call ratelimit function that returns retry-after value if limit is exceeded

location = / {
# test rate limit result
if ($rl_result != "0") {
add_header Retry-After $rl_result always;
return 429 "Too Many Requests.";
}
# Your normal processing here
return 200 "hello world";
}
}
}
60 changes: 60 additions & 0 deletions njs/http/rate-limit/simple.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const defaultResponse = "0";

/**
* Applies rate limiting logic for the request.
*
* @param {Object} r - The request object.
* @returns {string} - The retry-after value in seconds as a string. '0' means no reate limit.
*/
function ratelimit(r) {
const zone = r.variables['rl_zone_name'];
const kv = zone && ngx.shared && ngx.shared[zone];
if (!kv) {
r.log(`ratelimit: ${zone} js_shared_dict_zone not found`);
return defaultResponse;
}

const key = r.variables['rl_key'] || r.variables['remote_addr'];
const window = Number(r.variables['rl_windows_ms']) || 60000;
const limit = Number(r.variables['rl_limit']) || 10;
const now = Date.now();

let requestData = kv.get(key);
if (requestData === undefined || requestData.length === 0) {
r.log(`ratelimit: setting initial value for ${key}`);
requestData = { timestamp: now, count: 1 }
kv.set(key, JSON.stringify(requestData));
return defaultResponse;
}
try {
requestData = JSON.parse(requestData);
} catch (e) {
r.log(`ratelimit: failed to parse value for ${key}`);
requestData = { timestamp: now, count: 1 }
kv.set(key, JSON.stringify(requestData));
return defaultResponse;
}
if (!requestData) {
// remember the first request
r.log(`ratelimit: value for ${key} was not set`);
requestData = { timestamp: now, count: 1 }
kv.set(key, JSON.stringify(requestData));
return defaultResponse;
}
if (now - requestData.timestamp >= window) {
requestData.timestamp = now;
requestData.count = 1;
} else {
requestData.count++;
}
const elapsed = now - requestData.timestamp;
r.log(`limit: ${limit} window: ${window} elapsed: ${elapsed} count: ${requestData.count} timestamp: ${requestData.timestamp}`)
let retryAfter = 0;
if (requestData.count > limit) {
retryAfter = Math.ceil((window - elapsed) / 1000);
}
kv.set(key, JSON.stringify(requestData));
return retryAfter.toString();
}

export default { ratelimit };