From 827592b6e79a96cf8f0edc2b7d18d1ea3a1511b4 Mon Sep 17 00:00:00 2001 From: ghostfaccee Date: Mon, 6 Jul 2026 15:27:14 +0900 Subject: [PATCH 1/2] feat(rate-limit, redis): add redis-based rate-limiting for all endpoints --- .env.example | 2 ++ .github/workflows/docker.yml | 1 + .gitignore | 3 ++- app/api/v1/endpoints/habits.py | 18 +++++++++----- app/api/v1/endpoints/log.py | 9 ++++--- app/api/v1/endpoints/stats.py | 6 +++-- app/core/config.py | 1 + app/infrastructure/__init__.py | 0 app/infrastructure/redis/__init__.py | 0 app/infrastructure/redis/redis.py | 24 +++++++++++++++++++ app/main.py | 7 +++++- app/middlewares/__init__.py | 0 app/middlewares/logging/__init__.py | 0 .../logging/logging_middleware.py} | 0 app/middlewares/rate_limit/__init__.py | 0 app/middlewares/rate_limit/limiter.py | 20 ++++++++++++++++ docker-compose.yml | 12 +++++++++- requirements.txt | 5 ++++ tests/conftest.py | 19 +++++++++++++++ 19 files changed, 113 insertions(+), 14 deletions(-) create mode 100644 app/infrastructure/__init__.py create mode 100644 app/infrastructure/redis/__init__.py create mode 100644 app/infrastructure/redis/redis.py create mode 100644 app/middlewares/__init__.py create mode 100644 app/middlewares/logging/__init__.py rename app/{core/middleware.py => middlewares/logging/logging_middleware.py} (100%) create mode 100644 app/middlewares/rate_limit/__init__.py create mode 100644 app/middlewares/rate_limit/limiter.py diff --git a/.env.example b/.env.example index 9f38ec0..ced86d9 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,8 @@ POSTGRES_PASSWORD = POSTGRES_DB = DATABASE_URL = +REDIS_URL = + DEBUG = SLOW_REQUEST_THRESHOLD = \ No newline at end of file diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 6a174e7..5d006cf 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -13,6 +13,7 @@ env: DATABASE_URL: ${{ secrets.DATABASE_URL }} DEBUG: ${{ secrets.DEBUG }} SLOW_REQUEST_THRESHOLD: ${{ secrets.SLOW_REQUEST_THRESHOLD }} + REDIS_URL: ${{ secrets.REDIS_URL }} jobs: diff --git a/.gitignore b/.gitignore index aeca225..d05c75e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ __pycache__ *.db .env check-list-docker.md -ideas.md \ No newline at end of file +ideas.md +my_notes.md \ No newline at end of file diff --git a/app/api/v1/endpoints/habits.py b/app/api/v1/endpoints/habits.py index fe9d8d7..b5e2066 100644 --- a/app/api/v1/endpoints/habits.py +++ b/app/api/v1/endpoints/habits.py @@ -1,29 +1,35 @@ -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, status, Request from typing import List from app.dependencies import get_habit_service from app.services.habit_service import HabitService from app.schemas.habit import HabitCreate, HabitUpdate, HabitResponse +from app.middlewares.rate_limit.limiter import limiter router = APIRouter() @router.get('/habits', response_model = List[HabitResponse]) -async def get_all(service: HabitService = Depends(get_habit_service)): +@limiter.limit('5/minute') +async def get_all(request: Request, service: HabitService = Depends(get_habit_service)): return await service.get_all() @router.post('/habit/create', response_model = HabitResponse, status_code = status.HTTP_201_CREATED) -async def create(data: HabitCreate, service: HabitService = Depends(get_habit_service)): +@limiter.limit('5/minute') +async def create(request: Request, data: HabitCreate, service: HabitService = Depends(get_habit_service)): return await service.create(data) @router.get('/habit/{habit_id}', response_model = HabitResponse) -async def get_by_id(habit_id: int, service: HabitService = Depends(get_habit_service)): +@limiter.limit('5/minute') +async def get_by_id(request: Request, habit_id: int, service: HabitService = Depends(get_habit_service)): return await service.get_by_id(habit_id) @router.delete('/habit/{habit_id}', status_code = status.HTTP_204_NO_CONTENT) -async def delete_by_id(habit_id: int, service: HabitService = Depends(get_habit_service)): +@limiter.limit('5/minute') +async def delete_by_id(request: Request, habit_id: int, service: HabitService = Depends(get_habit_service)): await service.delete(habit_id) return None @router.put('/habit/{habit_id}', response_model = HabitResponse) -async def update(habit_id: int, data: HabitUpdate, service: HabitService = Depends(get_habit_service)): +@limiter.limit('5/minute') +async def update(request: Request, habit_id: int, data: HabitUpdate, service: HabitService = Depends(get_habit_service)): return await service.update(habit_id, data) diff --git a/app/api/v1/endpoints/log.py b/app/api/v1/endpoints/log.py index b7a2452..0ef8fa6 100644 --- a/app/api/v1/endpoints/log.py +++ b/app/api/v1/endpoints/log.py @@ -1,16 +1,19 @@ -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, status, Request from app.dependencies import get_log_service from app.services.log_service import LogService from app.schemas.log import LogCreate from app.schemas.log import LogResponse +from app.middlewares.rate_limit.limiter import limiter router = APIRouter() @router.post('/log/{habit_id}', response_model = LogResponse, status_code = status.HTTP_201_CREATED) -async def create(habit_id: int, data: LogCreate, service: LogService = Depends(get_log_service)): +@limiter.limit('1/day') +async def create(request: Request, habit_id: int, data: LogCreate, service: LogService = Depends(get_log_service)): return await service.create(habit_id, data) @router.delete('/log/{log_id}', status_code = status.HTTP_204_NO_CONTENT) -async def delete(log_id: int, service: LogService = Depends(get_log_service)): +@limiter.limit('5/minute') +async def delete(request: Request, log_id: int, service: LogService = Depends(get_log_service)): await service.delete(log_id) return None diff --git a/app/api/v1/endpoints/stats.py b/app/api/v1/endpoints/stats.py index f74a5d9..e457d4b 100644 --- a/app/api/v1/endpoints/stats.py +++ b/app/api/v1/endpoints/stats.py @@ -1,10 +1,12 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request from app.dependencies import get_stats_service from app.services.stats_service import StatsService from app.schemas.stats import StatsResponse +from app.middlewares.rate_limit.limiter import limiter router = APIRouter() @router.get('/stats/{habit_id}', response_model = StatsResponse) -async def get(habit_id: int, service: StatsService = Depends(get_stats_service)): +@limiter.limit('5/minute') +async def get(request: Request, habit_id: int, service: StatsService = Depends(get_stats_service)): return await service.get_stats(habit_id) \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py index bfb48ac..277aecd 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -6,6 +6,7 @@ class Settings(BaseSettings): POSTGRES_PASSWORD: str POSTGRES_DB: str DATABASE_URL: str + REDIS_URL: str SLOW_REQUEST_THRESHOLD: float DEBUG: bool = True diff --git a/app/infrastructure/__init__.py b/app/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/infrastructure/redis/__init__.py b/app/infrastructure/redis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/infrastructure/redis/redis.py b/app/infrastructure/redis/redis.py new file mode 100644 index 0000000..ed5902d --- /dev/null +++ b/app/infrastructure/redis/redis.py @@ -0,0 +1,24 @@ +# Redis client for caching and storing temporary data. Currently not used in the project and reserved for future innovations. + +import redis.asyncio as redis +from app.core.config import settings + +class RedisClient: + _client = None + + @classmethod + async def get_client(cls) -> redis.Redis: + if cls._client is None: + cls._client = redis.from_url( + settings.REDIS_URL, + decode_responses = True, + max_connections = 10 + ) + return cls._client + + @classmethod + async def close(cls): + if cls._client: + await cls._client.aclose() + cls._client = None + diff --git a/app/main.py b/app/main.py index ee2c6f9..a4266bc 100644 --- a/app/main.py +++ b/app/main.py @@ -2,9 +2,11 @@ from fastapi import FastAPI, Request, status from fastapi.responses import JSONResponse +from slowapi.errors import RateLimitExceeded from contextlib import asynccontextmanager from app.core.database import engine, Base -from app.core.middleware import LoggingMiddleware +from app.middlewares.rate_limit.limiter import limiter, rate_limit_exceed_handler +from app.middlewares.logging.logging_middleware import LoggingMiddleware from app.api import router from app.core.logger import logger @@ -17,6 +19,9 @@ async def lifespan(app: FastAPI): app = FastAPI(lifespan = lifespan) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, rate_limit_exceed_handler) + @app.exception_handler(Exception) async def global_exeption_handler(request: Request, exc: Exception): logger.error( diff --git a/app/middlewares/__init__.py b/app/middlewares/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/middlewares/logging/__init__.py b/app/middlewares/logging/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/middleware.py b/app/middlewares/logging/logging_middleware.py similarity index 100% rename from app/core/middleware.py rename to app/middlewares/logging/logging_middleware.py diff --git a/app/middlewares/rate_limit/__init__.py b/app/middlewares/rate_limit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/middlewares/rate_limit/limiter.py b/app/middlewares/rate_limit/limiter.py new file mode 100644 index 0000000..a7efacb --- /dev/null +++ b/app/middlewares/rate_limit/limiter.py @@ -0,0 +1,20 @@ +from slowapi import Limiter +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded +from fastapi import Request, status +import redis.asyncio as redis +from app.core.config import settings +from fastapi.responses import JSONResponse + +limiter = Limiter( + key_func = get_remote_address, + storage_uri = settings.REDIS_URL, + default_limits = ['20/minute'] +) + +def rate_limit_exceed_handler(request: Request, exc: RateLimitExceeded): + return JSONResponse( + status_code = status.HTTP_429_TOO_MANY_REQUESTS, + content = {'detail' : 'Too many requests.'} + ) + diff --git a/docker-compose.yml b/docker-compose.yml index a9b1f84..de3c198 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,13 +8,23 @@ services: POSTGRES_DB: ${POSTGRES_DB} ports: - "5432:5432" + restart: unless-stopped volumes: - postgres_data:/var/lib/postgresql/data - + + redis: + image: redis:8.8-alpine + container_name: trackit-redis + ports: + - "6379:6379" + restart: unless-stopped api: build: . container_name: trackit-api + depends_on: + - db + - redis ports: - "8000:8000" environment: diff --git a/requirements.txt b/requirements.txt index 6166ffe..f617fef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ asgi-lifespan==2.1.0 asyncpg==0.31.0 certifi==2026.6.17 click==8.4.2 +Deprecated==1.3.1 fastapi==0.138.1 greenlet==3.5.3 h11==0.16.0 @@ -13,6 +14,7 @@ httpcore==1.0.9 httpx==0.28.1 idna==3.18 iniconfig==2.3.0 +limits==5.8.0 loguru==0.7.3 packaging==26.2 pluggy==1.6.0 @@ -23,9 +25,12 @@ Pygments==2.20.0 pytest==9.1.1 pytest-asyncio==1.4.0 python-dotenv==1.2.2 +redis==8.0.1 +slowapi==0.1.10 sniffio==1.3.1 SQLAlchemy==2.0.51 starlette==1.3.1 typing-inspection==0.4.2 typing_extensions==4.15.0 uvicorn==0.49.0 +wrapt==2.2.2 diff --git a/tests/conftest.py b/tests/conftest.py index e407b87..476935f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,8 +2,27 @@ from httpx import AsyncClient, ASGITransport from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from app.core.database import Base, get_db + +import app.middlewares.rate_limit.limiter as rate_limit_module +from slowapi import Limiter +from slowapi.util import get_remote_address + +def mock_limit(self, limit_str: str): + def decorator(func): + return func + return decorator + +original_limiter_class = Limiter +rate_limit_module.limiter = Limiter( + key_func=get_remote_address, + default_limits=["100/minute"] +) +rate_limit_module.limiter.limit = mock_limit.__get__(rate_limit_module.limiter, Limiter) + from app.main import app +app.state.limiter = rate_limit_module.limiter + TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" engine = create_async_engine(TEST_DATABASE_URL, echo=False) TestingSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) From bde8df85e4ad564c68b6435c7cd7c23eb8e232a3 Mon Sep 17 00:00:00 2001 From: ghostfaccee Date: Mon, 6 Jul 2026 15:29:59 +0900 Subject: [PATCH 2/2] ci(ci.yml): add redis-url --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4145f82..88a8187 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ env: DATABASE_URL: ${{ secrets.DATABASE_URL }} DEBUG: ${{ secrets.DEBUG }} SLOW_REQUEST_THRESHOLD: ${{ secrets.SLOW_REQUEST_THRESHOLD }} + REDIS_URL: ${{ secrets.REDIS_URL }} jobs: test: