Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
3 changes: 2 additions & 1 deletion quantara/web_app/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from sqlalchemy.orm import Session
import redis.asyncio as redis

from web_app.api.rate_limiter import limiter
from web_app.api.rate_limiter import assert_rate_limiter_backend_available, limiter
from web_app.api.errors import APIError, api_error_handler
from web_app.api.openapi import build_custom_openapi
from web_app.api.dashboard import router as dashboard_router
Expand Down Expand Up @@ -89,6 +89,7 @@ async def lifespan(app: FastAPI):

# Validate required environment variables at startup.
assert_valid_config()
assert_rate_limiter_backend_available()

# Enforce minimum length for session secret at startup
secret = os.getenv("SESSION_SECRET_KEY")
Expand Down
32 changes: 31 additions & 1 deletion quantara/web_app/api/rate_limiter.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,40 @@
import functools
import os

import redis

from fastapi import Request
from slowapi import Limiter
from slowapi.util import get_remote_address

WRITE_LIMIT: str = os.getenv("RATE_LIMIT_WRITE", "5/minute")
USER_DATA_LIMIT: str = os.getenv("RATE_LIMIT_USER_DATA", "30/minute")
READ_LIMIT: str = os.getenv("RATE_LIMIT_READ", "100/minute")
RATE_LIMIT_STORAGE_URI: str = os.getenv("REDIS_URL", "redis://localhost:6379")


def is_production_env(env: str | None = None) -> bool:
return (env or os.getenv("ENV_VERSION", "DEV")).upper() == "PROD"


def allow_in_memory_fallback(env: str | None = None) -> bool:
return not is_production_env(env)


def assert_rate_limiter_backend_available(redis_url: str | None = None) -> None:
"""Fail startup in production when Redis is unavailable for rate limiting."""
if not is_production_env():
return

client = redis.Redis.from_url(
redis_url or RATE_LIMIT_STORAGE_URI,
socket_connect_timeout=2,
socket_timeout=2,
)
try:
client.ping()
finally:
client.close()


def get_wallet_key(request: Request) -> str:
Expand Down Expand Up @@ -73,4 +100,7 @@ def sync_wrapper(*func_args, **func_kwargs):
return decorator


limiter = LazyLimiter()
limiter = LazyLimiter(
storage_uri=RATE_LIMIT_STORAGE_URI,
in_memory_fallback_enabled=allow_in_memory_fallback(),
)
47 changes: 47 additions & 0 deletions quantara/web_app/tests/test_rate_limiter_prod_fallback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import importlib
from unittest.mock import MagicMock, patch


def test_production_disables_in_memory_rate_limit_fallback():
with patch.dict("os.environ", {"ENV_VERSION": "PROD", "REDIS_URL": "redis://redis:6379"}):
import web_app.api.rate_limiter as rate_limiter

importlib.reload(rate_limiter)

assert rate_limiter.allow_in_memory_fallback() is False
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
assert rate_limiter.limiter._in_memory_fallback_enabled is False
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed


def test_development_keeps_in_memory_rate_limit_fallback():
with patch.dict("os.environ", {"ENV_VERSION": "DEV", "REDIS_URL": "redis://127.0.0.1:1"}):
import web_app.api.rate_limiter as rate_limiter

importlib.reload(rate_limiter)

assert rate_limiter.allow_in_memory_fallback() is True
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
assert rate_limiter.limiter._in_memory_fallback_enabled is True
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed


def test_production_startup_pings_redis_backend():
with patch.dict("os.environ", {"ENV_VERSION": "PROD", "REDIS_URL": "redis://redis:6379"}):
import web_app.api.rate_limiter as rate_limiter

importlib.reload(rate_limiter)
fake_client = MagicMock()
with patch.object(rate_limiter.redis.Redis, "from_url", return_value=fake_client) as from_url:
rate_limiter.assert_rate_limiter_backend_available()

from_url.assert_called_once()
fake_client.ping.assert_called_once()
fake_client.close.assert_called_once()


def test_development_startup_skips_redis_ping():
with patch.dict("os.environ", {"ENV_VERSION": "DEV", "REDIS_URL": "redis://127.0.0.1:1"}):
import web_app.api.rate_limiter as rate_limiter

importlib.reload(rate_limiter)
with patch.object(rate_limiter.redis.Redis, "from_url") as from_url:
rate_limiter.assert_rate_limiter_backend_available()

from_url.assert_not_called()
Loading