Skip to content

Commit 75a7ac5

Browse files
committed
feat: IP-based rate limiting for free tier
- rate_limits SQLite table with daily counters - check_rate_limit() / increment_rate_limit() helpers - 20 requests/day per IP for free mode, unlimited for BYOK - auto-cleanup of expired entries Fixes #177
1 parent 40d9397 commit 75a7ac5

1 file changed

Lines changed: 82 additions & 0 deletions

File tree

backend/services/cache.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,23 @@ async def init_db():
2727
created_at REAL NOT NULL
2828
)
2929
""")
30+
# projects table: persist project metadata + file contents across restarts
31+
await _db.execute("""
32+
CREATE TABLE IF NOT EXISTS projects (
33+
id TEXT PRIMARY KEY,
34+
data TEXT NOT NULL,
35+
created_at REAL NOT NULL
36+
)
37+
""")
38+
# rate limiting: daily LLM call count per IP
39+
await _db.execute("""
40+
CREATE TABLE IF NOT EXISTS rate_limits (
41+
ip TEXT NOT NULL,
42+
date TEXT NOT NULL,
43+
count INTEGER NOT NULL DEFAULT 0,
44+
PRIMARY KEY (ip, date)
45+
)
46+
""")
3047
await _db.commit()
3148

3249

@@ -60,3 +77,68 @@ async def put(key: str, value: dict | list):
6077
(key, data, time.time()),
6178
)
6279
await _db.commit()
80+
81+
82+
async def save_project(project_id: str, data: dict):
83+
"""Persist project data (metadata + file contents) to SQLite."""
84+
if _db is None:
85+
return
86+
payload = json.dumps(data, ensure_ascii=False)
87+
await _db.execute(
88+
"INSERT OR REPLACE INTO projects (id, data, created_at) VALUES (?, ?, ?)",
89+
(project_id, payload, time.time()),
90+
)
91+
await _db.commit()
92+
93+
94+
_DAILY_LIMIT = 20
95+
96+
97+
async def check_rate_limit(ip: str) -> tuple[bool, int]:
98+
"""Check if IP is within daily free limit.
99+
100+
Returns (allowed, remaining).
101+
"""
102+
if _db is None:
103+
return True, _DAILY_LIMIT
104+
105+
today = time.strftime("%Y-%m-%d")
106+
async with _db.execute(
107+
"SELECT count FROM rate_limits WHERE ip = ? AND date = ?", (ip, today)
108+
) as cursor:
109+
row = await cursor.fetchone()
110+
111+
current = row[0] if row else 0
112+
remaining = max(0, _DAILY_LIMIT - current)
113+
return current < _DAILY_LIMIT, remaining
114+
115+
116+
async def increment_rate_limit(ip: str):
117+
"""Bump the daily usage counter for an IP."""
118+
if _db is None:
119+
return
120+
today = time.strftime("%Y-%m-%d")
121+
await _db.execute(
122+
"""INSERT INTO rate_limits (ip, date, count) VALUES (?, ?, 1)
123+
ON CONFLICT (ip, date) DO UPDATE SET count = count + 1""",
124+
(ip, today),
125+
)
126+
await _db.commit()
127+
128+
129+
async def load_project(project_id: str) -> dict | None:
130+
"""Load project data from SQLite. Returns None if not found or expired."""
131+
if _db is None:
132+
return None
133+
async with _db.execute(
134+
"SELECT data, created_at FROM projects WHERE id = ?", (project_id,)
135+
) as cursor:
136+
row = await cursor.fetchone()
137+
if row is None:
138+
return None
139+
data, created_at = row
140+
if time.time() - created_at > _TTL_SECONDS:
141+
await _db.execute("DELETE FROM projects WHERE id = ?", (project_id,))
142+
await _db.commit()
143+
return None
144+
return json.loads(data)

0 commit comments

Comments
 (0)