Skip to content

Commit af19f95

Browse files
Merge remote-tracking branch 'origin/main' into modal
2 parents 1c761fd + d46bddf commit af19f95

7 files changed

Lines changed: 204 additions & 12 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ DB_NAME=
2626
# Domain for production (used by Caddy for TLS)
2727
DOMAIN=
2828

29+
# Rate limit storage: memory (single process) or postgres (multi-worker)
30+
# RATE_LIMIT_BACKEND=memory
31+
2932
# Comma-separated reverse-proxy peer IPs for X-Forwarded-For (e.g. 127.0.0.1,::1)
3033
# TRUSTED_PROXY_IPS=
3134

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
<!-- version list -->
99

10+
## v1.1.5 (2026-07-02)
11+
12+
### Bug Fixes
13+
14+
- Enforce avatar upload size limit before buffering body
15+
([#220](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/pull/220),
16+
[`73cdebe`](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/commit/73cdebe0e94a8caeb7b8d229aee9e60aa0571fc5))
17+
18+
- Run async upload-limit tests outside pytest event loop
19+
([#220](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/pull/220),
20+
[`73cdebe`](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/commit/73cdebe0e94a8caeb7b8d229aee9e60aa0571fc5))
21+
22+
1023
## v1.1.4 (2026-07-02)
1124

1225
### Bug Fixes

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fastapi-jinja2-postgres-webapp"
3-
version = "1.1.4"
3+
version = "1.1.5"
44
description = "A template webapp with a pure-Python FastAPI backend, frontend templating with Jinja2, and a Postgres database to power user auth"
55
readme = "README.md"
66
package-mode = false

tests/utils/test_rate_limit.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
from unittest.mock import MagicMock, patch
44

55
import utils.core.rate_limit as rate_limit_module
6-
from utils.core.rate_limit import RateLimitWindow, get_client_ip
6+
from utils.core.rate_limit import (
7+
PostgresRateLimitWindow,
8+
RateLimitWindow,
9+
get_client_ip,
10+
)
711

812

913
# ---------------------------------------------------------------------------
@@ -164,6 +168,24 @@ def test_module_limiters_honor_env_configuration(monkeypatch):
164168
importlib.reload(rate_limit_module)
165169

166170

171+
def test_postgres_rate_limit_window_enforces_limit(engine, env_vars, monkeypatch):
172+
monkeypatch.setenv("RATE_LIMIT_BACKEND", "postgres")
173+
importlib.reload(rate_limit_module)
174+
175+
limiter = PostgresRateLimitWindow("test_scope", max_attempts=2, window_seconds=60)
176+
limiter.clear()
177+
limiter.record("shared-key")
178+
limiter.record("shared-key")
179+
assert limiter.check("shared-key")[0] is True
180+
181+
second = PostgresRateLimitWindow("test_scope", max_attempts=2, window_seconds=60)
182+
assert second.check("shared-key")[0] is True
183+
184+
limiter.clear()
185+
monkeypatch.delenv("RATE_LIMIT_BACKEND", raising=False)
186+
importlib.reload(rate_limit_module)
187+
188+
167189
# ---------------------------------------------------------------------------
168190
# Client IP behind trusted proxies
169191
# ---------------------------------------------------------------------------

utils/core/models.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,17 @@ def is_expired(self) -> bool:
156156
return datetime.now(UTC) > self.expires_at.replace(tzinfo=UTC)
157157

158158

159+
class RateLimitAttempt(SQLModel, table=True):
160+
"""Shared rate-limit counter row for multi-worker deployments."""
161+
162+
__table_args__ = {"schema": "private"}
163+
164+
id: Optional[int] = Field(default=None, primary_key=True)
165+
scope: str = Field(index=True)
166+
key: str = Field(index=True)
167+
attempted_at: datetime = Field(default_factory=utc_now, index=True)
168+
169+
159170
# --- Public database models ---
160171

161172

utils/core/rate_limit.py

Lines changed: 152 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,49 @@
22
import time
33
import threading
44
import math
5+
from datetime import UTC, datetime, timedelta
56
import ipaddress
67
from logging import getLogger
7-
from typing import Tuple
8+
from typing import Protocol, Tuple, runtime_checkable
89

910
from fastapi import Request, Form
1011
from pydantic import EmailStr
1112
from 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

1318
logger = getLogger("uvicorn.error")
1419
load_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

1749
def 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

219362
def 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.

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)