Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 3 additions & 3 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Marque as etapas conforme for concluindo. Use este arquivo como checklist.
- [x] Etapa 1: health/config/logging/testes basicos
- [x] Etapa 2: CORS e versionamento de rotas + testes
- [x] Etapa 3: Autenticacao JWT + testes
- [ ] Etapa 4: Rate limiting + testes
- [x] Etapa 4: Rate limiting + testes
- [ ] Etapa 5: Roteamento/proxy para microservicos + testes
- [ ] Etapa 6: Padronizacao de respostas/erros + testes
- [ ] Etapa 7: OpenAPI centralizado/ajustes de docs + testes
Expand All @@ -19,8 +19,8 @@ Detalhamento (opcional):
- [x] Etapa 2.3: Testes de CORS/versionamento
- [x] Etapa 3.1: Middleware/dependency de JWT
- [x] Etapa 3.2: Testes de autenticacao
- [ ] Etapa 4.1: Rate limiting
- [ ] Etapa 4.2: Testes de rate limiting
- [x] Etapa 4.1: Rate limiting
- [x] Etapa 4.2: Testes de rate limiting
- [ ] Etapa 5.1: Proxy/roteamento para servicos
- [ ] Etapa 5.2: Testes de roteamento
- [ ] Etapa 6.1: Normalizacao de respostas/erros
Expand Down
11 changes: 10 additions & 1 deletion app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,20 @@ class Settings(BaseSettings):
auth_service_validate_path: str = "/auth/validate"
auth_service_authorize_path: str = "/auth/authorize"
auth_service_timeout_seconds: float = 5.0
rate_limit_enabled: bool = True
rate_limit_requests: int = 100
rate_limit_window_seconds: int = 60
rate_limit_exempt_paths: list[str] = []
rate_limit_key_header: str = "X-Forwarded-For"

model_config = SettingsConfigDict(env_file=".env", case_sensitive=False)

@field_validator(
"cors_allow_origins", "cors_allow_methods", "cors_allow_headers", mode="before"
"cors_allow_origins",
"cors_allow_methods",
"cors_allow_headers",
"rate_limit_exempt_paths",
mode="before",
)
@classmethod
def parse_list_settings(cls, value: Any) -> list[str]:
Expand Down
144 changes: 144 additions & 0 deletions app/core/rate_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
from __future__ import annotations

import math
import time
from dataclasses import dataclass
from threading import Lock
from typing import Callable, Iterable

from fastapi import Request, status
from fastapi.responses import JSONResponse, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint


@dataclass
class RateLimitState:
window_start: float
count: int


@dataclass(frozen=True)
class RateLimitResult:
allowed: bool
limit: int
remaining: int
reset_seconds: int


class RateLimiter:
def __init__(
self,
*,
limit: int,
window_seconds: int,
clock: Callable[[], float] | None = None,
) -> None:
self._limit = limit
self._window_seconds = window_seconds
self._clock = clock or time.monotonic
self._state: dict[str, RateLimitState] = {}
self._lock = Lock()

def hit(self, key: str) -> RateLimitResult:
now = self._clock()
with self._lock:
state = self._state.get(key)
if state is None or now - state.window_start >= self._window_seconds:
state = RateLimitState(window_start=now, count=0)
state.count += 1
self._state[key] = state

remaining = max(self._limit - state.count, 0)
reset_seconds = max(
0, int(math.ceil(self._window_seconds - (now - state.window_start)))
)
allowed = state.count <= self._limit
return RateLimitResult(
allowed=allowed,
limit=self._limit,
remaining=remaining,
reset_seconds=reset_seconds,
)


class RateLimitMiddleware(BaseHTTPMiddleware):
def __init__(
self,
app,
*,
limiter: RateLimiter,
enabled: bool = True,
exempt_paths: Iterable[str] | None = None,
key_header: str = "X-Forwarded-For",
) -> None:
super().__init__(app)
self._limiter = limiter
self._enabled = enabled
self._key_header = key_header
self._exempt_paths = _normalize_exempt_paths(exempt_paths or [])

async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
if not self._enabled or request.method == "OPTIONS":
return await call_next(request)

path = request.url.path
if _is_exempt(path, self._exempt_paths):
return await call_next(request)

key = _get_client_key(request, self._key_header)
result = self._limiter.hit(key)

if not result.allowed:
return JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content={"detail": "rate_limited"},
headers=_rate_limit_headers(result, blocked=True),
)

response = await call_next(request)
response.headers.update(_rate_limit_headers(result, blocked=False))
return response


def _rate_limit_headers(result: RateLimitResult, *, blocked: bool) -> dict[str, str]:
headers = {
"X-RateLimit-Limit": str(result.limit),
"X-RateLimit-Remaining": str(result.remaining),
"X-RateLimit-Reset": str(result.reset_seconds),
}
if blocked:
headers["Retry-After"] = str(result.reset_seconds)
return headers


def _get_client_key(request: Request, key_header: str) -> str:
forwarded = request.headers.get(key_header)
if forwarded:
candidate = forwarded.split(",")[0].strip()
if candidate:
return candidate
if request.client:
return request.client.host
return "unknown"


def _normalize_exempt_paths(paths: Iterable[str]) -> list[str]:
normalized = []
for path in paths:
trimmed = path.strip()
if not trimmed:
continue
cleaned = trimmed.rstrip("/") or "/"
normalized.append(cleaned)
return normalized


def _is_exempt(path: str, exempt_paths: Iterable[str]) -> bool:
for exempt in exempt_paths:
if exempt == "/":
return True
if path == exempt or path.startswith(f"{exempt}/"):
return True
return False
23 changes: 20 additions & 3 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
from fastapi.middleware.cors import CORSMiddleware

from app.api.router import api_router
from app.core.config import get_settings
from app.core.config import Settings, get_settings
from app.core.logging import configure_logging
from app.core.rate_limit import RateLimitMiddleware, RateLimiter


def create_app() -> FastAPI:
settings = get_settings()
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or get_settings()
configure_logging(settings.log_level)

app = FastAPI(
Expand All @@ -23,6 +24,22 @@ def create_app() -> FastAPI:
allow_credentials=settings.cors_allow_credentials,
)

rate_limit_enabled = (
settings.rate_limit_enabled
and settings.rate_limit_requests > 0
and settings.rate_limit_window_seconds > 0
)
app.add_middleware(
RateLimitMiddleware,
limiter=RateLimiter(
limit=settings.rate_limit_requests,
window_seconds=settings.rate_limit_window_seconds,
),
enabled=rate_limit_enabled,
exempt_paths=settings.rate_limit_exempt_paths,
key_header=settings.rate_limit_key_header,
)

app.include_router(api_router, prefix=settings.api_prefix)
return app

Expand Down
44 changes: 44 additions & 0 deletions app/tests/test_rate_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from fastapi.testclient import TestClient

from app.core.config import Settings
from app.main import create_app


def _client(*, limit: int = 2, window_seconds: int = 60) -> TestClient:
settings = Settings(
rate_limit_enabled=True,
rate_limit_requests=limit,
rate_limit_window_seconds=window_seconds,
)
app = create_app(settings)
return TestClient(app)


def test_rate_limit_blocks_after_limit() -> None:
client = _client(limit=2)
headers = {"X-Forwarded-For": "203.0.113.10"}

assert client.get("/api/v1/health", headers=headers).status_code == 200
assert client.get("/api/v1/health", headers=headers).status_code == 200

response = client.get("/api/v1/health", headers=headers)

assert response.status_code == 429
assert response.headers["retry-after"].isdigit()
assert response.json() == {"detail": "rate_limited"}


def test_rate_limit_is_per_client() -> None:
client = _client(limit=1)

response_a = client.get("/api/v1/health", headers={"X-Forwarded-For": "10.0.0.1"})
response_b = client.get("/api/v1/health", headers={"X-Forwarded-For": "10.0.0.2"})

assert response_a.status_code == 200
assert response_b.status_code == 200

blocked_a = client.get("/api/v1/health", headers={"X-Forwarded-For": "10.0.0.1"})
blocked_b = client.get("/api/v1/health", headers={"X-Forwarded-For": "10.0.0.2"})

assert blocked_a.status_code == 429
assert blocked_b.status_code == 429
5 changes: 5 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,8 @@ AUTH_SERVICE_BASE_URL=http://auth-service:8000
AUTH_SERVICE_VALIDATE_PATH=/auth/validate
AUTH_SERVICE_AUTHORIZE_PATH=/auth/authorize
AUTH_SERVICE_TIMEOUT_SECONDS=5
RATE_LIMIT_ENABLED=true
RATE_LIMIT_REQUESTS=100
RATE_LIMIT_WINDOW_SECONDS=60
RATE_LIMIT_EXEMPT_PATHS=
RATE_LIMIT_KEY_HEADER=X-Forwarded-For