feat: homelab deployment - NPM + IONOS DNS + monitoring + NAS backup
- Restructured docker-compose for Nginx Proxy Manager (no custom nginx) - Added domain wordly.art configuration - Added Prometheus + Grafana monitoring stack with pre-configured dashboards - Added PostgreSQL backup script to NAS (daily/weekly/monthly rotation) - Added alert rules for backend, system, and Docker metrics - Updated deployment guide for NPM + IONOS DNS homelab setup - Added marketing plan document - PDF translator and watermark support - Enhanced middleware, routes, and translator modules Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,10 @@
|
||||
"""
|
||||
Tier-based daily translation quota (Story 1.6).
|
||||
Uses Redis sliding-window daily counter per user; fallback in-memory when Redis unavailable.
|
||||
Tier-based monthly translation quota.
|
||||
Uses Redis counter per user per month; fallback in-memory when Redis unavailable.
|
||||
Coexists with IP-based rate limiting in rate_limiting.py.
|
||||
|
||||
Source of truth: Redis (key per user per UTC date) is the authority for quota enforcement.
|
||||
User.daily_translation_count in DB is kept in sync on each successful translation for
|
||||
reporting/analytics; reset at midnight UTC is automatic in Redis (new key per day). DB
|
||||
reset can be done by a scheduled job at midnight UTC if needed.
|
||||
Free tier: 2 translations per calendar month.
|
||||
Paid tiers (starter/pro/business/enterprise): unlimited.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -18,65 +16,65 @@ from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Free tier: 5 translations per day (UTC). Pro (and equivalent) tiers: no daily cap.
|
||||
FREE_TIER_DAILY_LIMIT = 5
|
||||
KEY_PREFIX = "rate_limit:daily"
|
||||
# Free tier: 2 translations per calendar month.
|
||||
FREE_TIER_MONTHLY_LIMIT = 2
|
||||
KEY_PREFIX = "quota:monthly"
|
||||
|
||||
|
||||
def _utc_date_str(dt: Optional[datetime] = None) -> str:
|
||||
"""Current date in UTC as YYYY-MM-DD."""
|
||||
def _utc_month_str(dt: Optional[datetime] = None) -> str:
|
||||
"""Current month in UTC as YYYY-MM."""
|
||||
t = dt or datetime.now(timezone.utc)
|
||||
return t.strftime("%Y-%m-%d")
|
||||
return t.strftime("%Y-%m")
|
||||
|
||||
|
||||
def _next_midnight_utc(dt: Optional[datetime] = None) -> datetime:
|
||||
"""Next midnight UTC after the given time (or now)."""
|
||||
def _next_month_utc(dt: Optional[datetime] = None) -> datetime:
|
||||
"""First second of next month UTC."""
|
||||
now = dt or datetime.now(timezone.utc)
|
||||
tomorrow = (now.date() + timedelta(days=1))
|
||||
return datetime(tomorrow.year, tomorrow.month, tomorrow.day, tzinfo=timezone.utc)
|
||||
if now.month == 12:
|
||||
return datetime(now.year + 1, 1, 1, tzinfo=timezone.utc)
|
||||
return datetime(now.year, now.month + 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _seconds_until_midnight_utc(dt: Optional[datetime] = None) -> int:
|
||||
"""Seconds until next midnight UTC."""
|
||||
def _seconds_until_next_month(dt: Optional[datetime] = None) -> int:
|
||||
"""Seconds until start of next month UTC."""
|
||||
now = dt or datetime.now(timezone.utc)
|
||||
return max(0, int((_next_midnight_utc(now) - now).total_seconds()))
|
||||
return max(0, int((_next_month_utc(now) - now).total_seconds()))
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuotaResult:
|
||||
"""Result of a quota check."""
|
||||
allowed: bool
|
||||
remaining: int # -1 for pro (unlimited)
|
||||
remaining: int # -1 for paid tiers (unlimited)
|
||||
reset_at_utc: datetime
|
||||
current_usage: int = 0
|
||||
limit: int = FREE_TIER_DAILY_LIMIT
|
||||
limit: int = FREE_TIER_MONTHLY_LIMIT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redis backend (shared client from core.redis)
|
||||
# Redis backend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_async_redis():
|
||||
"""Return async Redis client or None. Uses shared client from core.redis."""
|
||||
from core.redis import get_async_redis
|
||||
|
||||
return get_async_redis()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory fallback (per process; not shared across workers). Documented as fallback.
|
||||
# In-memory fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_memory_usage: dict[tuple[str, str], int] = {} # (user_id, date_utc_str) -> count
|
||||
_memory_usage: dict[tuple[str, str], int] = {} # (user_id, month_str) -> count
|
||||
|
||||
|
||||
def _memory_get(user_id: str, date_str: str) -> int:
|
||||
return _memory_usage.get((user_id, date_str), 0)
|
||||
def _memory_get(user_id: str, month_str: str) -> int:
|
||||
return _memory_usage.get((user_id, month_str), 0)
|
||||
|
||||
|
||||
def _memory_incr(user_id: str, date_str: str) -> int:
|
||||
key = (user_id, date_str)
|
||||
def _memory_incr(user_id: str, month_str: str) -> int:
|
||||
key = (user_id, month_str)
|
||||
_memory_usage[key] = _memory_usage.get(key, 0) + 1
|
||||
return _memory_usage[key]
|
||||
|
||||
@@ -87,28 +85,21 @@ def _memory_incr(user_id: str, date_str: str) -> int:
|
||||
|
||||
class TierQuotaService:
|
||||
"""
|
||||
Daily translation quota per user by tier.
|
||||
Redis key pattern: rate_limit:daily:{user_id}:{YYYY-MM-DD}, TTL 25h.
|
||||
If Redis is unavailable, uses in-memory dict (documented fallback).
|
||||
Monthly translation quota per user by tier.
|
||||
Redis key pattern: quota:monthly:{user_id}:{YYYY-MM}, TTL 32 days.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._redis = None # Lazy init on first use
|
||||
self._redis = None
|
||||
|
||||
def _redis_client(self):
|
||||
if self._redis is None:
|
||||
self._redis = _get_async_redis()
|
||||
return self._redis
|
||||
|
||||
def _date_str(self, dt: Optional[datetime] = None) -> str:
|
||||
return _utc_date_str(dt)
|
||||
|
||||
async def check_quota(self, user_id: str, tier: str) -> QuotaResult:
|
||||
"""
|
||||
Check if user has quota for one more translation today (UTC).
|
||||
tier "free" -> limit 5/day; "pro" (or equivalent) -> unlimited.
|
||||
"""
|
||||
reset_at = _next_midnight_utc()
|
||||
"""Check monthly quota. Free = 2/month; paid = unlimited."""
|
||||
reset_at = _next_month_utc()
|
||||
tier_lower = (tier or "free").lower()
|
||||
if tier_lower in ("pro", "business", "enterprise", "starter"):
|
||||
return QuotaResult(
|
||||
@@ -118,48 +109,48 @@ class TierQuotaService:
|
||||
current_usage=0,
|
||||
limit=0,
|
||||
)
|
||||
# Free tier
|
||||
date_str = self._date_str()
|
||||
# Free tier — monthly counter
|
||||
month_str = _utc_month_str()
|
||||
redis_client = self._redis_client()
|
||||
if redis_client:
|
||||
try:
|
||||
key = f"{KEY_PREFIX}:{user_id}:{date_str}"
|
||||
key = f"{KEY_PREFIX}:{user_id}:{month_str}"
|
||||
count = await redis_client.get(key)
|
||||
count = int(count or 0)
|
||||
except Exception as e:
|
||||
logger.warning("Tier quota Redis get failed: %s, using in-memory", e)
|
||||
count = _memory_get(user_id, date_str)
|
||||
count = _memory_get(user_id, month_str)
|
||||
else:
|
||||
count = _memory_get(user_id, date_str)
|
||||
remaining = max(0, FREE_TIER_DAILY_LIMIT - count)
|
||||
count = _memory_get(user_id, month_str)
|
||||
remaining = max(0, FREE_TIER_MONTHLY_LIMIT - count)
|
||||
return QuotaResult(
|
||||
allowed=count < FREE_TIER_DAILY_LIMIT,
|
||||
allowed=count < FREE_TIER_MONTHLY_LIMIT,
|
||||
remaining=remaining,
|
||||
reset_at_utc=reset_at,
|
||||
current_usage=count,
|
||||
limit=FREE_TIER_DAILY_LIMIT,
|
||||
limit=FREE_TIER_MONTHLY_LIMIT,
|
||||
)
|
||||
|
||||
async def increment_on_success(self, user_id: str) -> None:
|
||||
"""Increment daily translation count for user (call after successful translation)."""
|
||||
date_str = self._date_str()
|
||||
"""Increment monthly translation count (call after successful translation)."""
|
||||
month_str = _utc_month_str()
|
||||
redis_client = self._redis_client()
|
||||
if redis_client:
|
||||
try:
|
||||
key = f"{KEY_PREFIX}:{user_id}:{date_str}"
|
||||
key = f"{KEY_PREFIX}:{user_id}:{month_str}"
|
||||
pipe = redis_client.pipeline()
|
||||
pipe.incr(key)
|
||||
pipe.expire(key, 25 * 3600) # 25h so key expires after midnight UTC
|
||||
pipe.expire(key, 32 * 24 * 3600) # 32 days
|
||||
await pipe.execute()
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("Tier quota Redis increment failed: %s, using in-memory", e)
|
||||
_memory_incr(user_id, date_str)
|
||||
_memory_incr(user_id, month_str)
|
||||
|
||||
def seconds_until_reset(self) -> int:
|
||||
"""Seconds until next midnight UTC (for Retry-After header)."""
|
||||
return _seconds_until_midnight_utc()
|
||||
"""Seconds until start of next month UTC."""
|
||||
return _seconds_until_next_month()
|
||||
|
||||
|
||||
# Singleton for app use
|
||||
# Singleton
|
||||
tier_quota_service = TierQuotaService()
|
||||
|
||||
Reference in New Issue
Block a user