diff --git a/.env b/.env index 1d44286e25..067000ca5e 100644 --- a/.env +++ b/.env @@ -43,3 +43,10 @@ SENTRY_DSN= # Configure these with your own Docker registry images DOCKER_IMAGE_BACKEND=backend DOCKER_IMAGE_FRONTEND=frontend + +# Redis config +REDIS_URL=redis://redis:6379/0 + +# Rate Limiting config +RATE_LIMITER_STRATEGY=sliding_window +RATE_LIMIT_FAIL_OPEN=false diff --git a/backend/app/alembic/rate_limiting_algorithms/sliding_window.lua b/backend/app/alembic/rate_limiting_algorithms/sliding_window.lua new file mode 100644 index 0000000000..732629c593 --- /dev/null +++ b/backend/app/alembic/rate_limiting_algorithms/sliding_window.lua @@ -0,0 +1,34 @@ +-- KEYS[1] = key +-- ARGV[1] = now_ms +-- ARGV[2] = window_ms +-- ARGV[3] = limit +-- ARGV[4] = member + +local key = KEYS[1] +local now = tonumber(ARGV[1]) +local window = tonumber(ARGV[2]) +local limit = tonumber(ARGV[3]) +local member = ARGV[4] + +local min_score = now - window + +-- remove old entries +redis.call('ZREMRANGEBYSCORE', key, 0, min_score) + +-- add current +redis.call('ZADD', key, now, member) + +-- count +local cnt = redis.call('ZCARD', key) + +-- expire same as window +redis.call('PEXPIRE', key, window) + +-- fetch oldest +local earliest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES') +local oldest_ts = 0 +if earliest ~= false and earliest ~= nil and #earliest >= 2 then + oldest_ts = earliest[2] +end + +return {cnt, oldest_ts} \ No newline at end of file diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 6429818458..67e6c354a0 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -11,6 +11,8 @@ get_current_active_superuser, ) from app.core.config import settings +from app.core.rate_limiter.key_strategy.key_strategy_enum import KeyStrategyName +from app.core.rate_limiter.rate_limiter import RateLimiter from app.core.security import get_password_hash, verify_password from app.models import ( Item, @@ -31,7 +33,12 @@ @router.get( "/", - dependencies=[Depends(get_current_active_superuser)], + dependencies=[ + Depends( + RateLimiter(limit=10, window_seconds=60, key_policy=KeyStrategyName.IP) + ), + Depends(get_current_active_superuser), + ], response_model=UsersPublic, ) def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 6a8ca50bb1..237c3894cb 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -41,6 +41,11 @@ class Settings(BaseSettings): list[AnyUrl] | str, BeforeValidator(parse_cors) ] = [] + # Correct Redis default inside Docker Compose + REDIS_URL: str = "" + RATE_LIMITER_STRATEGY: Literal["none", "sliding_window"] = "none" + RATE_LIMIT_FAIL_OPEN: bool = True + @computed_field # type: ignore[prop-decorator] @property def all_cors_origins(self) -> list[str]: @@ -48,6 +53,18 @@ def all_cors_origins(self) -> list[str]: self.FRONTEND_HOST ] + @computed_field # type: ignore[prop-decorator] + @property + def rate_limit_enabled(self) -> bool: + """ + Returns True if rate limiting should be enabled based on strategy. + Mirrors the style of all_cors_origins. + """ + strategy = (self.RATE_LIMITER_STRATEGY or "").strip().lower() + redis_url = (self.REDIS_URL or "").strip() + + return strategy not in ("", "none") and bool(redis_url) + PROJECT_NAME: str SENTRY_DSN: HttpUrl | None = None POSTGRES_SERVER: str diff --git a/backend/app/core/rate_limiter/__init__.py b/backend/app/core/rate_limiter/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/app/core/rate_limiter/key_strategy/__init__.py b/backend/app/core/rate_limiter/key_strategy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/app/core/rate_limiter/key_strategy/header_key_strategy.py b/backend/app/core/rate_limiter/key_strategy/header_key_strategy.py new file mode 100644 index 0000000000..7845f640de --- /dev/null +++ b/backend/app/core/rate_limiter/key_strategy/header_key_strategy.py @@ -0,0 +1,12 @@ +from starlette.requests import Request + +from app.core.rate_limiter.key_strategy.key_strategy import KeyStrategy + + +class HeaderKeyStrategy(KeyStrategy): + def __init__(self, header_name: str = "X-Client-ID"): + self.header_name = header_name + + def get_key(self, request: Request, route_path: str) -> str: + value = request.headers.get(self.header_name, "unknown") + return f"header:{self.header_name}:{value}:{route_path}" diff --git a/backend/app/core/rate_limiter/key_strategy/ip_key_strategy.py b/backend/app/core/rate_limiter/key_strategy/ip_key_strategy.py new file mode 100644 index 0000000000..aa7c75f1ee --- /dev/null +++ b/backend/app/core/rate_limiter/key_strategy/ip_key_strategy.py @@ -0,0 +1,11 @@ +from starlette.requests import Request + +from app.core.rate_limiter.key_strategy.key_strategy import KeyStrategy + + +class IPKeyStrategy(KeyStrategy): + """Generate rate limit key based on client IP address.""" + + def get_key(self, request: Request, route_path: str) -> str: + client_ip = request.client.host if request.client else "unknown" + return f"ip:{client_ip}:{route_path}" diff --git a/backend/app/core/rate_limiter/key_strategy/key_strategy.py b/backend/app/core/rate_limiter/key_strategy/key_strategy.py new file mode 100644 index 0000000000..c27d66b5fe --- /dev/null +++ b/backend/app/core/rate_limiter/key_strategy/key_strategy.py @@ -0,0 +1,12 @@ +from abc import ABC, abstractmethod + +from starlette.requests import Request + + +class KeyStrategy(ABC): + """Base interface for rate-limit key generation.""" + + @abstractmethod + def get_key(self, request: Request, route_path: str) -> str: + """Return unique identifier string (e.g., 'ip:127.0.0.1')""" + raise NotImplementedError diff --git a/backend/app/core/rate_limiter/key_strategy/key_strategy_enum.py b/backend/app/core/rate_limiter/key_strategy/key_strategy_enum.py new file mode 100644 index 0000000000..4e36bdc43b --- /dev/null +++ b/backend/app/core/rate_limiter/key_strategy/key_strategy_enum.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class KeyStrategyName(str, Enum): + IP = "ip" + HEADER = "header" diff --git a/backend/app/core/rate_limiter/key_strategy/key_strategy_registry.py b/backend/app/core/rate_limiter/key_strategy/key_strategy_registry.py new file mode 100644 index 0000000000..9618cdb873 --- /dev/null +++ b/backend/app/core/rate_limiter/key_strategy/key_strategy_registry.py @@ -0,0 +1,16 @@ +from app.core.rate_limiter.key_strategy.header_key_strategy import HeaderKeyStrategy +from app.core.rate_limiter.key_strategy.ip_key_strategy import IPKeyStrategy +from app.core.rate_limiter.key_strategy.key_strategy import KeyStrategy +from app.core.rate_limiter.key_strategy.key_strategy_enum import KeyStrategyName + + +def get_key_strategy( + name: KeyStrategyName, header_name: str | None = None +) -> KeyStrategy: + if name == KeyStrategyName.IP: + return IPKeyStrategy() + + if name == KeyStrategyName.HEADER: + return HeaderKeyStrategy(header_name=header_name or "X-Client-ID") + + raise ValueError(f"Unsupported key strategy: {name}") diff --git a/backend/app/core/rate_limiter/rate_limiter.py b/backend/app/core/rate_limiter/rate_limiter.py new file mode 100644 index 0000000000..872535dfb1 --- /dev/null +++ b/backend/app/core/rate_limiter/rate_limiter.py @@ -0,0 +1,52 @@ +import logging + +from fastapi import HTTPException, Request + +from app.core.rate_limiter.key_strategy import key_strategy_registry +from app.core.rate_limiter.key_strategy.key_strategy_enum import KeyStrategyName + +logger = logging.getLogger(__name__) + + +class RateLimiter: + def __init__( + self, + limit: int, + window_seconds: int, + key_policy: KeyStrategyName = KeyStrategyName.IP, + ): + self.limit = limit + self.window_seconds = window_seconds + self.key_policy = key_policy + + async def __call__(self, request: Request) -> None: + rate_limiter = getattr(request.app.state, "rate_limiter", None) + + if rate_limiter is None: + return None + + # Create Key + key_strategy = key_strategy_registry.get_key_strategy(self.key_policy) + path: str = request.scope.get("path") or "" + key = key_strategy.get_key(request, path) + + allowed = True + retry_after = None + try: + allowed, retry_after = await rate_limiter.allow_request( + key, self.limit, self.window_seconds + ) + except Exception: + logger.exception("Error invoking rate limiter") + if rate_limiter.get_fail_open(): + raise HTTPException( + status_code=503, + detail={"detail": "Rate limiter unavailable"}, + ) + + if not allowed: + raise HTTPException( + status_code=429, + detail=f"Too Many Requests. Retry after {retry_after}s", + headers={"Retry-After": str(retry_after)}, + ) diff --git a/backend/app/core/rate_limiter/rate_limiting_algorithm/__init__.py b/backend/app/core/rate_limiter/rate_limiting_algorithm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/app/core/rate_limiter/rate_limiting_algorithm/base.py b/backend/app/core/rate_limiter/rate_limiting_algorithm/base.py new file mode 100644 index 0000000000..dbbf6aaf5c --- /dev/null +++ b/backend/app/core/rate_limiter/rate_limiting_algorithm/base.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class BaseRateLimiter(ABC): + """Interface for pluggable rate limiter strategies.""" + + @abstractmethod + async def allow_request( + self, key: str, limit: int, window_seconds: int, member_id: str | None = None + ) -> tuple[bool, int | None]: + """ + Return (allowed: bool, retry_after_seconds: Optional[int]). + If allowed True -> retry_after_seconds is None. + If allowed False -> retry_after_seconds is seconds until next allowed request. + """ + raise NotImplementedError diff --git a/backend/app/core/rate_limiter/rate_limiting_algorithm/registry.py b/backend/app/core/rate_limiter/rate_limiting_algorithm/registry.py new file mode 100644 index 0000000000..3855b35253 --- /dev/null +++ b/backend/app/core/rate_limiter/rate_limiting_algorithm/registry.py @@ -0,0 +1,27 @@ +import redis.asyncio as redis + +from app.core.rate_limiter.rate_limiting_algorithm.base import BaseRateLimiter +from app.core.rate_limiter.rate_limiting_algorithm.sliding_window import ( + SlidingWindowRateLimiter, +) + + +def get_rate_limiter( + strategy: str | None, redis_url: str | None, fail_open: bool | None +) -> BaseRateLimiter | None: + """ + Factory: returns an instance of BaseRateLimiter or None (if disabled). + """ + if not strategy or strategy.lower() in ("none", "null", ""): + return None + + if not redis_url: + return None + + rc: redis.Redis = redis.from_url(redis_url, encoding="utf-8", decode_responses=True) # type: ignore[no-untyped-call] + st = strategy.lower() + if st == "sliding_window" or st == "sliding-window": + return SlidingWindowRateLimiter(rc, fail_open or False) + + # extendable for other strategies + raise ValueError(f"Unknown rate limiter strategy: {strategy}") diff --git a/backend/app/core/rate_limiter/rate_limiting_algorithm/sliding_window.py b/backend/app/core/rate_limiter/rate_limiting_algorithm/sliding_window.py new file mode 100644 index 0000000000..bdf8297214 --- /dev/null +++ b/backend/app/core/rate_limiter/rate_limiting_algorithm/sliding_window.py @@ -0,0 +1,61 @@ +import logging +import time +from pathlib import Path + +import redis.asyncio as redis + +from app.core.rate_limiter.rate_limiting_algorithm.base import BaseRateLimiter + +logger = logging.getLogger(__name__) + +SCRIPT_PATH = ( + Path(__file__).resolve().parents[3] + / "alembic" + / "rate_limiting_algorithms" + / "sliding_window.lua" +) + + +class SlidingWindowRateLimiter(BaseRateLimiter): + def __init__(self, redis_client: redis.Redis, fail_open: bool): + self.redis = redis_client + self.lua_script = None + self.fail_open = fail_open + + async def load_script(self) -> str | None: + if self.lua_script is None: + script_text = SCRIPT_PATH.read_text() + # LOAD script into redis → returns SHA + self.lua_script = await self.redis.script_load(script_text) + return self.lua_script + + async def allow_request( + self, key: str, limit: int, window_seconds: int, member_id: str | None = None + ) -> tuple[bool, int | None]: + now_ms = int(time.time() * 1000) + window_ms = window_seconds * 1000 + member = member_id or f"{now_ms}" + + try: + sha = await self.load_script() + if sha is None: + raise Exception + res = await self.redis.evalsha( # type: ignore[misc] + sha, 1, key, now_ms, window_ms, limit, member + ) + except Exception: + logger.exception("Redis error; failing open") + return True, None + + cnt, oldest_ts = int(res[0]), int(res[1] or 0) + + if cnt <= limit: + return True, None + + retry_after_ms = (oldest_ts + window_ms) - now_ms + retry_after_s = max(0, retry_after_ms // 1000) + + return False, retry_after_s + + def get_fail_open(self) -> bool: + return self.fail_open diff --git a/backend/app/main.py b/backend/app/main.py index 9a95801e74..ee1fd5501d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,6 +5,7 @@ from app.api.main import api_router from app.core.config import settings +from app.core.rate_limiter.rate_limiting_algorithm.registry import get_rate_limiter def custom_generate_unique_id(route: APIRoute) -> str: @@ -30,4 +31,13 @@ def custom_generate_unique_id(route: APIRoute) -> str: allow_headers=["*"], ) +# Set rate Limiting +if settings.rate_limit_enabled: + rate_limiter = get_rate_limiter( + settings.RATE_LIMITER_STRATEGY, + settings.REDIS_URL, + settings.RATE_LIMIT_FAIL_OPEN, + ) + app.state.rate_limiter = rate_limiter + app.include_router(api_router, prefix=settings.API_V1_STR) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d72454c28a..de47a26883 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -21,6 +21,8 @@ dependencies = [ "pydantic-settings<3.0.0,>=2.2.1", "sentry-sdk[fastapi]<2.0.0,>=1.40.6", "pyjwt<3.0.0,>=2.8.0", + + "redis>=4.6.0", ] [tool.uv] @@ -31,6 +33,8 @@ dev-dependencies = [ "pre-commit<4.0.0,>=3.6.2", "types-passlib<2.0.0.0,>=1.7.7.20240106", "coverage<8.0.0,>=7.4.3", + + "pytest-asyncio", ] [build-system] diff --git a/backend/tests/rate_limiter/test_dependency_rate_limit.py b/backend/tests/rate_limiter/test_dependency_rate_limit.py new file mode 100644 index 0000000000..d35bd47d20 --- /dev/null +++ b/backend/tests/rate_limiter/test_dependency_rate_limit.py @@ -0,0 +1,48 @@ +from unittest.mock import AsyncMock + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from app.core.rate_limiter.rate_limiter import RateLimiter + + +@pytest.fixture +def mock_limiter(): + limiter = AsyncMock() + limiter.allow_request = AsyncMock(return_value=(True, 0)) + return limiter + + +def test_dependency_allows_request(mock_limiter): + app = FastAPI() + + @app.get("/test", dependencies=[Depends(RateLimiter(limit=2, window_seconds=5))]) + async def endpoint(): + return {"ok": True} + + app.state.rate_limiter = mock_limiter + + client = TestClient(app) + resp = client.get("/test") + + assert resp.status_code == 200 + mock_limiter.allow_request.assert_awaited() + + +def test_dependency_blocks_request(mock_limiter): + mock_limiter.allow_request.return_value = (False, 10) + + app = FastAPI() + + @app.get("/test", dependencies=[Depends(RateLimiter(limit=2, window_seconds=5))]) + async def endpoint(): + return {"ok": True} + + app.state.rate_limiter = mock_limiter + + client = TestClient(app) + resp = client.get("/test") + + assert resp.status_code == 429 + assert "10s" in resp.json()["detail"] diff --git a/backend/tests/rate_limiter/test_sliding_window_logic.py b/backend/tests/rate_limiter/test_sliding_window_logic.py new file mode 100644 index 0000000000..4485b9ee98 --- /dev/null +++ b/backend/tests/rate_limiter/test_sliding_window_logic.py @@ -0,0 +1,97 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.core.rate_limiter.rate_limiting_algorithm.registry import get_rate_limiter +from app.core.rate_limiter.rate_limiting_algorithm.sliding_window import ( + SlidingWindowRateLimiter, +) + + +@pytest.fixture +def mock_redis_from_url(): + with patch("redis.asyncio.from_url") as mocker: + mock_instance = MagicMock() + mocker.return_value = mock_instance + yield mocker + +@pytest.mark.asyncio +async def test_allow_first_request(): + redis = AsyncMock() + redis.evalsha.return_value = [1, 0] # allowed + + limiter = SlidingWindowRateLimiter(redis, True) + allowed, retry_after = await limiter.allow_request("key1", 5, 60) + + assert allowed is True + assert retry_after is None + + +@pytest.mark.asyncio +async def test_block_when_limit_reached(): + redis = AsyncMock() + redis.evalsha.return_value = [3, 20] # blocked + + limiter = SlidingWindowRateLimiter(redis, True) + allowed, retry_after = await limiter.allow_request("key1", 1, 60) + + assert allowed is False + + +@pytest.mark.asyncio +async def test_fail_open_allows_requests(): + redis = AsyncMock() + redis.evalsha.side_effect = Exception("Redis down") + + limiter = SlidingWindowRateLimiter(redis, fail_open=True) + allowed, retry_after = await limiter.allow_request("key", 5, 60) + + assert allowed is True + assert retry_after is None + +def test_get_rate_limiter_none_strategy(mock_redis_from_url): # noqa: ARG001 + rl = get_rate_limiter(strategy="none", redis_url="redis://localhost:6379/0", fail_open=True) + assert rl is None + + +def test_get_rate_limiter_empty_strategy(mock_redis_from_url): # noqa: ARG001 + rl = get_rate_limiter(strategy="", redis_url="redis://localhost:6379/0", fail_open=True) + assert rl is None + + +def test_get_rate_limiter_null_strategy(mock_redis_from_url): # noqa: ARG001 + rl = get_rate_limiter(strategy="null", redis_url="redis://localhost:6379/0", fail_open=True) + assert rl is None + + +def test_get_rate_limiter_no_strategy(mock_redis_from_url): # noqa: ARG001 + rl = get_rate_limiter(strategy=None, redis_url="redis://localhost:6379/0", fail_open=True) + assert rl is None + + +def test_get_rate_limiter_no_redis_url(mock_redis_from_url): # noqa: ARG001 + rl = get_rate_limiter(strategy="sliding_window", redis_url="", fail_open=True) + assert rl is None + + +def test_get_rate_limiter_sliding_window(mock_redis_from_url): # noqa: ARG001 + rl = get_rate_limiter(strategy="sliding_window", redis_url="redis://x", fail_open=True) + assert isinstance(rl, SlidingWindowRateLimiter) + + +def test_get_rate_limiter_sliding_window_dash(mock_redis_from_url): # noqa: ARG001 + rl = get_rate_limiter(strategy="sliding-window", redis_url="redis://x", fail_open=False) + assert isinstance(rl, SlidingWindowRateLimiter) + assert rl.get_fail_open() is False + + +def test_get_rate_limiter_fail_open_coercion(mock_redis_from_url): # noqa: ARG001 + rl = get_rate_limiter(strategy="sliding_window", redis_url="redis://x", fail_open=None) + assert isinstance(rl, SlidingWindowRateLimiter) + assert rl.get_fail_open() is False # default fallback + + +def test_rate_limiter_unknown_strategy(mock_redis_from_url): # noqa: ARG001 + with pytest.raises(ValueError) as e: + get_rate_limiter(strategy="weird_strategy", redis_url="redis://x", fail_open=True) + assert "Unknown rate limiter strategy" in str(e.value) diff --git a/backend/tests/rate_limiter/test_strategy.py b/backend/tests/rate_limiter/test_strategy.py new file mode 100644 index 0000000000..46b6a688a4 --- /dev/null +++ b/backend/tests/rate_limiter/test_strategy.py @@ -0,0 +1,49 @@ +import pytest +from starlette.requests import Request + +from app.core.rate_limiter.key_strategy.header_key_strategy import HeaderKeyStrategy +from app.core.rate_limiter.key_strategy.ip_key_strategy import IPKeyStrategy +from app.core.rate_limiter.key_strategy.key_strategy_enum import KeyStrategyName +from app.core.rate_limiter.key_strategy.key_strategy_registry import get_key_strategy + + +def test_ip_strategy_build_key(): + strat = IPKeyStrategy() + + scope = { + "client": ("127.0.0.1", 5050), + "method": "GET", + "path": "/api/test", + "headers": [], + "type": "http" + } + request = Request(scope) + + key = strat.get_key(request, scope.get('path')) + + assert key.startswith("ip:127.0.0.1") + assert ":/api/test" in key + +def test_get_key_strategy_header_default(): + """ + Should return HeaderKeyStrategy with default header name + when header_name is not provided. + """ + ks = get_key_strategy(KeyStrategyName.HEADER) + assert isinstance(ks, HeaderKeyStrategy) + assert ks.header_name == "X-Client-ID" + + +def test_get_key_strategy_header_custom(): + """Should use user-supplied custom header name.""" + ks = get_key_strategy(KeyStrategyName.HEADER, header_name="X-Auth-ID") + assert isinstance(ks, HeaderKeyStrategy) + assert ks.header_name == "X-Auth-ID" + + +def test_get_key_strategy_invalid(): + """Should throw ValueError for unsupported key strategies.""" + with pytest.raises(ValueError) as e: + get_key_strategy("UNKNOWN") # type: ignore[arg-type] + + assert "Unsupported key strategy" in str(e.value) diff --git a/backend/uv.lock b/backend/uv.lock index 438ead01ae..0a35e0382d 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -63,6 +63,7 @@ dependencies = [ { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "python-multipart" }, + { name = "redis" }, { name = "sentry-sdk", extra = ["fastapi"] }, { name = "sqlmodel" }, { name = "tenacity" }, @@ -74,6 +75,7 @@ dev = [ { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "ruff" }, { name = "types-passlib" }, ] @@ -93,6 +95,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = ">=2.2.1,<3.0.0" }, { name = "pyjwt", specifier = ">=2.8.0,<3.0.0" }, { name = "python-multipart", specifier = ">=0.0.7,<1.0.0" }, + { name = "redis", specifier = ">=4.6.0" }, { name = "sentry-sdk", extras = ["fastapi"], specifier = ">=1.40.6,<2.0.0" }, { name = "sqlmodel", specifier = ">=0.0.21,<1.0.0" }, { name = "tenacity", specifier = ">=8.2.3,<9.0.0" }, @@ -104,10 +107,20 @@ dev = [ { name = "mypy", specifier = ">=1.8.0,<2.0.0" }, { name = "pre-commit", specifier = ">=3.6.2,<4.0.0" }, { name = "pytest", specifier = ">=7.4.3,<8.0.0" }, + { name = "pytest-asyncio" }, { name = "ruff", specifier = ">=0.2.2,<1.0.0" }, { name = "types-passlib", specifier = ">=1.7.7.20240106,<2.0.0.0" }, ] +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + [[package]] name = "bcrypt" version = "4.3.0" @@ -1150,6 +1163,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8", size = 325287, upload-time = "2023-12-31T12:00:13.963Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "0.23.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/b4/0b378b7bf26a8ae161c3890c0b48a91a04106c5713ce81b4b080ea2f4f18/pytest_asyncio-0.23.8.tar.gz", hash = "sha256:759b10b33a6dc61cce40a8bd5205e302978bbbcc00e279a8b61d9a6a3c82e4d3", size = 46920, upload-time = "2024-07-17T17:39:34.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/82/62e2d63639ecb0fbe8a7ee59ef0bc69a4669ec50f6d3459f74ad4e4189a2/pytest_asyncio-0.23.8-py3-none-any.whl", hash = "sha256:50265d892689a5faefb84df80819d1ecef566eb3549cf915dfb33569359d1ce2", size = 17663, upload-time = "2024-07-17T17:39:32.478Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1224,6 +1249,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] +[[package]] +name = "redis" +version = "7.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/8f/f125feec0b958e8d22c8f0b492b30b1991d9499a4315dfde466cf4289edc/redis-7.0.1.tar.gz", hash = "sha256:c949df947dca995dc68fdf5a7863950bf6df24f8d6022394585acc98e81624f1", size = 4755322, upload-time = "2025-10-27T14:34:00.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/97/9f22a33c475cda519f20aba6babb340fb2f2254a02fb947816960d1e669a/redis-7.0.1-py3-none-any.whl", hash = "sha256:4977af3c7d67f8f0eb8b6fec0dafc9605db9343142f634041fb0235f67c0588a", size = 339938, upload-time = "2025-10-27T14:33:58.553Z" }, +] + [[package]] name = "requests" version = "2.32.3" diff --git a/docker-compose.yml b/docker-compose.yml index b1aa17ed43..c718b6d3d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,23 @@ services: - POSTGRES_USER=${POSTGRES_USER?Variable not set} - POSTGRES_DB=${POSTGRES_DB?Variable not set} + redis: + image: redis:7-alpine + restart: always + networks: + - traefik-public + - default + command: ["redis-server", "--appendonly", "yes"] + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + volumes: + - redis-data:/data + adminer: image: adminer restart: always @@ -53,6 +70,8 @@ services: db: condition: service_healthy restart: true + redis: + condition: service_healthy command: bash scripts/prestart.sh env_file: - .env @@ -85,6 +104,8 @@ services: db: condition: service_healthy restart: true + redis: + condition: service_healthy prestart: condition: service_completed_successfully env_file: @@ -164,6 +185,7 @@ services: - traefik.http.routers.${STACK_NAME?Variable not set}-frontend-http.middlewares=https-redirect volumes: app-db-data: + redis-data: networks: traefik-public: