docs: apply fleet-template (16-artifact scaffold)
Adds missing standard artifacts: - README.md (if missing) - AGENTS.md (AI agent contract) - PLAN.md (current sprint) - STATUS.md (where we are) - DEVELOPMENT.md (dev workflow) - DEPLOYMENT.md (deploy procedure) - TESTING.md (test strategy) - DECISIONS.md (ADR index + templates) - .github/CODEOWNERS - .github/workflows/ci.yml Preserves all existing artifacts. Refs: RugMunchMedia/fleet-template
This commit is contained in:
commit
47ba268131
310 changed files with 38429 additions and 0 deletions
84
ratelimit.py
Normal file
84
ratelimit.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""Pry — token-bucket rate limiter per IP.
|
||||
Prevents abuse while keeping the service responsive."""
|
||||
|
||||
import time
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Token-bucket rate limiter with per-IP tracking.
|
||||
|
||||
Default: 60 requests/minute per IP, burstable to 100.
|
||||
"""
|
||||
|
||||
def __init__(self, default_rpm: int = 60, burst: int = 100):
|
||||
self.default_rpm = default_rpm
|
||||
self.burst = burst
|
||||
self._buckets: dict[str, dict] = {}
|
||||
self._cleanup_interval = 300 # 5 min cleanup
|
||||
self._last_cleanup = time.time()
|
||||
|
||||
def check(self, ip: str, rpm: int | None = None) -> tuple[bool, dict]:
|
||||
"""Check if request is allowed. Returns (allowed, stats)."""
|
||||
now = time.time()
|
||||
rate = rpm or self.default_rpm
|
||||
|
||||
# Periodic cleanup
|
||||
if now - self._last_cleanup > self._cleanup_interval:
|
||||
self._cleanup()
|
||||
|
||||
# Get or create bucket
|
||||
if ip not in self._buckets:
|
||||
self._buckets[ip] = {
|
||||
"tokens": self.burst,
|
||||
"last_refill": now,
|
||||
"total": 0,
|
||||
"blocked": 0,
|
||||
}
|
||||
|
||||
bucket = self._buckets[ip]
|
||||
|
||||
# Refill tokens
|
||||
elapsed = now - bucket["last_refill"]
|
||||
bucket["tokens"] = min(self.burst, bucket["tokens"] + elapsed * (rate / 60))
|
||||
bucket["last_refill"] = now
|
||||
bucket["total"] += 1
|
||||
|
||||
# Check if allowed
|
||||
if bucket["tokens"] >= 1:
|
||||
bucket["tokens"] -= 1
|
||||
return True, {
|
||||
"remaining": int(bucket["tokens"]),
|
||||
"limit": rate,
|
||||
"total": bucket["total"],
|
||||
}
|
||||
else:
|
||||
bucket["blocked"] += 1
|
||||
wait_time = (1 - bucket["tokens"]) * (60 / rate)
|
||||
return False, {
|
||||
"remaining": 0,
|
||||
"limit": rate,
|
||||
"retry_after": round(wait_time, 1),
|
||||
"total": bucket["total"],
|
||||
"blocked": bucket["blocked"],
|
||||
}
|
||||
|
||||
def _cleanup(self):
|
||||
"""Remove stale buckets older than 1 hour."""
|
||||
now = time.time()
|
||||
stale = [ip for ip, b in self._buckets.items() if now - b["last_refill"] > 3600]
|
||||
for ip in stale:
|
||||
del self._buckets[ip]
|
||||
self._last_cleanup = now
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""Get global rate limiter statistics."""
|
||||
total_active = len(self._buckets)
|
||||
total_blocked = sum(b["blocked"] for b in self._buckets.values())
|
||||
total_requests = sum(b["total"] for b in self._buckets.values())
|
||||
return {
|
||||
"active_ips": total_active,
|
||||
"total_requests": total_requests,
|
||||
"total_blocked": total_blocked,
|
||||
"default_rpm": self.default_rpm,
|
||||
"burst": self.burst,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue