From af4f39ecf144e17c845581e0e9b794678a373ba2 Mon Sep 17 00:00:00 2001 From: Yashwin Reddy Lakkireddy Date: Wed, 5 Aug 2026 14:53:19 -0400 Subject: [PATCH] docs: add sliding window rate limiter example notebook Signed-off-by: Yashwin Reddy Lakkireddy --- .../rate_limiter_sliding_window.ipynb | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 docs/examples/rate_limiter_sliding_window.ipynb diff --git a/docs/examples/rate_limiter_sliding_window.ipynb b/docs/examples/rate_limiter_sliding_window.ipynb new file mode 100644 index 0000000000..19a5228b78 --- /dev/null +++ b/docs/examples/rate_limiter_sliding_window.ipynb @@ -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", + " keys=[key],\n", + " args=[time.time(), window_seconds, limit, str(uuid.uuid4())],\n", + " )\n", + " return bool(result)" + ] + }, + { + "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", + " 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())" + ] + }, + { + "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 +}