22import time
33import threading
44import math
5+ from datetime import UTC , datetime , timedelta
56import ipaddress
67from logging import getLogger
7- from typing import Tuple
8+ from typing import Protocol , Tuple , runtime_checkable
89
910from fastapi import Request , Form
1011from pydantic import EmailStr
1112from dotenv import load_dotenv
13+ from sqlmodel import Session , col , create_engine , delete , select
14+
15+ from utils .core .db import get_connection_url
16+ from utils .core .models import RateLimitAttempt
1217
1318logger = getLogger ("uvicorn.error" )
1419load_dotenv ()
1520
21+ _rate_limit_engine = None
22+
23+
24+ def _get_rate_limit_engine ():
25+ global _rate_limit_engine
26+ if _rate_limit_engine is None :
27+ _rate_limit_engine = create_engine (get_connection_url ())
28+ return _rate_limit_engine
29+
30+
31+ @runtime_checkable
32+ class RateLimiter (Protocol ):
33+ max_attempts : int
34+ window_seconds : int
35+
36+ def check (self , key : str ) -> Tuple [bool , int ]: ...
37+
38+ def record (self , key : str ) -> None : ...
39+
40+ def remaining (self , key : str ) -> int : ...
41+
42+ def reset (self , key : str ) -> None : ...
43+
44+ def prune (self ) -> None : ...
45+
46+ def clear (self ) -> None : ...
47+
1648
1749def get_trusted_proxy_hosts () -> tuple [str , ...]:
1850 """
@@ -168,6 +200,100 @@ def prune(self) -> None:
168200 self ._prune_stale_keys (now )
169201 self ._next_prune_at = now + self .prune_interval_seconds
170202
203+ def clear (self ) -> None :
204+ """Clear all recorded attempts."""
205+ with self ._lock :
206+ self ._attempts .clear ()
207+
208+
209+ class PostgresRateLimitWindow :
210+ """
211+ Sliding-window rate limiter backed by PostgreSQL.
212+
213+ Use when running multiple workers or replicas so configured limits apply
214+ cluster-wide instead of per process.
215+ """
216+
217+ def __init__ (self , scope : str , max_attempts : int , window_seconds : int ):
218+ self .scope = scope
219+ self .max_attempts = max_attempts
220+ self .window_seconds = window_seconds
221+
222+ def _cutoff (self , now : datetime ) -> datetime :
223+ return now - timedelta (seconds = self .window_seconds )
224+
225+ def _recent_attempts (self , session : Session , key : str , now : datetime ):
226+ return session .exec (
227+ select (RateLimitAttempt )
228+ .where (
229+ col (RateLimitAttempt .scope ) == self .scope ,
230+ col (RateLimitAttempt .key ) == key ,
231+ col (RateLimitAttempt .attempted_at ) > self ._cutoff (now ),
232+ )
233+ .order_by (col (RateLimitAttempt .attempted_at ))
234+ ).all ()
235+
236+ def check (self , key : str ) -> Tuple [bool , int ]:
237+ now = datetime .now (UTC )
238+ with Session (_get_rate_limit_engine ()) as session :
239+ attempts = self ._recent_attempts (session , key , now )
240+ if len (attempts ) >= self .max_attempts :
241+ oldest = attempts [0 ].attempted_at
242+ if oldest .tzinfo is None :
243+ oldest = oldest .replace (tzinfo = UTC )
244+ retry_after = math .ceil (
245+ (
246+ oldest + timedelta (seconds = self .window_seconds ) - now
247+ ).total_seconds ()
248+ )
249+ return True , max (retry_after , 1 )
250+ return False , 0
251+
252+ def record (self , key : str ) -> None :
253+ with Session (_get_rate_limit_engine ()) as session :
254+ session .add (
255+ RateLimitAttempt (
256+ scope = self .scope , key = key , attempted_at = datetime .now (UTC )
257+ )
258+ )
259+ session .commit ()
260+
261+ def remaining (self , key : str ) -> int :
262+ now = datetime .now (UTC )
263+ with Session (_get_rate_limit_engine ()) as session :
264+ attempts = self ._recent_attempts (session , key , now )
265+ return max (0 , self .max_attempts - len (attempts ))
266+
267+ def reset (self , key : str ) -> None :
268+ with Session (_get_rate_limit_engine ()) as session :
269+ session .exec (
270+ delete (RateLimitAttempt ).where (
271+ col (RateLimitAttempt .scope ) == self .scope ,
272+ col (RateLimitAttempt .key ) == key ,
273+ )
274+ )
275+ session .commit ()
276+
277+ def prune (self ) -> None :
278+ cutoff = self ._cutoff (datetime .now (UTC ))
279+ with Session (_get_rate_limit_engine ()) as session :
280+ session .exec (
281+ delete (RateLimitAttempt ).where (
282+ col (RateLimitAttempt .scope ) == self .scope ,
283+ col (RateLimitAttempt .attempted_at ) <= cutoff ,
284+ )
285+ )
286+ session .commit ()
287+
288+ def clear (self ) -> None :
289+ with Session (_get_rate_limit_engine ()) as session :
290+ session .exec (
291+ delete (RateLimitAttempt ).where (
292+ col (RateLimitAttempt .scope ) == self .scope
293+ )
294+ )
295+ session .commit ()
296+
171297
172298# --- Configuration helpers ---
173299
@@ -184,25 +310,42 @@ def _int_env(name: str, default: int) -> int:
184310 return default
185311
186312
313+ def _rate_limit_backend () -> str :
314+ return os .environ .get ("RATE_LIMIT_BACKEND" , "memory" ).lower ()
315+
316+
317+ def _make_rate_limiter (
318+ scope : str , max_attempts : int , window_seconds : int
319+ ) -> RateLimiter :
320+ if _rate_limit_backend () == "postgres" :
321+ return PostgresRateLimitWindow (scope , max_attempts , window_seconds )
322+ return RateLimitWindow (max_attempts = max_attempts , window_seconds = window_seconds )
323+
324+
187325# --- Shared limiter instances ---
188326
189- login_ip_limiter = RateLimitWindow (
327+ login_ip_limiter = _make_rate_limiter (
328+ "login_ip" ,
190329 max_attempts = _int_env ("LOGIN_IP_LIMIT" , 10 ),
191330 window_seconds = _int_env ("LOGIN_IP_WINDOW_SECONDS" , 60 ),
192331)
193- login_email_limiter = RateLimitWindow (
332+ login_email_limiter = _make_rate_limiter (
333+ "login_email" ,
194334 max_attempts = _int_env ("LOGIN_EMAIL_LIMIT" , 5 ),
195335 window_seconds = _int_env ("LOGIN_EMAIL_WINDOW_SECONDS" , 60 ),
196336)
197- register_ip_limiter = RateLimitWindow (
337+ register_ip_limiter = _make_rate_limiter (
338+ "register_ip" ,
198339 max_attempts = _int_env ("REGISTER_IP_LIMIT" , 5 ),
199340 window_seconds = _int_env ("REGISTER_IP_WINDOW_SECONDS" , 60 ),
200341)
201- forgot_password_ip_limiter = RateLimitWindow (
342+ forgot_password_ip_limiter = _make_rate_limiter (
343+ "forgot_password_ip" ,
202344 max_attempts = _int_env ("FORGOT_PASSWORD_IP_LIMIT" , 5 ),
203345 window_seconds = _int_env ("FORGOT_PASSWORD_IP_WINDOW_SECONDS" , 60 ),
204346)
205- forgot_password_email_limiter = RateLimitWindow (
347+ forgot_password_email_limiter = _make_rate_limiter (
348+ "forgot_password_email" ,
206349 max_attempts = _int_env ("FORGOT_PASSWORD_EMAIL_LIMIT" , 3 ),
207350 window_seconds = _int_env ("FORGOT_PASSWORD_EMAIL_WINDOW_SECONDS" , 60 ),
208351)
@@ -217,15 +360,15 @@ def _int_env(name: str, default: int) -> int:
217360
218361
219362def clear_all_rate_limiters () -> None :
220- """Clear all in-memory rate limiter state."""
363+ """Clear all rate limiter state for the active backend ."""
221364 for limiter in _ALL_LIMITERS :
222- limiter ._attempts . clear ()
365+ limiter .clear ()
223366
224367
225368# --- Dependency helpers ---
226369
227370
228- def _enforce_rate_limit (limiter : RateLimitWindow , key : str , scope : str ) -> int :
371+ def _enforce_rate_limit (limiter : RateLimiter , key : str , scope : str ) -> int :
229372 """
230373 Check the limiter for the given key. If limited, raise RateLimitError.
231374 Otherwise, record the attempt and return.
0 commit comments