Skip to content
Open
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
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ MODEL_NAME=gemini-2.5-pro
# Show model "thinking" process in the UI
SHOW_MODEL_THINKING=false

# Per-IP rate limit for the costly /api/query endpoint (flask_limiter syntax).
# Protects against scripted abuse / cost-draining; tune without a redeploy.
QUERY_RATE_LIMIT=10 per minute

# Google Gemini / Vertex AI settings
GOOGLE_CLOUD_PROJECT=tenantfirstaid
# Fill with your own path (see README.md Local Development/Prerequisites for details)
Expand Down
87 changes: 87 additions & 0 deletions backend/scripts/repro_rate_limit.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like this should go in the backend/scripts dir

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to backend/scripts/ alongside the other utility scripts. Let me know if there's anything else to fix before the pr can get merged.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make sure you pass the code-quality checks. I enabled the github action checks, but you can run these checks locally as well (see the README.md).

Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Reproduction: /api/query has NO rate limit, while /api/feedback does.

Uses the real Flask app from tenantfirstaid.app with the LLM chat manager and
the email sender mocked out, so no external calls are made. We fire a burst of
requests at each endpoint and report the status codes.

Expected (buggy) behavior:
- /api/query : 20/20 requests return 200, ZERO 429s -> unthrottled, cost risk
- /api/feedback: first 3 return 200, the rest return 429 -> throttled
"""

from collections import Counter
from unittest.mock import patch

from tenantfirstaid.app import app, limiter


def fire_query(client, n):
"""Send n POSTs to /api/query with a mocked chat manager."""
codes = []
with patch("tenantfirstaid.chat.LangChainChatManager") as mock_cm:
mock_cm.return_value.generate_streaming_response.return_value = iter(
[{"type": "text", "text": "mocked legal advice"}]
)
for _ in range(n):
resp = client.post(
"/api/query",
json={
"messages": [{"role": "human", "content": "hi"}],
"city": None,
"state": "or",
},
)
# Drain the streaming body so the request fully completes.
resp.get_data()
codes.append(resp.status_code)
return Counter(codes)


def fire_feedback(client, n):
"""Send n POSTs to /api/feedback with a mocked email sender."""
codes = []
with (
patch("tenantfirstaid.feedback.EmailMessage"),
patch.dict(
"os.environ", {"SENDER_EMAIL": "s@t.com", "RECIPIENT_EMAIL": "r@t.com"}
),
):
for _ in range(n):
resp = client.post(
"/api/feedback",
data={"name": "Jane", "subject": "Bug", "feedback": "Broken"},
)
codes.append(resp.status_code)
return Counter(codes)


def run_trial(trial_no, burst=20):
app.testing = True
limiter.reset() # start each trial with a clean rate-limit window
client = app.test_client()

query_codes = fire_query(client, burst)
limiter.reset()
feedback_codes = fire_feedback(client, 5)

print(f"\n===== TRIAL {trial_no} =====")
print(f"/api/query x{burst:<3} -> {dict(query_codes)}")
print(
f" 429s on /api/query: {query_codes.get(429, 0)} "
f"(0 == UNPROTECTED, bug reproduced)"
)
print(f"/api/feedback x5 -> {dict(feedback_codes)}")
print(
f" 429s on /api/feedback: {feedback_codes.get(429, 0)} "
f"(>0 == limiter works here)"
)

bug_present = query_codes.get(429, 0) == 0 and feedback_codes.get(429, 0) > 0
print(f" RESULT: bug {'REPRODUCED' if bug_present else 'NOT reproduced'}")
return bug_present


if __name__ == "__main__":
results = [run_trial(i) for i in (1, 2)]
print("\n========================================")
print(f"Bug reproduced in {sum(results)}/{len(results)} trials.")
9 changes: 8 additions & 1 deletion backend/tenantfirstaid/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,14 @@
mail = Mail(app)


app.add_url_rule("/api/query", view_func=ChatView.as_view("chat"), methods=["POST"])
# Rate limit the costly /api/query endpoint: each call fans out to Vertex AI RAG
# plus a Gemini completion, so unthrottled traffic is a direct cost and
# availability risk. The budget is tunable without a redeploy via QUERY_RATE_LIMIT
# and defaults to a value that allows a normal back-and-forth chat but blocks
# scripted abuse.
QUERY_RATE_LIMIT = os.getenv("QUERY_RATE_LIMIT", "10 per minute")
chat_view = limiter.limit(QUERY_RATE_LIMIT)(ChatView.as_view("chat"))
app.add_url_rule("/api/query", view_func=chat_view, methods=["POST"])


@limiter.limit("3 per minute")
Expand Down
33 changes: 33 additions & 0 deletions backend/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,39 @@ def test_post_query_returns_200(self, mock_cm_cls, client):
assert resp.status_code == 200
assert resp.mimetype == "text/plain"

@patch("tenantfirstaid.chat.LangChainChatManager")
def test_query_within_limit_returns_200(self, mock_cm_cls, client):
# A short burst that stays under the limit should all succeed.
mock_cm_cls.return_value.generate_streaming_response.side_effect = (
lambda *args, **kwargs: iter([{"type": "text", "text": "Hi"}])
)
payload = {
"messages": [{"role": "human", "content": "Help"}],
"city": None,
"state": "or",
}
for _ in range(5):
resp = client.post("/api/query", json=payload)
resp.get_data() # Drain the streaming body before the next request.
assert resp.status_code == 200

@patch("tenantfirstaid.chat.LangChainChatManager")
def test_query_rate_limiting_returns_429(self, mock_cm_cls, client):
# Exceeding the per-IP limit (10 per minute) should be rejected with 429.
mock_cm_cls.return_value.generate_streaming_response.side_effect = (
lambda *args, **kwargs: iter([{"type": "text", "text": "Hi"}])
)
payload = {
"messages": [{"role": "human", "content": "Help"}],
"city": None,
"state": "or",
}
for _ in range(10):
resp = client.post("/api/query", json=payload)
resp.get_data() # Drain the streaming body before the next request.
resp = client.post("/api/query", json=payload)
assert resp.status_code == 429

def test_get_query_returns_405(self, client):
resp = client.get("/api/query")
assert resp.status_code == 405
Expand Down
Loading
Loading