From b39a4b8ba7553dbbf3551019bddf89301a7f27a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 16:07:56 +0900 Subject: [PATCH 1/5] experiment: bound send throttle scopes with expired eviction --- backend/api/emails.py | 10 ++++++++++ backend/tests/test_emails_api.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/backend/api/emails.py b/backend/api/emails.py index 2b0a9dbd6..a485859eb 100644 --- a/backend/api/emails.py +++ b/backend/api/emails.py @@ -52,6 +52,7 @@ _SEND_EMAIL_RATE_LIMIT_MAX_ATTEMPTS = 10 _SEND_EMAIL_RATE_LIMIT_WINDOW_SECONDS = 60.0 +_SEND_EMAIL_RATE_LIMIT_MAX_SCOPES = 1000 _email_send_attempts_by_scope: dict[tuple[str | None, str], list[float]] = {} _email_send_rate_limit_lock = Lock() @@ -76,6 +77,15 @@ def _enforce_send_email_rate_limit(auth_context: AuthContext) -> None: ) attempts.append(now) _email_send_attempts_by_scope[key] = attempts + if len(_email_send_attempts_by_scope) > _SEND_EMAIL_RATE_LIMIT_MAX_SCOPES: + stale_scopes = [ + scope + for scope, scope_attempts in _email_send_attempts_by_scope.items() + if scope != key + and not any(attempt > cutoff for attempt in scope_attempts) + ] + for scope in stale_scopes: + del _email_send_attempts_by_scope[scope] def canonical_thread_key(email: Email) -> str: diff --git a/backend/tests/test_emails_api.py b/backend/tests/test_emails_api.py index 7bffa6ff7..fd394d257 100644 --- a/backend/tests/test_emails_api.py +++ b/backend/tests/test_emails_api.py @@ -1886,6 +1886,36 @@ def fake_validate_smtp_destination(smtp_server, smtp_port, *, resolve_host=True) mock_send_email.assert_called_once() +def test_send_email_rate_limit_evicts_only_expired_scopes_when_over_cap( + monkeypatch, +): + from api.auth import AuthContext + + monkeypatch.setattr(emails_api, "_SEND_EMAIL_RATE_LIMIT_MAX_SCOPES", 2) + monkeypatch.setattr(emails_api.time, "monotonic", lambda: 1000.0) + emails_api._email_send_attempts_by_scope.clear() + try: + emails_api._email_send_attempts_by_scope[("org-acme", "stale-user")] = [1.0] + emails_api._email_send_attempts_by_scope[("org-acme", "live-user")] = [999.0] + emails_api._enforce_send_email_rate_limit( + AuthContext( + user_id="new-user", + organization_id="org-acme", + role="user", + group_ids=[], + workspace_id="ws1", + ) + ) + + assert ("org-acme", "stale-user") not in ( + emails_api._email_send_attempts_by_scope + ) + assert ("org-acme", "live-user") in emails_api._email_send_attempts_by_scope + assert ("org-acme", "new-user") in emails_api._email_send_attempts_by_scope + finally: + emails_api._email_send_attempts_by_scope.clear() + + @patch("api.emails.send_email", return_value={"status": "simulated", "simulated": True}) def test_send_email_endpoint_ignores_user_id_query_and_uses_authenticated_user_config( mock_send_email, monkeypatch, sample_email From 1c5049cfe50782204927bc79145ab5a791afcb86 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 16:11:15 +0900 Subject: [PATCH 2/5] experiment: lock send throttle window expiry with regression test --- backend/tests/test_emails_api.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/backend/tests/test_emails_api.py b/backend/tests/test_emails_api.py index fd394d257..a9bd2c1f3 100644 --- a/backend/tests/test_emails_api.py +++ b/backend/tests/test_emails_api.py @@ -1916,6 +1916,30 @@ def test_send_email_rate_limit_evicts_only_expired_scopes_when_over_cap( emails_api._email_send_attempts_by_scope.clear() +def test_send_email_rate_limit_window_expiry_allows_new_send(monkeypatch): + from api.auth import AuthContext + + now = {"value": 2000.0} + monkeypatch.setattr(emails_api.time, "monotonic", lambda: now["value"]) + emails_api._email_send_attempts_by_scope.clear() + try: + scope = AuthContext( + user_id="expiry-user", + organization_id="org-acme", + role="user", + group_ids=[], + workspace_id="ws1", + ) + for _ in range(emails_api._SEND_EMAIL_RATE_LIMIT_MAX_ATTEMPTS): + emails_api._enforce_send_email_rate_limit(scope) + now["value"] = ( + 2000.0 + emails_api._SEND_EMAIL_RATE_LIMIT_WINDOW_SECONDS + 1.0 + ) + emails_api._enforce_send_email_rate_limit(scope) + finally: + emails_api._email_send_attempts_by_scope.clear() + + @patch("api.emails.send_email", return_value={"status": "simulated", "simulated": True}) def test_send_email_endpoint_ignores_user_id_query_and_uses_authenticated_user_config( mock_send_email, monkeypatch, sample_email From 23fa8f7440970f5faeecbf44292437cc454ac06c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 16:13:04 +0900 Subject: [PATCH 3/5] experiment: format own expiry lines to ruff style --- backend/tests/test_emails_api.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/tests/test_emails_api.py b/backend/tests/test_emails_api.py index a9bd2c1f3..4d4beaf77 100644 --- a/backend/tests/test_emails_api.py +++ b/backend/tests/test_emails_api.py @@ -1932,9 +1932,7 @@ def test_send_email_rate_limit_window_expiry_allows_new_send(monkeypatch): ) for _ in range(emails_api._SEND_EMAIL_RATE_LIMIT_MAX_ATTEMPTS): emails_api._enforce_send_email_rate_limit(scope) - now["value"] = ( - 2000.0 + emails_api._SEND_EMAIL_RATE_LIMIT_WINDOW_SECONDS + 1.0 - ) + now["value"] = 2000.0 + emails_api._SEND_EMAIL_RATE_LIMIT_WINDOW_SECONDS + 1.0 emails_api._enforce_send_email_rate_limit(scope) finally: emails_api._email_send_attempts_by_scope.clear() From 1fcd8b56ceabe3e8efc908d7e7eb4b0a645c496a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 16:14:48 +0900 Subject: [PATCH 4/5] experiment: lock org-scope isolation for send throttle --- backend/tests/test_emails_api.py | 34 ++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/backend/tests/test_emails_api.py b/backend/tests/test_emails_api.py index 4d4beaf77..22fe9cf31 100644 --- a/backend/tests/test_emails_api.py +++ b/backend/tests/test_emails_api.py @@ -1938,6 +1938,40 @@ def test_send_email_rate_limit_window_expiry_allows_new_send(monkeypatch): emails_api._email_send_attempts_by_scope.clear() +def test_send_email_rate_limit_isolates_organization_scopes(monkeypatch): + from api.auth import AuthContext + from fastapi import HTTPException + + monkeypatch.setattr(emails_api, "_SEND_EMAIL_RATE_LIMIT_MAX_ATTEMPTS", 2) + emails_api._email_send_attempts_by_scope.clear() + try: + scope_a = AuthContext( + user_id="same-user", + organization_id="org-a", + role="user", + group_ids=[], + workspace_id="ws1", + ) + scope_b = AuthContext( + user_id="same-user", + organization_id="org-b", + role="user", + group_ids=[], + workspace_id="ws1", + ) + emails_api._enforce_send_email_rate_limit(scope_a) + emails_api._enforce_send_email_rate_limit(scope_a) + try: + emails_api._enforce_send_email_rate_limit(scope_a) + except HTTPException as exc: + assert exc.status_code == 429 + else: + raise AssertionError("scope-a must stay limited") + emails_api._enforce_send_email_rate_limit(scope_b) + finally: + emails_api._email_send_attempts_by_scope.clear() + + @patch("api.emails.send_email", return_value={"status": "simulated", "simulated": True}) def test_send_email_endpoint_ignores_user_id_query_and_uses_authenticated_user_config( mock_send_email, monkeypatch, sample_email From f7390a362863c29351b1b37f5db09ca4acfc3fa5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 16:16:04 +0900 Subject: [PATCH 5/5] experiment: lock concurrent burst atomicity for send throttle --- backend/tests/test_emails_api.py | 44 ++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/backend/tests/test_emails_api.py b/backend/tests/test_emails_api.py index 22fe9cf31..a57937ee8 100644 --- a/backend/tests/test_emails_api.py +++ b/backend/tests/test_emails_api.py @@ -1972,6 +1972,50 @@ def test_send_email_rate_limit_isolates_organization_scopes(monkeypatch): emails_api._email_send_attempts_by_scope.clear() +def test_send_email_rate_limit_is_thread_safe_under_concurrent_bursts(monkeypatch): + import threading + + from api.auth import AuthContext + from fastapi import HTTPException + + monkeypatch.setattr(emails_api, "_SEND_EMAIL_RATE_LIMIT_MAX_ATTEMPTS", 10) + emails_api._email_send_attempts_by_scope.clear() + scope = AuthContext( + user_id="burst-user", + organization_id="org-acme", + role="user", + group_ids=[], + workspace_id="ws1", + ) + outcomes: list[str] = [] + guard = threading.Lock() + + def attempt_send() -> None: + try: + emails_api._enforce_send_email_rate_limit(scope) + result = "allowed" + except HTTPException as exc: + assert exc.status_code == 429 + result = "limited" + with guard: + outcomes.append(result) + + threads = [threading.Thread(target=attempt_send) for _ in range(20)] + try: + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=10.0) + assert not any(thread.is_alive() for thread in threads) + finally: + recorded = len(emails_api._email_send_attempts_by_scope.get(("org-acme", "burst-user"), [])) + emails_api._email_send_attempts_by_scope.clear() + + assert outcomes.count("allowed") == 10 + assert outcomes.count("limited") == 10 + assert recorded == 10 + + @patch("api.emails.send_email", return_value={"status": "simulated", "simulated": True}) def test_send_email_endpoint_ignores_user_id_query_and_uses_authenticated_user_config( mock_send_email, monkeypatch, sample_email