-
Notifications
You must be signed in to change notification settings - Fork 2.7k
docs: add sliding window rate limiter example notebook #4248
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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": [], | ||
| "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", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When this helper is copied and called with any client other than the global Useful? React with 👍 / 👎. |
||
| " keys=[key],\n", | ||
| " args=[time.time(), window_seconds, limit, str(uuid.uuid4())],\n", | ||
| " )\n", | ||
| " return bool(result)" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ignored Redis client parameterMedium Severity The 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 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())" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because this code lives in a Jupyter notebook, this cell runs inside an IPython event loop; Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. asyncio.run breaks notebook cellMedium Severity The async example's use of 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 | ||
| } | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because every code cell is committed without stored outputs and
docs/conf.pydoes not overridenbsphinx_execute, nbsphinx's defaultautomode executes the notebook during the Sphinx build; the later example cells callr.delete(...)againstlocalhost:6379, soinvoke build-docsfails anywhere Redis is not already running. Store executed outputs or set notebook metadata to skip execution.Useful? React with 👍 / 👎.