gabriel / musehub public
rate_limits.py python
100 lines 4.6 KB
Raw
sha256:ea0b9bd5e41fc9f0da875d492f38c801d44ca8596aa87c073f69687cf04d24da fix: untrack stuck .vscode directory sentinel (muse#103) Sonnet 5 minor ⚠ breaking 6 days ago
1 """Shared rate-limiter instance for MuseHub.
2
3 Importing ``limiter`` here (rather than from ``main``) breaks the
4 circular-import chain: ``main`` registers the limiter with the app,
5 route modules import it from here, and ``main`` imports it from here too.
6
7 Limits are read from :class:`~musehub.config.Settings` so they can be
8 overridden via environment variables without touching code:
9
10 MCP_RATE_LIMIT_HUMAN (default: 60/minute)
11 MCP_RATE_LIMIT_AGENT (default: 600/minute)
12 MCP_RATE_LIMIT_ANONYMOUS (default: 20/minute)
13 ASSET_RATE_LIMIT_PER_IP (default: 120/minute)
14
15 Wire endpoints (push/fetch) use a conservative fixed limit.
16 Auth endpoints use a fixed per-IP limit to slow credential-stuffing.
17 Issue write endpoints key on the MSign handle — IP is too easy to cycle.
18 """
19
20 from starlette.requests import Request
21
22 from slowapi import Limiter
23 from slowapi.util import get_remote_address
24
25 from musehub.config import settings
26
27 # Global baseline: 300 req/min per IP on all routes that do not declare their
28 # own tighter limit. Routes WITH a @limiter.limit() decorator consume both
29 # their per-route bucket and this global bucket, so the tighter one fires first.
30 GLOBAL_LIMIT: str = "300/minute"
31
32 limiter = Limiter(
33 key_func=get_remote_address,
34 default_limits=[GLOBAL_LIMIT],
35 )
36
37 # ── Convenience limit strings (resolved at module load from config) ──────────
38
39 # Wire protocol — push is expensive (disk + DB); cap tightly per IP.
40 WIRE_PUSH_LIMIT: str = "30/minute"
41 # Fetch/refs are cheaper but can still exhaust DB; moderately capped.
42 WIRE_FETCH_LIMIT: str = "120/minute"
43 # Repair endpoints — operator-only, owner-auth required, rarely called but
44 # may need to repair many objects/snapshots in bulk; higher cap than push.
45 REPAIR_LIMIT: str = "2000/minute"
46 # MCP POST endpoint — different caps by caller type; use the most
47 # permissive (agent) as the global cap; per-tool limits added later.
48 MCP_LIMIT: str = settings.mcp_rate_limit_agent
49 # Auth endpoints — protect against credential stuffing.
50 # In test mode the limit is raised so stress tests (e.g. 50 sequential logins)
51 # don't trip the limiter; production keeps the tight 20/minute cap.
52 AUTH_LIMIT_PROD: str = "20/minute"
53 AUTH_LIMIT: str = "10000/minute" if settings.muse_env == "test" else AUTH_LIMIT_PROD
54 # Search — can trigger Qdrant; cap to protect the embedding service.
55 SEARCH_LIMIT: str = "60/minute"
56 # muse_push via MCP — tighter than the global MCP cap because each push
57 # writes to disk and DB; an agent loop should never exceed 30 pushes/minute.
58 MCP_PUSH_LIMIT: str = "30/minute"
59 # Content-addressed object download — immutable blobs, cacheable, but
60 # unbounded scraping of the /o/ namespace could exhaust disk I/O.
61 OBJECT_LIMIT: str = "120/minute"
62 # Domain marketplace registration — musehub#117 DOM_08. A legitimate caller
63 # registers a handful of domains, rarely; tight cap protects the unschema'd
64 # `capabilities` JSON path from spam/DoS registration attempts.
65 DOMAIN_REGISTER_LIMIT: str = "10/minute"
66 # Issue write endpoints — keyed on MSign handle, not IP, because IPs are
67 # trivially cycled. Creation is tighter than comments (more expensive, more
68 # spam-prone).
69 ISSUE_CREATE_LIMIT: str = "20/minute"
70 ISSUE_COMMENT_LIMIT: str = "60/minute"
71 # Mist write endpoints — keyed on MSign handle, not IP.
72 # create: tighter (content hash + repo creation).
73 # fork: cheaper (copy-on-write) but still guarded.
74 # update: moderate — content replacement writes a new commit.
75 # delete: tight — destructive, rarely legitimately frequent.
76 MIST_CREATE_LIMIT: str = "20/minute"
77 MIST_FORK_LIMIT: str = "30/minute"
78 MIST_UPDATE_LIMIT: str = "30/minute"
79 MIST_DELETE_LIMIT: str = "10/minute"
80 # Open Graph PNG renders — expensive on cache miss; cap per IP.
81 OPENGRAPH_LIMIT: str = "60/minute"
82 # Mist read endpoints — keyed on IP (no auth required).
83 # Public discovery feed and detail reads; generous but bounded to prevent scraping.
84 MIST_READ_LIMIT: str = "120/minute"
85
86 def get_msign_handle(request: Request) -> str:
87 """Rate-limit key function: extract the MSign handle from the Authorization header.
88
89 Falls back to the remote IP if the header is absent or unparseable, which
90 should never happen on authenticated endpoints but keeps the limiter safe.
91 """
92 auth = request.headers.get("Authorization", "")
93 if auth.startswith("MSign "):
94 # Inline the handle extraction to avoid importing from auth (circular risk).
95 # Header format: MSign handle="<h>" ts=<n> sig="<s>"
96 import re
97 m = re.search(r'handle="([^"]+)"', auth)
98 if m:
99 return f"msign:{m.group(1)}"
100 return f"ip:{get_remote_address(request)}"
File History 1 commit
sha256:ea0b9bd5e41fc9f0da875d492f38c801d44ca8596aa87c073f69687cf04d24da fix: untrack stuck .vscode directory sentinel (muse#103) Sonnet 5 minor ⚠ 6 days ago