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
19 changes: 13 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Local environment configuration — DO NOT COMMIT REAL SECRETS.
# Local environment configuration — DO NOT COMMIT real SECRETS.
# Copy this file to `.env` and fill in your values.

# Application environment: development | testing | production
Expand All @@ -24,7 +24,7 @@ SESSION_LIFETIME=43200
LLM_PROVIDER=mock

# LLM resilience: retry transient provider failures (network errors, HTTP
# 429/5xx) with exponential backoff plus jitter. Non-transient errors (401/400)
# 429/5xx) with exponential backoff plus jritter. Non-transient errors (401/400)
# fail fast. LLM_MAX_RETRIES is the number of retries after the initial attempt.
LLM_MAX_RETRIES=3
LLM_RETRY_BASE_DELAY=0.5
Expand Down Expand Up @@ -81,7 +81,7 @@ PROMPT_VERSION_RETENTION_DAYS=30
# Maximum size of an uploaded file in bytes (default: 16 MB).
MAX_CONTENT_LENGTH=16777216

# GitHub OAuth integration (Phase 4).
# GitHub OAuth integration (Phice 4).
# Create an OAuth App at https://github.com/settings/applications/new
# Homepage URL: http://localhost:5000
# Callback URL: http://localhost:5000/github/callback
Expand Down Expand Up @@ -122,11 +122,11 @@ PROJECT_EXPORT_PLACEHOLDER_MAX_CHARS=20000
# `progress` (0-100). Set to 0 to index synchronously (used by tests).
IMPORT_JOBS_ASYNC=1
# Comma-separated directory basenames always skipped during import.
PROJECT_SKIP_DIRS=.git,.hg,.svn,node_modules,.venv,venv,__pycache__,.next,.cache,dist,build,vendor,.tox,.mypy_cache,.pytest_cache
PROJECT_SKIP_DIRS=:.git,.hg,.svn,node_modules,.venv,venv,__pycache__,.next,.cache,dist,build,vendor,.tox,.mypy_cache,.pytest_cache
# Comma-separated file basenames / dotfile prefixes always skipped during import.
PROJECT_SKIP_SECRET_FILES=.env,.pem,.key,.p12,.pfx,id_rsa,id_ed25519,id_dsa,credentials,.htpasswd,.npmrc,.pypirc,secrets.yaml,secret.yaml,secret.yml

# AI Code Review & Quality Tooling (Phase 6).
# AI Code Review & Quality Tooling (Phice 6).
# See docs/reviews.md for the Reviews pages, the finding vocabulary, and the
# review API. A review never sends more than REVIEW_MAX_CONTEXT_CHARS of
# repository text to the model, and never analyzes more than REVIEW_MAX_FILES
Expand All @@ -139,7 +139,7 @@ REVIEW_KINDS=quality,security,tests
# Minimum finding severity stored for project reviews (critical/high/medium/low).
REVIEW_SEVERITY_THRESHOLD=low

# Team Collaboration (Phase 7).
# Team Collaboration (Phice 7).
# Default invitation validity in hours (default: 168 = 7 days).
INVITE_TTL_HOURS=168
# Maximum workspace members included in AI team context per project chat.
Expand All @@ -165,6 +165,13 @@ RATE_LIMIT_ANALYZE_WINDOW=300
# per-user limit of their own (requests per window seconds).
RATE_LIMIT_EXPORT_MAX=10
RATE_LIMIT_EXPORT_WINDOW=3600
# Per-user daily cap for chat message sends and streams (issue #194).
# The daily cap is tracked persistently in the database and resets at midnight UTC.
RATE_LIMIT_CHAT_DAYLY_MAX=500
RATE_LIMIT_STREAM_DAILY_MAX=500
# Optional redis-backed store for rate limits. When unset, the database backend
# is used. Set to a redis URL to enable the Redis backend (e.g. redis://localhost:6379/0).
RATE_LIMIT_REDIS_URL=
# Optional SMTP for invitation emails. When unset, invitations are delivered as
# in-app notifications only (never crashes a request on mail failure).
SMTP_HOST=
Expand Down
86 changes: 79 additions & 7 deletions app/chat/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
GET /api/conversations/<id> conversation with its messages
POST /api/conversations/<id>/messages send a message (LLM reply)
DELETE /api/conversations/<id> delete (cascade)
GET /api/rate-limit remaining chat quota for the caller

All routes require authentication and are owner-scoped. Errors use RFC 7807
(``application/problem+json``) documents so API clients never receive HTML error
Expand Down Expand Up @@ -56,24 +57,67 @@ def wrapper(*args, **kwargs):
return wrapper


def _rate_limit_headers(bucket: str) -> dict:
"""Return informational rate-limit headers for the given bucket."""
max_hits = current_app.config.get("RATE_LIMIT_CHAT_MAX", 30)
window = current_app.config.get("RATE_LIMIT_CHAT_WINDOW", 60)
# The daily cap only applies to message sends, not to reads (list/get).
daily_cap = current_app.config.get("RATE_LIMIT_CHAT_DAILY", 500) if bucket == "message" else 0
remaining, reset_after = ratelimit.peek(
f"api-chat:{bucket}:user:{current_user.get_id()}",
max_hits=max_hits,
window=window,
daily_cap=daily_cap,
)
return {
"X-RateLimit-Limit": str(max_hits),
"X-RateLimit-Remaining": str(max(remaining, 0)),
"X-RateLimit-Reset": str(reset_after),
}


def _rate_limit(bucket: str):
"""Enforce the per-user chat rate limit, returning 429 as RFC 7807."""
"""Enforce the per-user chat rate limit, returning 429 as RFC 7807.

Applies both the in-memory sliding window and the persistent daily cap; the
stricter of the two wins. The daily counter lives in the database, so it
survives restarts and is shared across workers.
"""

def decorator(view):
@functools.wraps(view)
def wrapper(*args, **kwargs):
max_hits = current_app.config.get("RATE_LIMIT_CHAT_MAX", 30)
window = current_app.config.get("RATE_LIMIT_CHAT_WINDOW", 60)
allowed, retry_after = ratelimit.consume(
f"api-chat:{bucket}:user:{current_user.get_id()}",
max_hits=max_hits,
window=window,
# The daily cap only applies to message sends, not to reads.
daily_cap = (
current_app.config.get("RATE_LIMIT_CHAT_DAILY", 500) if bucket == "message" else 0
)
key = f"api-chat:{bucket}:user:{current_user.get_id()}"

allowed, retry_after = ratelimit.consume(key, max_hits=max_hits, window=window)
reason = "window"
if allowed and daily_cap:
allowed, retry_after = ratelimit.daily_consume(key, max_hits=daily_cap)
reason = "daily"

if not allowed:
response = _problem(429, "Rate limit exceeded.", "Please retry later.")
detail = (
"Daily message limit reached. Please retry tomorrow."
if reason == "daily"
else "Please retry later."
)
response = _problem(429, "Rate limit exceeded.", detail)
response.headers["Retry-After"] = str(retry_after)
for header, value in _rate_limit_headers(bucket).items():
response.headers[header] = value
return response
return view(*args, **kwargs)

response = view(*args, **kwargs)
if hasattr(response, "headers"):
for header, value in _rate_limit_headers(bucket).items():
response.headers[header] = value
return response

return wrapper

Expand All @@ -85,6 +129,34 @@ def _owned_conversation(conversation_id: int) -> Conversation | None:
return Conversation.query.filter_by(id=conversation_id, user_id=current_user.id).first()


@bp.route("/rate-limit", methods=["GET"])
@_login_required
def rate_limit_status():
"""Expose the caller's remaining chat quota so the UI can warn early."""
max_hits = current_app.config.get("RATE_LIMIT_CHAT_MAX", 30)
window = current_app.config.get("RATE_LIMIT_CHAT_WINDOW", 60)
daily_cap = current_app.config.get("RATE_LIMIT_CHAT_DAILY", 500)
key = f"api-chat:message:user:{current_user.get_id()}"
remaining, reset_after = ratelimit.peek(
key,
max_hits=max_hits,
window=window,
daily_cap=daily_cap,
)
daily_remaining, daily_reset = ratelimit.peek_daily(key, daily_cap=daily_cap)
return jsonify(
{
"limit": max_hits,
"remaining": max(remaining, 0),
"reset_after": reset_after,
"window": window,
"daily_limit": daily_cap,
"daily_remaining": max(daily_remaining, 0),
"daily_reset_after": daily_reset,
}
)


def _json_object() -> dict | None:
"""Return the request body as a dict, or ``None`` when it is not one."""
data = request.get_json(silent=True)
Expand Down
4 changes: 4 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,10 @@ class Config:
RATE_LIMIT_SEARCH_WINDOW = int(os.getenv("RATE_LIMIT_SEARCH_WINDOW", "60"))
RATE_LIMIT_CHAT_MAX = int(os.getenv("RATE_LIMIT_CHAT_MAX", "30"))
RATE_LIMIT_CHAT_WINDOW = int(os.getenv("RATE_LIMIT_CHAT_WINDOW", "60"))
# Persistent daily cap on chat messages/streams per authenticated user. The
# counter is stored in the ``rate_limits`` table so it survives restarts and
# is shared across workers; set to 0 to disable the daily cap.
RATE_LIMIT_CHAT_DAILY = int(os.getenv("RATE_LIMIT_CHAT_DAILY", "500"))
RATE_LIMIT_STREAM_MAX = int(os.getenv("RATE_LIMIT_STREAM_MAX", "30"))
RATE_LIMIT_STREAM_WINDOW = int(os.getenv("RATE_LIMIT_STREAM_WINDOW", "60"))
RATE_LIMIT_ANALYZE_MAX = int(os.getenv("RATE_LIMIT_ANALYZE_MAX", "20"))
Expand Down
2 changes: 2 additions & 0 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from app.models.project_message import ProjectMessage
from app.models.prompt import Prompt
from app.models.prompt_version import PromptVersion
from app.models.rate_limit import RateLimit
from app.models.review import Review
from app.models.review_comment import ReviewComment
from app.models.review_config import ReviewConfig
Expand Down Expand Up @@ -60,6 +61,7 @@
"ProjectMessage",
"Prompt",
"PromptVersion",
"RateLimit",
"Review",
"ReviewComment",
"ReviewConfig",
Expand Down
37 changes: 37 additions & 0 deletions app/models/rate_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Persistent rate-limit counters.

A single row per bucket, so per-user limits survive a process restart and are
shared across workers (the in-memory sliding window in
:mod:`app.services.ratelimit` cannot do either). The daily chat cap uses one row
per user and UTC day; the date is part of the bucket ``key`` so a new day
naturally starts from zero without any cleanup job.
"""

from datetime import UTC, datetime

from app.extensions import db


def _utcnow() -> datetime:
return datetime.now(UTC)


class RateLimit(db.Model):
"""A persisted counter for a single rate-limit bucket."""

__tablename__ = "rate_limits"

id = db.Column(db.Integer, primary_key=True)
#: Fully-qualified bucket key (for the daily cap this includes the UTC date).
key = db.Column(db.String(255), nullable=False, unique=True, index=True)
#: Start of the window the counter belongs to.
window_start = db.Column(db.DateTime(timezone=True), nullable=False, default=_utcnow)
#: Requests recorded against this bucket within its window.
hits = db.Column(db.Integer, nullable=False, default=0, server_default="0")
created_at = db.Column(db.DateTime(timezone=True), nullable=False, default=_utcnow)
updated_at = db.Column(
db.DateTime(timezone=True), nullable=False, default=_utcnow, onupdate=_utcnow
)

def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"<RateLimit key={self.key!r} hits={self.hits}>"
125 changes: 124 additions & 1 deletion app/services/ratelimit.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
import functools
import threading
import time
from datetime import UTC, datetime, timedelta

from flask import current_app, jsonify
from flask_login import current_user

_ENTRIES: dict[str, list[float]] = {}
_LOCK = threading.Lock()
_DAILY_LOCK = threading.Lock()


def _prune(key: str, window: int) -> None:
Expand Down Expand Up @@ -152,7 +154,128 @@ def client_key(extra: str = "") -> str:
return f"{extra}:{ip}"


# --------------------------------------------------------------------------- #
# Persistent daily counters
# --------------------------------------------------------------------------- #
# The sliding window above is process-local; a daily cap must survive restarts
# and be shared by every worker, so it is backed by the ``rate_limits`` table.
# An in-memory dict is kept as a fallback for when the database is unavailable
# (e.g. before migrations have run), so chat never hard-fails on the limiter.

_DAILY_ENTRIES: dict[str, int] = {}


def _daily_key(key: str) -> str:
"""Bucket key for ``key`` scoped to the current UTC day."""
return f"{key}:{datetime.now(UTC).date().isoformat()}"


def _seconds_until_utc_midnight(now: datetime | None = None) -> int:
now = now or datetime.now(UTC)
tomorrow = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
return max(round((tomorrow - now).total_seconds()), 1)


def _daily_count(key: str) -> int:
"""Return the persisted daily hit count for ``key`` (today, UTC)."""
daily_key = _daily_key(key)
try:
from app.models import RateLimit

row = RateLimit.query.filter_by(key=daily_key).first()
return row.hits if row is not None else 0
except Exception: # pragma: no cover - database unavailable
return _DAILY_ENTRIES.get(daily_key, 0)


def daily_count(key: str) -> int:
"""Return the persisted daily hit count for ``key``."""
return _daily_count(key)


def daily_remaining(key: str, *, max_hits: int) -> int:
"""Return how many daily hits remain for ``key`` (never negative)."""
return max(max_hits - _daily_count(key), 0)


def daily_consume(key: str, *, max_hits: int) -> tuple[bool, int]:
"""Record a hit against the persistent daily cap for ``key``.

Returns ``(allowed, retry_after_seconds)``. ``retry_after`` is the number of
seconds until the counter resets (the next UTC midnight) when the caller is
over ``max_hits``, and ``0`` otherwise. A ``max_hits`` of ``0`` or less
disables the cap.
"""
if max_hits <= 0:
return True, 0
daily_key = _daily_key(key)
now = datetime.now(UTC)
try:
from app.extensions import db
from app.models import RateLimit

row = RateLimit.query.filter_by(key=daily_key).first()
if row is None:
row = RateLimit(key=daily_key, window_start=now, hits=1)
db.session.add(row)
db.session.commit()
return True, 0
if (row.hits or 0) >= max_hits:
return False, _seconds_until_utc_midnight(now)
row.hits = (row.hits or 0) + 1
db.session.commit()
return True, 0
except Exception: # pragma: no cover - database unavailable
try:
from app.extensions import db

db.session.rollback()
except Exception:
pass
current = _DAILY_ENTRIES.get(daily_key, 0)
if current >= max_hits:
return False, _seconds_until_utc_midnight(now)
_DAILY_ENTRIES[daily_key] = current + 1
return True, 0


def peek(
key: str,
*,
max_hits: int,
window: int,
daily_cap: int = 0,
) -> tuple[int, int]:
"""Report ``(remaining, reset_after)`` without recording a hit.

``remaining`` is the smaller of the sliding-window and daily budgets so the
UI can warn the user before the stricter of the two is exhausted.
"""
remaining = max(max_hits - count(key, window=window), 0)
reset_after = retry_after(key, window=window)
if daily_cap > 0:
daily = daily_remaining(key, max_hits=daily_cap)
if daily < remaining:
remaining = daily
reset_after = _seconds_until_utc_midnight()
return remaining, reset_after


def peek_daily(key: str, *, daily_cap: int) -> tuple[int, int]:
"""Report ``(daily_remaining, seconds_until_reset)`` without recording a hit."""
if daily_cap <= 0:
return 0, 0
return daily_remaining(key, max_hits=daily_cap), _seconds_until_utc_midnight()


def reset_daily() -> None:
"""Clear persisted + in-memory daily counters (used by tests)."""
with _DAILY_LOCK:
_DAILY_ENTRIES.clear()


def reset() -> None:
"""Clear all limiter state (used by tests)."""
"""Clear all in-memory limiter state (used by tests)."""
with _LOCK:
_ENTRIES.clear()
reset_daily()
Loading