rate_limits.py
python
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