Skip to content
Open
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
270 changes: 270 additions & 0 deletions docs/examples/rate_limiter_sliding_window.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Sliding Window Rate Limiter\n",
"\n",
"A sliding window rate limiter tracks requests within a rolling time window.\n",
"Unlike a fixed window (which resets at a set interval), a sliding window\n",
"prevents burst traffic at window boundaries.\n",
"\n",
"This example shows two implementations:\n",
"- **Sorted set approach** — precise sliding window using `ZADD` / `ZREMRANGEBYSCORE`\n",
"- **Lua script approach** — atomic version safe for concurrent multi-instance deployments"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Setup"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent docs from auto-executing the notebook

Because every code cell is committed without stored outputs and docs/conf.py does not override nbsphinx_execute, nbsphinx's default auto mode executes the notebook during the Sphinx build; the later example cells call r.delete(...) against localhost:6379, so invoke build-docs fails anywhere Redis is not already running. Store executed outputs or set notebook metadata to skip execution.

Useful? React with 👍 / 👎.

"source": [
"import time\n",
"import uuid\n",
"\n",
"import redis\n",
"\n",
"r = redis.Redis(host='localhost', port=6379, decode_responses=True)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Sorted Set Implementation\n",
"\n",
"Each request is stored as a member of a sorted set with the timestamp as score.\n",
"On every request:\n",
"1. Remove entries older than the window\n",
"2. Count remaining entries\n",
"3. If under the limit, add the new entry and allow\n",
"4. Otherwise reject"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def is_allowed_sorted_set(\n",
" client: redis.Redis,\n",
" key: str,\n",
" limit: int,\n",
" window_seconds: int,\n",
") -> bool:\n",
" \"\"\"\n",
" Returns True if the request is within the rate limit, False otherwise.\n",
"\n",
" Args:\n",
" client: Redis client instance\n",
" key: unique identifier for the rate limit bucket (e.g. user_id or ip)\n",
" limit: maximum number of requests allowed in the window\n",
" window_seconds: size of the sliding window in seconds\n",
" \"\"\"\n",
" now = time.time()\n",
" window_start = now - window_seconds\n",
"\n",
" pipe = client.pipeline()\n",
" # Remove requests outside the sliding window\n",
" pipe.zremrangebyscore(key, '-inf', window_start)\n",
" # Count requests in the current window\n",
" pipe.zcard(key)\n",
" _, count = pipe.execute()\n",
"\n",
" if count < limit:\n",
" # Add this request with current timestamp as score\n",
" member = str(uuid.uuid4())\n",
" client.zadd(key, {member: now})\n",
" # Set TTL so the key expires after the window if unused\n",
" client.expire(key, window_seconds * 2)\n",
" return True\n",
"\n",
" return False"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Example: 5 requests per 10 seconds for user 'user_123'\n",
"r.delete('rate:user_123')\n",
"\n",
"for i in range(7):\n",
" allowed = is_allowed_sorted_set(r, 'rate:user_123', limit=5, window_seconds=10)\n",
" print(f'Request {i + 1}: {\"allowed\" if allowed else \"rejected\"}')"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Atomic Lua Script Implementation\n",
"\n",
"The sorted set approach above uses a pipeline but is not fully atomic —\n",
"two concurrent processes could both read `count < limit` and both add.\n",
"A Lua script runs atomically on the Redis server, eliminating this race condition."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"SLIDING_WINDOW_SCRIPT = \"\"\"\n",
"local key = KEYS[1]\n",
"local now = tonumber(ARGV[1])\n",
"local window = tonumber(ARGV[2])\n",
"local limit = tonumber(ARGV[3])\n",
"local member = ARGV[4]\n",
"\n",
"local window_start = now - window\n",
"\n",
"-- Remove entries outside the window\n",
"redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)\n",
"\n",
"-- Count current entries\n",
"local count = redis.call('ZCARD', key)\n",
"\n",
"if count < limit then\n",
" redis.call('ZADD', key, now, member)\n",
" redis.call('EXPIRE', key, window * 2)\n",
" return 1\n",
"end\n",
"\n",
"return 0\n",
"\"\"\"\n",
"\n",
"sliding_window = r.register_script(SLIDING_WINDOW_SCRIPT)\n",
"\n",
"\n",
"def is_allowed_atomic(\n",
" client: redis.Redis,\n",
" key: str,\n",
" limit: int,\n",
" window_seconds: int,\n",
") -> bool:\n",
" result = sliding_window(\n",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Execute the script on the supplied client

When this helper is copied and called with any client other than the global r used during setup (for example a different DB, host, cluster client, or test fixture), this call still runs the registered script against r, so the passed client argument is ignored and the rate-limit key is checked/mutated on the wrong connection. Pass client=client to the Script call or register the script on the provided client.

Useful? React with 👍 / 👎.

" keys=[key],\n",
" args=[time.time(), window_seconds, limit, str(uuid.uuid4())],\n",
" )\n",
" return bool(result)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ignored Redis client parameter

Medium Severity

The is_allowed_atomic function accepts a client argument, but the registered sliding_window script isn't executed with it. This causes the rate limit logic to always run on the client used during script registration, potentially leading to operations on an unintended Redis connection or database.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit af4f39e. Configure here.

]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Same test using the atomic Lua version\n",
"r.delete('rate:user_456')\n",
"\n",
"for i in range(7):\n",
" allowed = is_allowed_atomic(r, 'rate:user_456', limit=5, window_seconds=10)\n",
" print(f'Request {i + 1}: {\"allowed\" if allowed else \"rejected\"}')"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Async Version\n",
"\n",
"For async applications use `redis.asyncio`:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import asyncio\n",
"import redis.asyncio as aioredis\n",
"\n",
"\n",
"async def is_allowed_async(\n",
" client: aioredis.Redis,\n",
" key: str,\n",
" limit: int,\n",
" window_seconds: int,\n",
") -> bool:\n",
" now = time.time()\n",
" window_start = now - window_seconds\n",
"\n",
" async with client.pipeline(transaction=True) as pipe:\n",
" await pipe.zremrangebyscore(key, '-inf', window_start)\n",
" await pipe.zcard(key)\n",
" _, count = await pipe.execute()\n",
"\n",
" if count < limit:\n",
" await client.zadd(key, {str(uuid.uuid4()): now})\n",
Comment on lines +211 to +214

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the async limiter atomic under concurrency

For async apps with concurrent workers, this repeats the non-atomic check-then-add pattern after the notebook has introduced the Lua script as the concurrency-safe implementation: two callers can both observe count < limit here and then both add, exceeding the configured limit. Either show an async version of the Lua script or explicitly mark this async helper as having the same race as the first sorted-set example.

Useful? React with 👍 / 👎.

" await client.expire(key, window_seconds * 2)\n",
" return True\n",
"\n",
" return False\n",
"\n",
"\n",
"async def main():\n",
" async_client = aioredis.Redis(host='localhost', port=6379, decode_responses=True)\n",
" await async_client.delete('rate:user_789')\n",
"\n",
" for i in range(7):\n",
" allowed = await is_allowed_async(\n",
" async_client, 'rate:user_789', limit=5, window_seconds=10\n",
" )\n",
" print(f'Request {i + 1}: {\"allowed\" if allowed else \"rejected\"}')\n",
"\n",
" await async_client.aclose()\n",
"\n",
"\n",
"asyncio.run(main())"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use top-level await in the notebook

Because this code lives in a Jupyter notebook, this cell runs inside an IPython event loop; asyncio.run(main()) raises RuntimeError in that environment instead of executing the async example. The existing async notebook examples use top-level await, so this should end with await main() to keep the notebook runnable.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

asyncio.run breaks notebook cell

Medium Severity

The async example's use of asyncio.run(main()) causes a RuntimeError in Jupyter/IPython environments because an event loop is already active. This prevents the async demonstration from running as intended.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit af4f39e. Configure here.

]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Expected output\n",
"\n",
"```\n",
"Request 1: allowed\n",
"Request 2: allowed\n",
"Request 3: allowed\n",
"Request 4: allowed\n",
"Request 5: allowed\n",
"Request 6: rejected\n",
"Request 7: rejected\n",
"```\n",
"\n",
"Requests 6 and 7 are rejected because 5 requests already exist within the 10-second window."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11.0"
}
},
"nbformat": 4,
"nbformat_minor": 4
}