From c69d00da343da52e0a8285cabba215f9ed5fe650 Mon Sep 17 00:00:00 2001 From: Shikhar Date: Mon, 24 Nov 2025 00:20:54 +0530 Subject: [PATCH 1/3] feat: add FastAPI rate limiter --- TESTING_RATE_LIMITS.md | 305 +++++++++++++++++++++++++++++++++ docker/nginx/conf.d/lenny.conf | 30 ++++ docker/nginx/nginx.conf | 11 ++ lenny/app.py | 4 + lenny/core/ratelimit.py | 64 +++++++ lenny/routes/api.py | 14 +- requirements.txt | 1 + 7 files changed, 427 insertions(+), 2 deletions(-) create mode 100644 TESTING_RATE_LIMITS.md create mode 100644 lenny/core/ratelimit.py diff --git a/TESTING_RATE_LIMITS.md b/TESTING_RATE_LIMITS.md new file mode 100644 index 0000000..a45da4b --- /dev/null +++ b/TESTING_RATE_LIMITS.md @@ -0,0 +1,305 @@ +# Testing Rate Limiting + +This guide explains how to verify that rate limiting is working correctly in Lenny. + +## Prerequisites + +1. Make sure Lenny is running: + ```bash + make start + # or + docker compose -p lenny up -d + ``` + +2. Verify services are up: + ```bash + docker ps | grep lenny + ``` + +3. Check the API is accessible: + ```bash + curl http://localhost:8080/v1/api/items + ``` + +--- + +## Testing Methods + +### Method 1: Quick Test with curl (Single Endpoint) + +#### Test 1: Verify OPDS endpoints have NO rate limiting +```bash +# Make many rapid requests to /opds (should all succeed) +for i in {1..50}; do + curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/v1/api/opds +done +# Expected: All return 200 (no rate limiting) +``` + +#### Test 2: Verify /items has NO rate limiting +```bash +# Make many rapid requests to /items (should all succeed) +for i in {1..50}; do + curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/v1/api/items +done +# Expected: All return 200 (no rate limiting) +``` + +#### Test 3: Test STRICT rate limiting on /upload +```bash +# Make 25 requests rapidly (limit is 20/min + 5 burst = 25 total) +for i in {1..25}; do + echo "Request $i:" + curl -s -o /dev/null -w "HTTP %{http_code}\n" \ + -X POST http://localhost:8080/v1/api/upload \ + -F "openlibrary_edition=1" \ + -F "file=@/dev/null" 2>/dev/null || echo "Failed" +done +# Expected: First 25 succeed (20 + 5 burst), then 503 (Service Unavailable) +``` + +#### Test 4: Test GENERAL rate limiting on other endpoints +```bash +# Make 120 requests rapidly (limit is 100/min + 10 burst = 110 total) +for i in {1..120}; do + echo "Request $i:" + curl -s -o /dev/null -w "HTTP %{http_code}\n" \ + http://localhost:8080/v1/api/items/borrowed +done +# Expected: First 110 succeed, then 503 or 429 +``` + +--- + +### Method 2: Comprehensive Test Script + +Create a test script `test_rate_limits.sh`: + +```bash +#!/bin/bash + +BASE_URL="http://localhost:8080/v1/api" +echo "Testing Rate Limiting..." +echo "=========================" + +# Test 1: OPDS (should NOT be rate limited) +echo -e "\n[Test 1] OPDS endpoint (should NOT be rate limited):" +for i in {1..10}; do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/opds") + echo -n "$STATUS " +done +echo "" + +# Test 2: Items (should NOT be rate limited) +echo -e "\n[Test 2] Items endpoint (should NOT be rate limited):" +for i in {1..10}; do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/items") + echo -n "$STATUS " +done +echo "" + +# Test 3: General endpoint (should be rate limited after 110 requests) +echo -e "\n[Test 3] General endpoint rate limiting (100/min + 10 burst):" +SUCCESS=0 +FAILED=0 +for i in {1..120}; do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/items/borrowed") + if [ "$STATUS" = "200" ] || [ "$STATUS" = "401" ]; then + SUCCESS=$((SUCCESS + 1)) + else + FAILED=$((FAILED + 1)) + fi + if [ $i -eq 110 ]; then + echo "At request 110 (limit + burst): Success=$SUCCESS, Failed=$FAILED" + fi +done +echo "Total: Success=$SUCCESS, Failed=$FAILED" + +# Test 4: Strict endpoint (should be rate limited after 25 requests) +echo -e "\n[Test 4] Strict endpoint rate limiting (20/min + 5 burst):" +SUCCESS=0 +FAILED=0 +for i in {1..30}; do + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "$BASE_URL/authenticate" \ + -H "Content-Type: application/json" \ + -d '{"email":"test@example.com"}' 2>/dev/null) + if [ "$STATUS" = "200" ] || [ "$STATUS" = "400" ] || [ "$STATUS" = "401" ]; then + SUCCESS=$((SUCCESS + 1)) + else + FAILED=$((FAILED + 1)) + fi + if [ $i -eq 25 ]; then + echo "At request 25 (limit + burst): Success=$SUCCESS, Failed=$FAILED" + fi +done +echo "Total: Success=$SUCCESS, Failed=$FAILED" +``` + +Make it executable and run: +```bash +chmod +x test_rate_limits.sh +./test_rate_limits.sh +``` + +--- + +### Method 3: Using Apache Bench (ab) or wrk + +#### Install Apache Bench (if not installed): +```bash +# macOS +brew install httpd + +# Ubuntu/Debian +sudo apt-get install apache2-utils +``` + +#### Test rate limiting: +```bash +# Test general endpoint (100/min limit) +ab -n 120 -c 10 http://localhost:8080/v1/api/items/borrowed + +# Test strict endpoint (20/min limit) +ab -n 30 -c 5 -p auth.json -T application/json \ + http://localhost:8080/v1/api/authenticate +``` + +--- + +## What to Look For + +### Success Indicators: + +1. **OPDS and /items endpoints**: + - Should return `200 OK` for all requests + - No `429 Too Many Requests` or `503 Service Unavailable` + +2. **Rate-limited endpoints**: + - First N requests (limit + burst) return `200`, `401`, or `400` + - Subsequent requests return: + - `429 Too Many Requests` (from slowapi) + - `503 Service Unavailable` (from Nginx) + - Or both headers indicating rate limiting + +3. **Response Headers**: + ```bash + curl -I http://localhost:8080/v1/api/items/borrowed + ``` + Look for: + - `X-RateLimit-Limit`: Rate limit value + - `X-RateLimit-Remaining`: Remaining requests + - `Retry-After`: When to retry (if rate limited) + +--- + +## Checking Logs + +### Nginx Logs (Rate Limit Blocks): +```bash +# Check Nginx error log for rate limit messages +docker exec lenny_api tail -f /var/log/nginx/error.log + +# Check Nginx access log +docker exec lenny_api tail -f /var/log/nginx/access.log | grep "503" +``` + +### FastAPI/slowapi Logs: +```bash +# Check FastAPI application logs +docker compose logs -f api | grep -i "rate\|limit\|429" +``` + +### All Logs: +```bash +make log +# or +docker compose logs -f +``` + +--- + +## Testing Both Layers + +### Test Nginx Layer (First Defense): +```bash +# Make requests that should be blocked by Nginx before reaching FastAPI +# Check Nginx logs for "limiting requests" messages +for i in {1..120}; do + curl -s http://localhost:8080/v1/api/items/borrowed > /dev/null +done +docker exec lenny_api grep "limiting requests" /var/log/nginx/error.log +``` + +### Test slowapi Layer (Second Defense): +```bash +# If Nginx allows a request through, slowapi should catch it +# Look for 429 responses with X-RateLimit headers +curl -v http://localhost:8080/v1/api/items/borrowed 2>&1 | grep -i "rate" +``` + +--- + +## Expected Behavior Summary + +| Endpoint | Rate Limit | Burst | Total Allowed | Status Code When Limited | +|----------|------------|-------|---------------|-------------------------| +| `/v1/api/opds*` | None | N/A | Unlimited | N/A | +| `/v1/api/items` | None | N/A | Unlimited | N/A | +| `/v1/api/upload` | 20/min | 5 | 25 | 503 (Nginx) or 429 (slowapi) | +| `/v1/api/authenticate` | 20/min | 5 | 25 | 503 (Nginx) or 429 (slowapi) | +| Other `/v1/api/*` | 100/min | 10 | 110 | 503 (Nginx) or 429 (slowapi) | + +--- + +## Troubleshooting + +### Rate limiting not working? + +1. **Check Nginx configuration is loaded**: + ```bash + docker exec lenny_api nginx -t + docker exec lenny_api nginx -s reload + ``` + +2. **Verify slowapi is installed**: + ```bash + docker exec lenny_api pip list | grep slowapi + ``` + +3. **Check if rate limit zones are active**: + ```bash + docker exec lenny_api cat /etc/nginx/nginx.conf | grep limit_req_zone + ``` + +4. **Restart services**: + ```bash + make restart + # or + docker compose restart api + ``` + +### Rate limiting too aggressive? + +- Adjust limits in `lenny/core/ratelimit.py` or via environment variables +- Adjust Nginx limits in `docker/nginx/nginx.conf` +- Restart services after changes + +--- + +## Quick Verification Commands + +```bash +# 1. Check services are running +docker ps | grep lenny + +# 2. Test OPDS (should work unlimited) +curl http://localhost:8080/v1/api/opds + +# 3. Test rate limited endpoint (make 120 requests) +for i in {1..120}; do curl -s http://localhost:8080/v1/api/items/borrowed; done | tail -5 + +# 4. Check logs for rate limit messages +docker compose logs api | grep -i "rate\|429\|503" | tail -10 +``` + diff --git a/docker/nginx/conf.d/lenny.conf b/docker/nginx/conf.d/lenny.conf index f2df257..8edbf3e 100644 --- a/docker/nginx/conf.d/lenny.conf +++ b/docker/nginx/conf.d/lenny.conf @@ -12,7 +12,37 @@ server { add_header Access-Control-Allow-Headers "Content-Type, Authorization" always; add_header Access-Control-Allow-Credentials false always; + # OPDS endpoints without rate limiting + location ~ ^/v1/api/opds { + proxy_pass http://lenny_api:1337; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Items endpoint without rate limiting + location = /v1/api/items { + proxy_pass http://lenny_api:1337/v1/api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Strict rate limiting for sensitive endpoints + location ~ ^/v1/api/(upload|authenticate) { + limit_req zone=strict_limit burst=5 nodelay; + proxy_pass http://lenny_api:1337; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # General API endpoints with standard rate limiting location /v1/api { + limit_req zone=general_limit burst=10 nodelay; proxy_pass http://lenny_api:1337/v1/api; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index 74e2a88..cd32bfb 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -15,6 +15,17 @@ http { sendfile on; keepalive_timeout 65; client_max_body_size 50M; + + # Rate limiting zones + # General zone: 100 requests per minute (1.67 req/sec) with burst of 10 + limit_req_zone $binary_remote_addr zone=general_limit:10m rate=100r/m; + + # Lenient zone for OPDS endpoints: 300 requests per minute (5 req/sec) with burst of 20 + limit_req_zone $binary_remote_addr zone=lenient_limit:10m rate=300r/m; + + # Strict zone for sensitive endpoints: 20 requests per minute (0.33 req/sec) with burst of 5 + limit_req_zone $binary_remote_addr zone=strict_limit:10m rate=20r/m; + include /etc/nginx/conf.d/*.conf; } diff --git a/lenny/app.py b/lenny/app.py index 12ec6ae..fc1a532 100755 --- a/lenny/app.py +++ b/lenny/app.py @@ -7,6 +7,7 @@ from lenny.routes import api from lenny.configs import OPTIONS from lenny import __version__ as VERSION +from lenny.core.ratelimit import init_rate_limiter app = FastAPI( title="Lenny API", @@ -25,6 +26,9 @@ allow_headers=["*"], ) +# Initialize rate limiting +init_rate_limiter(app) + app.templates = Jinja2Templates(directory="lenny/templates") app.include_router(api.router, prefix="/v1/api") diff --git a/lenny/core/ratelimit.py b/lenny/core/ratelimit.py new file mode 100644 index 0000000..bfbae0c --- /dev/null +++ b/lenny/core/ratelimit.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 + +""" +Rate limiting configuration for Lenny. + +This module provides rate limiting settings that are compatible between +Nginx limit_req and slowapi, ensuring consistent TTL values. + +:copyright: (c) 2015 by AUTHORS +:license: see LICENSE for more details +""" + +import os +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded + +# Rate limit window in seconds - must be compatible between nginx and slowapi +# Using 60 seconds (1 minute) as a standard window +RATE_LIMIT_WINDOW = int(os.environ.get('RATE_LIMIT_WINDOW', 60)) + +def _parse_rate_limit(env_var: str, default: int) -> int: + """ + Parse rate limit from environment variable. + Supports both integer format (100) and string format ('100/minute'). + Returns the integer count of requests. + """ + value = os.environ.get(env_var, str(default)) + # If it's already a string like '100/minute', extract the number + if '/' in str(value): + return int(value.split('/')[0]) + # Otherwise, treat as integer + return int(value) + +# Rate limits as number of requests per window +# These are converted to slowapi-compatible strings below +# Supports both integer (100) and string ('100/minute') formats for backward compatibility +RATE_LIMIT_GENERAL_COUNT = _parse_rate_limit('RATE_LIMIT_GENERAL', 100) +RATE_LIMIT_LENIENT_COUNT = _parse_rate_limit('RATE_LIMIT_LENIENT', 300) +RATE_LIMIT_STRICT_COUNT = _parse_rate_limit('RATE_LIMIT_STRICT', 20) + +# Convert to slowapi-compatible string format (requests per minute) +# This ensures TTL compatibility between nginx and slowapi +RATE_LIMIT_GENERAL = f'{RATE_LIMIT_GENERAL_COUNT}/minute' +RATE_LIMIT_LENIENT = f'{RATE_LIMIT_LENIENT_COUNT}/minute' +RATE_LIMIT_STRICT = f'{RATE_LIMIT_STRICT_COUNT}/minute' + +# Create the limiter instance +limiter = Limiter( + key_func=get_remote_address, + default_limits=[RATE_LIMIT_GENERAL], + headers_enabled=True, +) + +def init_rate_limiter(app): + """ + Initialize rate limiting for the FastAPI app. + + Args: + app: FastAPI application instance + """ + app.state.limiter = limiter + app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + diff --git a/lenny/routes/api.py b/lenny/routes/api.py index 43458dc..b67f826 100644 --- a/lenny/routes/api.py +++ b/lenny/routes/api.py @@ -44,6 +44,7 @@ ) from lenny.core.readium import ReadiumAPI from lenny.core.models import Item +from lenny.core.ratelimit import limiter, RATE_LIMIT_GENERAL, RATE_LIMIT_LENIENT, RATE_LIMIT_STRICT from urllib.parse import quote COOKIES_MAX_AGE = 604800 # 1 week @@ -84,7 +85,7 @@ async def home(request: Request): return request.app.templates.TemplateResponse("index.html", kwargs) @router.get("/items") -async def get_items(fields: Optional[str]=None, offset: Optional[int]=None, limit: Optional[int]=None): +async def get_items(request: Request, fields: Optional[str]=None, offset: Optional[int]=None, limit: Optional[int]=None): fields = fields.split(",") if fields else None return LennyAPI.get_enriched_items( fields=fields, offset=offset, limit=limit @@ -110,6 +111,7 @@ async def get_opds_item(request: Request, book_id:int): # Redirect to the Thorium Web Reader @router.get("/items/{book_id}/read") +@limiter.limit(RATE_LIMIT_GENERAL) @requires_item_auth() async def redirect_reader(request: Request, book_id: str, format: str = "epub", session: Optional[str] = Cookie(None), item=None, email: str=''): manifest_uri = LennyAPI.make_manifest_url(book_id) @@ -119,12 +121,14 @@ async def redirect_reader(request: Request, book_id: str, format: str = "epub", return RedirectResponse(url=reader_url, status_code=307) @router.get("/items/{book_id}/readium/manifest.json") +@limiter.limit(RATE_LIMIT_GENERAL) @requires_item_auth() async def get_manifest(request: Request, book_id: str, format: str=".epub", session: Optional[str] = Cookie(None), item=None, email: str=''): return ReadiumAPI.get_manifest(book_id, format) # Proxy all other readium requests @router.get("/items/{book_id}/readium/{readium_path:path}") +@limiter.limit(RATE_LIMIT_GENERAL) @requires_item_auth() async def proxy_readium(request: Request, book_id: str, readium_path: str, format: str=".epub", session: Optional[str] = Cookie(None), item=None, email: str=''): readium_url = ReadiumAPI.make_url(book_id, format, readium_path) @@ -135,6 +139,7 @@ async def proxy_readium(request: Request, book_id: str, readium_path: str, forma return Response(content=r.content, media_type=content_type) @router.post('/items/{book_id}/borrow') +@limiter.limit(RATE_LIMIT_GENERAL) @requires_item_auth() async def borrow_item(request: Request, book_id: int, format: str=".epub", session: Optional[str] = Cookie(None), item=None, email: str=''): """ @@ -158,6 +163,7 @@ async def borrow_item(request: Request, book_id: int, format: str=".epub", sess raise HTTPException(status_code=400, detail=str(e)) @router.post('/items/{book_id}/return', status_code=status.HTTP_200_OK) +@limiter.limit(RATE_LIMIT_GENERAL) @requires_item_auth() async def return_item(request: Request, book_id: int, format: str=".epub", session: Optional[str] = Cookie(None), item=None, email: str=''): """ @@ -179,6 +185,7 @@ async def return_item(request: Request, book_id: int, format: str=".epub", sessi @router.post('/upload', status_code=status.HTTP_200_OK) +@limiter.limit(RATE_LIMIT_STRICT) async def upload( request: Request, openlibrary_edition: int = Form( @@ -216,6 +223,7 @@ async def upload( raise HTTPException(status_code=500, detail=f"Unexpected error: {str(e)}") @router.post("/authenticate") +@limiter.limit(RATE_LIMIT_STRICT) async def authenticate(request: Request, response: Response): client_ip = request.client.host @@ -261,6 +269,7 @@ async def authenticate(request: Request, response: Response): @router.post('/items/borrowed', status_code=status.HTTP_200_OK) +@limiter.limit(RATE_LIMIT_GENERAL) async def get_borrowed_items(request: Request, session : Optional[str] = Cookie(None)): """ Returns a list of active (not returned) borrowed items for the given patron's email. @@ -294,7 +303,8 @@ async def get_borrowed_items(request: Request, session : Optional[str] = Cookie( @router.get('/logout', status_code=status.HTTP_200_OK) -async def logout_page(response: Response): +@limiter.limit(RATE_LIMIT_GENERAL) +async def logout_page(request: Request, response: Response): """ Logs out the user and sends a logout confirmation JSON response. """ diff --git a/requirements.txt b/requirements.txt index 472d67d..5be3597 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,6 +46,7 @@ urllib3==2.4.0 uvicorn==0.32.0 watchfiles==1.0.5 itsdangerous==2.2.0 +slowapi==0.1.9 git+https://github.com/ArchiveLabs/pyopds2.git git+https://github.com/ArchiveLabs/pyopds2_openlibrary.git From e3923a558b4a88df9ce62041fe0089de6240ad07 Mon Sep 17 00:00:00 2001 From: Shikhar Date: Mon, 24 Nov 2025 00:32:50 +0530 Subject: [PATCH 2/3] updated: add FastAPI rate limiter --- TESTING_RATE_LIMITS.md | 305 ---------------------------------------- docker/nginx/nginx.conf | 2 +- lenny/core/ratelimit.py | 7 - 3 files changed, 1 insertion(+), 313 deletions(-) delete mode 100644 TESTING_RATE_LIMITS.md diff --git a/TESTING_RATE_LIMITS.md b/TESTING_RATE_LIMITS.md deleted file mode 100644 index a45da4b..0000000 --- a/TESTING_RATE_LIMITS.md +++ /dev/null @@ -1,305 +0,0 @@ -# Testing Rate Limiting - -This guide explains how to verify that rate limiting is working correctly in Lenny. - -## Prerequisites - -1. Make sure Lenny is running: - ```bash - make start - # or - docker compose -p lenny up -d - ``` - -2. Verify services are up: - ```bash - docker ps | grep lenny - ``` - -3. Check the API is accessible: - ```bash - curl http://localhost:8080/v1/api/items - ``` - ---- - -## Testing Methods - -### Method 1: Quick Test with curl (Single Endpoint) - -#### Test 1: Verify OPDS endpoints have NO rate limiting -```bash -# Make many rapid requests to /opds (should all succeed) -for i in {1..50}; do - curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/v1/api/opds -done -# Expected: All return 200 (no rate limiting) -``` - -#### Test 2: Verify /items has NO rate limiting -```bash -# Make many rapid requests to /items (should all succeed) -for i in {1..50}; do - curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/v1/api/items -done -# Expected: All return 200 (no rate limiting) -``` - -#### Test 3: Test STRICT rate limiting on /upload -```bash -# Make 25 requests rapidly (limit is 20/min + 5 burst = 25 total) -for i in {1..25}; do - echo "Request $i:" - curl -s -o /dev/null -w "HTTP %{http_code}\n" \ - -X POST http://localhost:8080/v1/api/upload \ - -F "openlibrary_edition=1" \ - -F "file=@/dev/null" 2>/dev/null || echo "Failed" -done -# Expected: First 25 succeed (20 + 5 burst), then 503 (Service Unavailable) -``` - -#### Test 4: Test GENERAL rate limiting on other endpoints -```bash -# Make 120 requests rapidly (limit is 100/min + 10 burst = 110 total) -for i in {1..120}; do - echo "Request $i:" - curl -s -o /dev/null -w "HTTP %{http_code}\n" \ - http://localhost:8080/v1/api/items/borrowed -done -# Expected: First 110 succeed, then 503 or 429 -``` - ---- - -### Method 2: Comprehensive Test Script - -Create a test script `test_rate_limits.sh`: - -```bash -#!/bin/bash - -BASE_URL="http://localhost:8080/v1/api" -echo "Testing Rate Limiting..." -echo "=========================" - -# Test 1: OPDS (should NOT be rate limited) -echo -e "\n[Test 1] OPDS endpoint (should NOT be rate limited):" -for i in {1..10}; do - STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/opds") - echo -n "$STATUS " -done -echo "" - -# Test 2: Items (should NOT be rate limited) -echo -e "\n[Test 2] Items endpoint (should NOT be rate limited):" -for i in {1..10}; do - STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/items") - echo -n "$STATUS " -done -echo "" - -# Test 3: General endpoint (should be rate limited after 110 requests) -echo -e "\n[Test 3] General endpoint rate limiting (100/min + 10 burst):" -SUCCESS=0 -FAILED=0 -for i in {1..120}; do - STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/items/borrowed") - if [ "$STATUS" = "200" ] || [ "$STATUS" = "401" ]; then - SUCCESS=$((SUCCESS + 1)) - else - FAILED=$((FAILED + 1)) - fi - if [ $i -eq 110 ]; then - echo "At request 110 (limit + burst): Success=$SUCCESS, Failed=$FAILED" - fi -done -echo "Total: Success=$SUCCESS, Failed=$FAILED" - -# Test 4: Strict endpoint (should be rate limited after 25 requests) -echo -e "\n[Test 4] Strict endpoint rate limiting (20/min + 5 burst):" -SUCCESS=0 -FAILED=0 -for i in {1..30}; do - STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ - -X POST "$BASE_URL/authenticate" \ - -H "Content-Type: application/json" \ - -d '{"email":"test@example.com"}' 2>/dev/null) - if [ "$STATUS" = "200" ] || [ "$STATUS" = "400" ] || [ "$STATUS" = "401" ]; then - SUCCESS=$((SUCCESS + 1)) - else - FAILED=$((FAILED + 1)) - fi - if [ $i -eq 25 ]; then - echo "At request 25 (limit + burst): Success=$SUCCESS, Failed=$FAILED" - fi -done -echo "Total: Success=$SUCCESS, Failed=$FAILED" -``` - -Make it executable and run: -```bash -chmod +x test_rate_limits.sh -./test_rate_limits.sh -``` - ---- - -### Method 3: Using Apache Bench (ab) or wrk - -#### Install Apache Bench (if not installed): -```bash -# macOS -brew install httpd - -# Ubuntu/Debian -sudo apt-get install apache2-utils -``` - -#### Test rate limiting: -```bash -# Test general endpoint (100/min limit) -ab -n 120 -c 10 http://localhost:8080/v1/api/items/borrowed - -# Test strict endpoint (20/min limit) -ab -n 30 -c 5 -p auth.json -T application/json \ - http://localhost:8080/v1/api/authenticate -``` - ---- - -## What to Look For - -### Success Indicators: - -1. **OPDS and /items endpoints**: - - Should return `200 OK` for all requests - - No `429 Too Many Requests` or `503 Service Unavailable` - -2. **Rate-limited endpoints**: - - First N requests (limit + burst) return `200`, `401`, or `400` - - Subsequent requests return: - - `429 Too Many Requests` (from slowapi) - - `503 Service Unavailable` (from Nginx) - - Or both headers indicating rate limiting - -3. **Response Headers**: - ```bash - curl -I http://localhost:8080/v1/api/items/borrowed - ``` - Look for: - - `X-RateLimit-Limit`: Rate limit value - - `X-RateLimit-Remaining`: Remaining requests - - `Retry-After`: When to retry (if rate limited) - ---- - -## Checking Logs - -### Nginx Logs (Rate Limit Blocks): -```bash -# Check Nginx error log for rate limit messages -docker exec lenny_api tail -f /var/log/nginx/error.log - -# Check Nginx access log -docker exec lenny_api tail -f /var/log/nginx/access.log | grep "503" -``` - -### FastAPI/slowapi Logs: -```bash -# Check FastAPI application logs -docker compose logs -f api | grep -i "rate\|limit\|429" -``` - -### All Logs: -```bash -make log -# or -docker compose logs -f -``` - ---- - -## Testing Both Layers - -### Test Nginx Layer (First Defense): -```bash -# Make requests that should be blocked by Nginx before reaching FastAPI -# Check Nginx logs for "limiting requests" messages -for i in {1..120}; do - curl -s http://localhost:8080/v1/api/items/borrowed > /dev/null -done -docker exec lenny_api grep "limiting requests" /var/log/nginx/error.log -``` - -### Test slowapi Layer (Second Defense): -```bash -# If Nginx allows a request through, slowapi should catch it -# Look for 429 responses with X-RateLimit headers -curl -v http://localhost:8080/v1/api/items/borrowed 2>&1 | grep -i "rate" -``` - ---- - -## Expected Behavior Summary - -| Endpoint | Rate Limit | Burst | Total Allowed | Status Code When Limited | -|----------|------------|-------|---------------|-------------------------| -| `/v1/api/opds*` | None | N/A | Unlimited | N/A | -| `/v1/api/items` | None | N/A | Unlimited | N/A | -| `/v1/api/upload` | 20/min | 5 | 25 | 503 (Nginx) or 429 (slowapi) | -| `/v1/api/authenticate` | 20/min | 5 | 25 | 503 (Nginx) or 429 (slowapi) | -| Other `/v1/api/*` | 100/min | 10 | 110 | 503 (Nginx) or 429 (slowapi) | - ---- - -## Troubleshooting - -### Rate limiting not working? - -1. **Check Nginx configuration is loaded**: - ```bash - docker exec lenny_api nginx -t - docker exec lenny_api nginx -s reload - ``` - -2. **Verify slowapi is installed**: - ```bash - docker exec lenny_api pip list | grep slowapi - ``` - -3. **Check if rate limit zones are active**: - ```bash - docker exec lenny_api cat /etc/nginx/nginx.conf | grep limit_req_zone - ``` - -4. **Restart services**: - ```bash - make restart - # or - docker compose restart api - ``` - -### Rate limiting too aggressive? - -- Adjust limits in `lenny/core/ratelimit.py` or via environment variables -- Adjust Nginx limits in `docker/nginx/nginx.conf` -- Restart services after changes - ---- - -## Quick Verification Commands - -```bash -# 1. Check services are running -docker ps | grep lenny - -# 2. Test OPDS (should work unlimited) -curl http://localhost:8080/v1/api/opds - -# 3. Test rate limited endpoint (make 120 requests) -for i in {1..120}; do curl -s http://localhost:8080/v1/api/items/borrowed; done | tail -5 - -# 4. Check logs for rate limit messages -docker compose logs api | grep -i "rate\|429\|503" | tail -10 -``` - diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index cd32bfb..763df11 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -16,7 +16,7 @@ http { keepalive_timeout 65; client_max_body_size 50M; - # Rate limiting zones + # General zone: 100 requests per minute (1.67 req/sec) with burst of 10 limit_req_zone $binary_remote_addr zone=general_limit:10m rate=100r/m; diff --git a/lenny/core/ratelimit.py b/lenny/core/ratelimit.py index bfbae0c..e49db90 100644 --- a/lenny/core/ratelimit.py +++ b/lenny/core/ratelimit.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """ Rate limiting configuration for Lenny. @@ -26,21 +25,15 @@ def _parse_rate_limit(env_var: str, default: int) -> int: Returns the integer count of requests. """ value = os.environ.get(env_var, str(default)) - # If it's already a string like '100/minute', extract the number if '/' in str(value): return int(value.split('/')[0]) - # Otherwise, treat as integer return int(value) -# Rate limits as number of requests per window -# These are converted to slowapi-compatible strings below # Supports both integer (100) and string ('100/minute') formats for backward compatibility RATE_LIMIT_GENERAL_COUNT = _parse_rate_limit('RATE_LIMIT_GENERAL', 100) RATE_LIMIT_LENIENT_COUNT = _parse_rate_limit('RATE_LIMIT_LENIENT', 300) RATE_LIMIT_STRICT_COUNT = _parse_rate_limit('RATE_LIMIT_STRICT', 20) -# Convert to slowapi-compatible string format (requests per minute) -# This ensures TTL compatibility between nginx and slowapi RATE_LIMIT_GENERAL = f'{RATE_LIMIT_GENERAL_COUNT}/minute' RATE_LIMIT_LENIENT = f'{RATE_LIMIT_LENIENT_COUNT}/minute' RATE_LIMIT_STRICT = f'{RATE_LIMIT_STRICT_COUNT}/minute' From 653847810e1e701dae102fca40c4a92f4e4941ae Mon Sep 17 00:00:00 2001 From: Shikhar Date: Mon, 24 Nov 2025 04:25:14 +0530 Subject: [PATCH 3/3] removed nginx rate-limiter logic --- docker/nginx/conf.d/lenny.conf | 30 ------------------------------ docker/nginx/nginx.conf | 11 ----------- 2 files changed, 41 deletions(-) diff --git a/docker/nginx/conf.d/lenny.conf b/docker/nginx/conf.d/lenny.conf index 8edbf3e..f2df257 100644 --- a/docker/nginx/conf.d/lenny.conf +++ b/docker/nginx/conf.d/lenny.conf @@ -12,37 +12,7 @@ server { add_header Access-Control-Allow-Headers "Content-Type, Authorization" always; add_header Access-Control-Allow-Credentials false always; - # OPDS endpoints without rate limiting - location ~ ^/v1/api/opds { - proxy_pass http://lenny_api:1337; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # Items endpoint without rate limiting - location = /v1/api/items { - proxy_pass http://lenny_api:1337/v1/api; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # Strict rate limiting for sensitive endpoints - location ~ ^/v1/api/(upload|authenticate) { - limit_req zone=strict_limit burst=5 nodelay; - proxy_pass http://lenny_api:1337; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # General API endpoints with standard rate limiting location /v1/api { - limit_req zone=general_limit burst=10 nodelay; proxy_pass http://lenny_api:1337/v1/api; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index 763df11..74e2a88 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -15,17 +15,6 @@ http { sendfile on; keepalive_timeout 65; client_max_body_size 50M; - - - # General zone: 100 requests per minute (1.67 req/sec) with burst of 10 - limit_req_zone $binary_remote_addr zone=general_limit:10m rate=100r/m; - - # Lenient zone for OPDS endpoints: 300 requests per minute (5 req/sec) with burst of 20 - limit_req_zone $binary_remote_addr zone=lenient_limit:10m rate=300r/m; - - # Strict zone for sensitive endpoints: 20 requests per minute (0.33 req/sec) with burst of 5 - limit_req_zone $binary_remote_addr zone=strict_limit:10m rate=20r/m; - include /etc/nginx/conf.d/*.conf; }