From f5cafca5145d4258b4609187d4eace2b68edba26 Mon Sep 17 00:00:00 2001 From: architect-bot Date: Tue, 18 Aug 2026 15:40:44 +0000 Subject: [PATCH] WIP checkpoint: Redis rate-limiting dependency --- backend/app/config.py | 1 + backend/tests/test_rate_limit.py | 193 +++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 backend/tests/test_rate_limit.py diff --git a/backend/app/config.py b/backend/app/config.py index af23b68..16c7ce4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -16,6 +16,7 @@ class Settings(BaseSettings): # Redis REDIS_URL: str = "redis://localhost:6379/0" + REDIS_TIMEOUT_SECONDS: float = 2.0 # JWT / Auth # NOTE: production must override SECRET_KEY with a secure random value diff --git a/backend/tests/test_rate_limit.py b/backend/tests/test_rate_limit.py new file mode 100644 index 0000000..f06b4cf --- /dev/null +++ b/backend/tests/test_rate_limit.py @@ -0,0 +1,193 @@ +"""Tests for the Redis-backed token-bucket rate limiter. + +These tests use fakeredis (Lua enabled via lupa) so no live Redis server is +required. +""" + +import asyncio +import sys + +import pytest +from fastapi import Depends, FastAPI, Request +from fastapi.testclient import TestClient + +sys.path.insert(0, "/workspace") + +import fakeredis.aioredis +from redis.asyncio import Redis +from redis.exceptions import ConnectionError + +from app.rate_limit import ( + RateLimit, + RateLimitConfig, + RateLimiter, + default_user_scope, + named_limit, + rate_limit, +) + + +@pytest.fixture() +def fake_redis(): + return fakeredis.aioredis.FakeRedis(decode_responses=True) + + +def make_request(client_ip: str = "127.0.0.1") -> Request: + scope = { + "type": "http", + "method": "GET", + "path": "/", + "headers": [], + "client": (client_ip, 12345), + } + return Request(scope) + + +@pytest.mark.asyncio +async def test_token_bucket_allows_burst_then_exhausts(fake_redis): + limiter = RateLimiter( + RateLimitConfig( + limit=RateLimit(name="burst", capacity=3, refill_rate=0.1), + ), + redis=fake_redis, + ) + request = make_request() + + results = [] + for _ in range(4): + result = await limiter.check(request, user_id=42) + results.append(result) + + assert [r.allowed for r in results] == [True, True, True, False] + assert results[0].remaining == 2 + assert results[1].remaining == 1 + assert results[2].remaining == 0 + assert results[3].retry_after > 0 + + +@pytest.mark.asyncio +async def test_retry_after_reflects_refill_rate(fake_redis): + limiter = RateLimiter( + RateLimitConfig( + limit=RateLimit(name="slow", capacity=1, refill_rate=0.5), + ), + redis=fake_redis, + ) + request = make_request() + + assert (await limiter.check(request, user_id=7)).allowed is True + denied = await limiter.check(request, user_id=7) + assert denied.allowed is False + # 1 token / 0.5 tokens per second = 2 seconds. + assert denied.retry_after == 2 + + +@pytest.mark.asyncio +async def test_separate_scopes_get_separate_buckets(fake_redis): + limiter = RateLimiter( + RateLimitConfig( + limit=RateLimit(name="scoped", capacity=1, refill_rate=0.1), + ), + redis=fake_redis, + ) + request = make_request() + + assert (await limiter.check(request, user_id=1)).allowed is True + assert (await limiter.check(request, user_id=2)).allowed is True + assert (await limiter.check(request, user_id=1)).allowed is False + + +@pytest.mark.asyncio +async def test_fail_open_returns_allowed_when_redis_unavailable(): + class BrokenRedis(Redis): + async def eval(self, *args, **kwargs): + raise ConnectionError("down") + + limiter = RateLimiter( + RateLimitConfig( + limit=RateLimit(name="open", capacity=1, refill_rate=0.1, fail_open=True), + ), + redis=BrokenRedis(), + ) + result = await limiter.check(make_request(), user_id=1) + assert result.allowed is True + + +@pytest.mark.asyncio +async def test_fail_closed_returns_denied_with_negative_retry_after(): + class BrokenRedis(Redis): + async def eval(self, *args, **kwargs): + raise ConnectionError("down") + + limiter = RateLimiter( + RateLimitConfig( + limit=RateLimit(name="closed", capacity=1, refill_rate=0.1, fail_open=False), + ), + redis=BrokenRedis(), + ) + result = await limiter.check(make_request(), user_id=1) + assert result.allowed is False + assert result.retry_after == -1 + + +@pytest.mark.asyncio +async def test_fail_closed_call_raises_503(fake_redis): + class BrokenRedis(Redis): + async def eval(self, *args, **kwargs): + raise ConnectionError("down") + + limiter = RateLimiter( + RateLimitConfig( + limit=RateLimit(name="closed", capacity=1, refill_rate=0.1, fail_open=False), + ), + redis=BrokenRedis(), + ) + with pytest.raises(Exception) as exc_info: + await limiter(make_request(), user_id=1) + assert exc_info.value.status_code == 503 + + +def test_named_limit_builds_expected_config(): + config = named_limit( + "chat_send", + capacity=5, + refill_rate=0.2, + fail_open=False, + ) + assert config.limit.name == "chat_send" + assert config.limit.capacity == 5 + assert config.limit.refill_rate == 0.2 + assert config.limit.fail_open is False + assert config.scope is default_user_scope + + +def test_invalid_limit_definition_rejected(): + with pytest.raises(ValueError): + named_limit("bad", capacity=0, refill_rate=1.0) + with pytest.raises(ValueError): + named_limit("bad", capacity=1, refill_rate=0) + + +def test_dependency_integration_returns_429_with_retry_after(): + async def fake_user_id() -> int: + return 99 + + chat_limit = rate_limit( + named_limit("chat_send_test", capacity=2, refill_rate=0.1, fail_open=False), + user_id_dependency=Depends(fake_user_id), + ) + + app = FastAPI() + + @app.get("/probe") + async def probe(_: None = Depends(chat_limit)): + return {"ok": True} + + client = TestClient(app) + assert client.get("/probe").status_code == 200 + assert client.get("/probe").status_code == 200 + + denied = client.get("/probe") + assert denied.status_code == 429 + assert denied.headers["retry-after"] + assert denied.json() == {"detail": "Rate limit exceeded"}