diff --git a/.github/configs/graph.yml b/.github/configs/graph.yml index 75f8afe5..e9d4993a 100644 --- a/.github/configs/graph.yml +++ b/.github/configs/graph.yml @@ -97,7 +97,7 @@ production: max_subgraphs: 10 # Support up to 10 subgraphs (1 parent + 10 children = 11 databases total) # Resource limits - api_rate_multiplier: 2.5 # 2.5x rate limits for team usage + api_rate_multiplier: 1.5 # 1.5x rate limits (2x RAM, same CPU as standard) # Copy operation limits copy_operations: @@ -168,7 +168,7 @@ production: # Note: 1 parent + 25 subgraphs = 26 databases total on xlarge instance # Resource limits - api_rate_multiplier: 5.0 # 5x rate limits for enterprise usage + api_rate_multiplier: 2.5 # 2.5x rate limits (4x RAM, 2x CPU vs standard) # Copy operation limits copy_operations: diff --git a/robosystems/adapters/sec/manifest.py b/robosystems/adapters/sec/manifest.py index f8b70ae8..dfc52d61 100644 --- a/robosystems/adapters/sec/manifest.py +++ b/robosystems/adapters/sec/manifest.py @@ -27,6 +27,9 @@ "mcp_queries_per_minute": 5, "mcp_queries_per_hour": 100, "mcp_queries_per_day": 1000, + "searches_per_minute": 5, + "searches_per_hour": 50, + "searches_per_day": 500, "agent_calls_per_minute": 2, "agent_calls_per_hour": 20, "agent_calls_per_day": 200, @@ -39,6 +42,9 @@ "mcp_queries_per_minute": 25, "mcp_queries_per_hour": 500, "mcp_queries_per_day": 5000, + "searches_per_minute": 25, + "searches_per_hour": 250, + "searches_per_day": 2500, "agent_calls_per_minute": 10, "agent_calls_per_hour": 100, "agent_calls_per_day": 1000, @@ -81,6 +87,7 @@ "query", "mcp", "agent", + "search", "schema", "status", "info", diff --git a/robosystems/config/rate_limits.py b/robosystems/config/rate_limits.py index 7fbdff7c..c0b777ab 100644 --- a/robosystems/config/rate_limits.py +++ b/robosystems/config/rate_limits.py @@ -45,6 +45,7 @@ class EndpointCategory(str, Enum): GRAPH_SYNC = "graph_sync" GRAPH_MCP = "graph_mcp" GRAPH_AGENT = "graph_agent" + GRAPH_SEARCH = "graph_search" # OpenSearch full-text search (shared resource) # High-cost operations GRAPH_QUERY = "graph_query" # Direct Cypher queries @@ -87,150 +88,119 @@ class RateLimitConfig: SUBSCRIPTION_RATE_LIMITS: dict[ str, dict[EndpointCategory, tuple[int, RateLimitPeriod]] ] = { - "free": { - # Non-graph endpoints - keep some restrictions for free tier + # ----------------------------------------------------------------------- + # MANAGED SERVICE RATE LIMITS + # All tiers share managed infrastructure. Limits are conservative to + # protect shared resources (OpenSearch t3.medium, LadybugDB on m7g/r7g). + # For self-hosted scale, customers deploy their own infrastructure. + # Loosen these as infra scales up. + # ----------------------------------------------------------------------- + "base": { + # Anonymous / unrecognized tier — tightest limits EndpointCategory.AUTH: (10, RateLimitPeriod.MINUTE), - EndpointCategory.USER_MANAGEMENT: (60, RateLimitPeriod.MINUTE), - EndpointCategory.TASKS: (60, RateLimitPeriod.MINUTE), - EndpointCategory.STATUS: (120, RateLimitPeriod.MINUTE), - EndpointCategory.SSE: ( - 5, - RateLimitPeriod.MINUTE, - ), # Limited SSE connections for free + EndpointCategory.USER_MANAGEMENT: (30, RateLimitPeriod.MINUTE), + EndpointCategory.TASKS: (30, RateLimitPeriod.MINUTE), + EndpointCategory.STATUS: (60, RateLimitPeriod.MINUTE), + EndpointCategory.SSE: (3, RateLimitPeriod.MINUTE), EndpointCategory.BILLING: (60, RateLimitPeriod.MINUTE), # Never block payments - # Graph-scoped endpoints - burst protection only - EndpointCategory.GRAPH_READ: (100, RateLimitPeriod.MINUTE), - EndpointCategory.GRAPH_WRITE: (20, RateLimitPeriod.MINUTE), - EndpointCategory.GRAPH_ANALYTICS: (10, RateLimitPeriod.MINUTE), + # Graph-scoped + EndpointCategory.GRAPH_READ: (30, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_WRITE: (10, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_ANALYTICS: (5, RateLimitPeriod.MINUTE), EndpointCategory.GRAPH_BACKUP: (2, RateLimitPeriod.MINUTE), - EndpointCategory.GRAPH_SYNC: (5, RateLimitPeriod.MINUTE), - EndpointCategory.GRAPH_MCP: (10, RateLimitPeriod.MINUTE), - EndpointCategory.GRAPH_AGENT: (5, RateLimitPeriod.MINUTE), - EndpointCategory.GRAPH_QUERY: (50, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_SYNC: (3, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_MCP: (5, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_AGENT: (3, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_SEARCH: (5, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_QUERY: (20, RateLimitPeriod.MINUTE), EndpointCategory.GRAPH_IMPORT: (2, RateLimitPeriod.MINUTE), - # Table operations - free tier - EndpointCategory.TABLE_QUERY: (30, RateLimitPeriod.MINUTE), - EndpointCategory.TABLE_UPLOAD: (10, RateLimitPeriod.MINUTE), - EndpointCategory.TABLE_MANAGEMENT: (10, RateLimitPeriod.MINUTE), + # Table operations + EndpointCategory.TABLE_QUERY: (15, RateLimitPeriod.MINUTE), + EndpointCategory.TABLE_UPLOAD: (5, RateLimitPeriod.MINUTE), + EndpointCategory.TABLE_MANAGEMENT: (5, RateLimitPeriod.MINUTE), }, - # Technical tier names (primary) + # ladybug-standard: m7g.large (8GB, 2 vCPU) — anchor tier "ladybug-standard": { - # Non-graph endpoints - generous burst limits EndpointCategory.AUTH: (20, RateLimitPeriod.MINUTE), - EndpointCategory.USER_MANAGEMENT: (600, RateLimitPeriod.MINUTE), - EndpointCategory.TASKS: (200, RateLimitPeriod.MINUTE), - EndpointCategory.STATUS: (600, RateLimitPeriod.MINUTE), - EndpointCategory.SSE: ( - 10, - RateLimitPeriod.MINUTE, - ), # Standard SSE connection rate + EndpointCategory.USER_MANAGEMENT: (60, RateLimitPeriod.MINUTE), + EndpointCategory.TASKS: (60, RateLimitPeriod.MINUTE), + EndpointCategory.STATUS: (120, RateLimitPeriod.MINUTE), + EndpointCategory.SSE: (5, RateLimitPeriod.MINUTE), EndpointCategory.BILLING: (60, RateLimitPeriod.MINUTE), # Never block payments - # Graph-scoped endpoints - HIGH BURST LIMITS - EndpointCategory.GRAPH_READ: (500, RateLimitPeriod.MINUTE), # 30k/hour possible - EndpointCategory.GRAPH_WRITE: (100, RateLimitPeriod.MINUTE), # 6k/hour possible - EndpointCategory.GRAPH_ANALYTICS: ( - 50, - RateLimitPeriod.MINUTE, - ), # 3k/hour possible - EndpointCategory.GRAPH_BACKUP: (10, RateLimitPeriod.MINUTE), # 600/hour possible - EndpointCategory.GRAPH_SYNC: (100, RateLimitPeriod.MINUTE), # 6k/hour possible - EndpointCategory.GRAPH_MCP: (100, RateLimitPeriod.MINUTE), # 6k/hour possible - EndpointCategory.GRAPH_AGENT: (50, RateLimitPeriod.MINUTE), # 3k/hour possible - EndpointCategory.GRAPH_QUERY: (200, RateLimitPeriod.MINUTE), # 12k/hour possible - EndpointCategory.GRAPH_IMPORT: (50, RateLimitPeriod.MINUTE), # 3k/hour possible - # Table operations (generous burst limits) - EndpointCategory.TABLE_QUERY: (60, RateLimitPeriod.MINUTE), # 3.6k/hour possible - EndpointCategory.TABLE_UPLOAD: (20, RateLimitPeriod.MINUTE), # 1.2k/hour possible - EndpointCategory.TABLE_MANAGEMENT: ( - 30, + # Graph-scoped — sized for m7g.large + EndpointCategory.GRAPH_READ: (120, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_WRITE: (30, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_ANALYTICS: (15, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_BACKUP: (5, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_SYNC: (10, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_MCP: (30, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_AGENT: (15, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_SEARCH: ( + 10, RateLimitPeriod.MINUTE, - ), # 1.8k/hour possible + ), # Shared OpenSearch t3.medium + EndpointCategory.GRAPH_QUERY: (60, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_IMPORT: (10, RateLimitPeriod.MINUTE), + # Table operations + EndpointCategory.TABLE_QUERY: (30, RateLimitPeriod.MINUTE), + EndpointCategory.TABLE_UPLOAD: (10, RateLimitPeriod.MINUTE), + EndpointCategory.TABLE_MANAGEMENT: (15, RateLimitPeriod.MINUTE), }, + # ladybug-large: r7g.large (16GB, 2 vCPU) + # Same base values as standard — graph.yml api_rate_multiplier (1.5x) handles scaling "ladybug-large": { - # Non-graph endpoints - very high burst limits - EndpointCategory.AUTH: (50, RateLimitPeriod.MINUTE), - EndpointCategory.USER_MANAGEMENT: (1000, RateLimitPeriod.MINUTE), - EndpointCategory.TASKS: (1000, RateLimitPeriod.MINUTE), - EndpointCategory.STATUS: (3000, RateLimitPeriod.MINUTE), - EndpointCategory.SSE: ( - 30, - RateLimitPeriod.MINUTE, - ), # More SSE connections for large tier + EndpointCategory.AUTH: (20, RateLimitPeriod.MINUTE), + EndpointCategory.USER_MANAGEMENT: (60, RateLimitPeriod.MINUTE), + EndpointCategory.TASKS: (60, RateLimitPeriod.MINUTE), + EndpointCategory.STATUS: (120, RateLimitPeriod.MINUTE), + EndpointCategory.SSE: (5, RateLimitPeriod.MINUTE), EndpointCategory.BILLING: (60, RateLimitPeriod.MINUTE), # Never block payments - # Graph-scoped endpoints - VERY HIGH BURST LIMITS - EndpointCategory.GRAPH_READ: (2000, RateLimitPeriod.MINUTE), # 120k/hour possible - EndpointCategory.GRAPH_WRITE: (500, RateLimitPeriod.MINUTE), # 30k/hour possible - EndpointCategory.GRAPH_ANALYTICS: ( - 200, - RateLimitPeriod.MINUTE, - ), # 12k/hour possible - EndpointCategory.GRAPH_BACKUP: (50, RateLimitPeriod.MINUTE), # 3k/hour possible - EndpointCategory.GRAPH_SYNC: (500, RateLimitPeriod.MINUTE), # 30k/hour possible - EndpointCategory.GRAPH_MCP: (500, RateLimitPeriod.MINUTE), # 30k/hour possible - EndpointCategory.GRAPH_AGENT: (200, RateLimitPeriod.MINUTE), # 12k/hour possible - EndpointCategory.GRAPH_QUERY: (1000, RateLimitPeriod.MINUTE), # 60k/hour possible - EndpointCategory.GRAPH_IMPORT: (200, RateLimitPeriod.MINUTE), # 12k/hour possible - # Table operations - large tier (very high burst limits) - EndpointCategory.TABLE_QUERY: (300, RateLimitPeriod.MINUTE), # 18k/hour possible - EndpointCategory.TABLE_UPLOAD: (100, RateLimitPeriod.MINUTE), # 6k/hour possible - EndpointCategory.TABLE_MANAGEMENT: ( - 150, + # Graph-scoped — same base, multiplied by 1.5x from graph.yml + EndpointCategory.GRAPH_READ: (120, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_WRITE: (30, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_ANALYTICS: (15, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_BACKUP: (5, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_SYNC: (10, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_MCP: (30, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_AGENT: (15, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_SEARCH: ( + 10, RateLimitPeriod.MINUTE, - ), # 9k/hour possible + ), # Shared OpenSearch t3.medium + EndpointCategory.GRAPH_QUERY: (60, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_IMPORT: (10, RateLimitPeriod.MINUTE), + # Table operations + EndpointCategory.TABLE_QUERY: (30, RateLimitPeriod.MINUTE), + EndpointCategory.TABLE_UPLOAD: (10, RateLimitPeriod.MINUTE), + EndpointCategory.TABLE_MANAGEMENT: (15, RateLimitPeriod.MINUTE), }, + # ladybug-xlarge: r7g.xlarge (32GB, 4 vCPU) + # Same base values as standard — graph.yml api_rate_multiplier (2.5x) handles scaling "ladybug-xlarge": { - # XLarge tier gets extreme burst limits - essentially unlimited - # Only safety limits to prevent complete system abuse - EndpointCategory.AUTH: (100, RateLimitPeriod.MINUTE), - EndpointCategory.USER_MANAGEMENT: (3000, RateLimitPeriod.MINUTE), - EndpointCategory.TASKS: (5000, RateLimitPeriod.MINUTE), - EndpointCategory.STATUS: (10000, RateLimitPeriod.MINUTE), - EndpointCategory.SSE: ( - 100, - RateLimitPeriod.MINUTE, - ), # Generous SSE connections for xlarge tier + EndpointCategory.AUTH: (20, RateLimitPeriod.MINUTE), + EndpointCategory.USER_MANAGEMENT: (60, RateLimitPeriod.MINUTE), + EndpointCategory.TASKS: (60, RateLimitPeriod.MINUTE), + EndpointCategory.STATUS: (120, RateLimitPeriod.MINUTE), + EndpointCategory.SSE: (5, RateLimitPeriod.MINUTE), EndpointCategory.BILLING: (60, RateLimitPeriod.MINUTE), # Never block payments - # Graph-scoped endpoints - EXTREME BURST LIMITS - EndpointCategory.GRAPH_READ: ( - 10000, - RateLimitPeriod.MINUTE, - ), # 600k/hour possible - EndpointCategory.GRAPH_WRITE: ( - 5000, - RateLimitPeriod.MINUTE, - ), # 300k/hour possible - EndpointCategory.GRAPH_ANALYTICS: ( - 2000, - RateLimitPeriod.MINUTE, - ), # 120k/hour possible - EndpointCategory.GRAPH_BACKUP: (200, RateLimitPeriod.MINUTE), # 12k/hour possible - EndpointCategory.GRAPH_SYNC: (2000, RateLimitPeriod.MINUTE), # 120k/hour possible - EndpointCategory.GRAPH_MCP: (5000, RateLimitPeriod.MINUTE), # 300k/hour possible - EndpointCategory.GRAPH_AGENT: ( - 2000, - RateLimitPeriod.MINUTE, - ), # 120k/hour possible - EndpointCategory.GRAPH_QUERY: ( - 10000, - RateLimitPeriod.MINUTE, - ), # 600k/hour possible - EndpointCategory.GRAPH_IMPORT: ( - 1000, - RateLimitPeriod.MINUTE, - ), # 60k/hour possible - # Table operations - xlarge tier (extreme burst limits) - EndpointCategory.TABLE_QUERY: ( - 1000, - RateLimitPeriod.MINUTE, - ), # 60k/hour possible - EndpointCategory.TABLE_UPLOAD: ( - 500, - RateLimitPeriod.MINUTE, - ), # 30k/hour possible - EndpointCategory.TABLE_MANAGEMENT: ( - 500, + # Graph-scoped — same base, multiplied by 2.5x from graph.yml + EndpointCategory.GRAPH_READ: (120, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_WRITE: (30, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_ANALYTICS: (15, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_BACKUP: (5, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_SYNC: (10, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_MCP: (30, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_AGENT: (15, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_SEARCH: ( + 10, RateLimitPeriod.MINUTE, - ), # 30k/hour possible + ), # Shared OpenSearch t3.medium + EndpointCategory.GRAPH_QUERY: (60, RateLimitPeriod.MINUTE), + EndpointCategory.GRAPH_IMPORT: (10, RateLimitPeriod.MINUTE), + # Table operations + EndpointCategory.TABLE_QUERY: (30, RateLimitPeriod.MINUTE), + EndpointCategory.TABLE_UPLOAD: (10, RateLimitPeriod.MINUTE), + EndpointCategory.TABLE_MANAGEMENT: (15, RateLimitPeriod.MINUTE), }, } @@ -246,8 +216,8 @@ def get_rate_limit( """ tier_limits = cls.SUBSCRIPTION_RATE_LIMITS.get(tier) if not tier_limits: - # Default to free tier if unknown - tier_limits = cls.SUBSCRIPTION_RATE_LIMITS["free"] + # Default to base tier if unknown + tier_limits = cls.SUBSCRIPTION_RATE_LIMITS["base"] limit_config = tier_limits.get(category) if not limit_config: @@ -353,6 +323,10 @@ def get_endpoint_category( elif endpoint_type == "agent": return EndpointCategory.GRAPH_AGENT + # Search operations (OpenSearch - shared resource) + elif endpoint_type == "search": + return EndpointCategory.GRAPH_SEARCH + # Backup operations elif endpoint_type == "graph" and "backup" in path: return EndpointCategory.GRAPH_BACKUP diff --git a/robosystems/middleware/rate_limits/rate_limiting.py b/robosystems/middleware/rate_limits/rate_limiting.py index 6971edbf..bba46f24 100644 --- a/robosystems/middleware/rate_limits/rate_limiting.py +++ b/robosystems/middleware/rate_limits/rate_limiting.py @@ -472,8 +472,8 @@ def subscription_aware_rate_limit_dependency(request: Request): # Get user identification user_id = get_user_from_request(request) if not user_id: - # Anonymous users get free tier limits - subscription_tier = "free" + # Anonymous users get base tier limits + subscription_tier = "base" identifier = f"anon_sub:{request.client.host if request.client else 'unknown'}" else: # All authenticated users get ladybug-standard tier rate limits @@ -537,7 +537,7 @@ def subscription_aware_rate_limit_dependency(request: Request): # Provide helpful error message upgrade_msg = "" - if subscription_tier in ["free", "starter"]: + if subscription_tier == "base": upgrade_msg = " Upgrade your subscription for higher limits." raise HTTPException( @@ -583,7 +583,7 @@ def sse_connection_rate_limit_dependency(request: Request): # Determine subscription tier # For now, all authenticated users get ladybug-standard tier # In the future, this could check actual subscription status - subscription_tier = "ladybug-standard" if user_id else "free" + subscription_tier = "ladybug-standard" if user_id else "base" # Get rate limit for SSE based on subscription tier rate_limit = RateLimitConfig.get_rate_limit(subscription_tier, EndpointCategory.SSE) diff --git a/robosystems/middleware/rate_limits/repository_rate_limits.py b/robosystems/middleware/rate_limits/repository_rate_limits.py index 90f517cb..82d507d0 100644 --- a/robosystems/middleware/rate_limits/repository_rate_limits.py +++ b/robosystems/middleware/rate_limits/repository_rate_limits.py @@ -32,6 +32,7 @@ class AllowedSharedEndpoints(str, Enum): QUERY = "query" # Direct Cypher queries MCP = "mcp" # MCP tool access AGENT = "agent" # AI agent operations + SEARCH = "search" # Full-text search (OpenSearch) SCHEMA = "schema" # Schema inspection STATUS = "status" # Status checks @@ -195,7 +196,12 @@ async def _check_repository_limit( return {"allowed": False, "message": "No access to repository"} # Map operation to limit keys - operation_keys = {"query": "queries", "mcp": "mcp_queries", "agent": "agent_calls"} + operation_keys = { + "query": "queries", + "mcp": "mcp_queries", + "agent": "agent_calls", + "search": "searches", + } base_key = operation_keys.get(operation, "queries") @@ -273,6 +279,7 @@ def _operation_to_category(self, operation: str) -> EndpointCategory: "query": EndpointCategory.GRAPH_QUERY, "mcp": EndpointCategory.GRAPH_MCP, "agent": EndpointCategory.GRAPH_AGENT, + "search": EndpointCategory.GRAPH_SEARCH, } return mapping.get(operation, EndpointCategory.GRAPH_READ) @@ -286,7 +293,7 @@ async def get_usage_stats(self, user_id: str, repository: str, plan: str) -> dic stats = {} # Get current usage for each operation type - for operation in ["query", "mcp", "agent"]: + for operation in ["query", "mcp", "agent", "search"]: operation_stats = {} # Check each time window diff --git a/robosystems/routers/graphs/search.py b/robosystems/routers/graphs/search.py index bc2a79ee..5cb83b34 100644 --- a/robosystems/routers/graphs/search.py +++ b/robosystems/routers/graphs/search.py @@ -2,7 +2,8 @@ import logging -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException +from starlette import status as http_status from robosystems.middleware.auth.dependencies import get_current_user_with_graph from robosystems.models.api.search import ( @@ -29,14 +30,87 @@ def _require_search_service(): return service +async def _check_search_rate_limit( + graph_id: str, current_user: User, endpoint: str +) -> None: + """Apply dual-layer rate limiting for search on shared repositories.""" + from robosystems.config import env + from robosystems.config.shared_repositories import is_shared_repository + + if not is_shared_repository(graph_id): + return + + if not env.RATE_LIMIT_ENABLED: + return + + from robosystems.config.valkey_registry import ( + ValkeyDatabase, + create_async_redis_client, + ) + + # Check user has access to the shared repo + from robosystems.middleware.auth.session import SessionFactory + from robosystems.middleware.rate_limits import DualLayerRateLimiter + from robosystems.models.iam.user_repository import UserRepository + + session = SessionFactory() + try: + repo_access = UserRepository.get_by_user_and_repository( + current_user.id, graph_id, session + ) + finally: + session.close() + + if not repo_access: + raise HTTPException( + status_code=http_status.HTTP_403_FORBIDDEN, + detail=f"Access to {graph_id.upper()} repository requires a subscription. " + "Visit https://roboledger.ai/pricing", + ) + + redis_client = create_async_redis_client(ValkeyDatabase.RATE_LIMITS) + + try: + limiter = DualLayerRateLimiter(redis_client) + user_tier = getattr(current_user, "subscription_tier", None) or "ladybug-standard" + + limit_check = await limiter.check_limits( + user_id=str(current_user.id), + graph_id=graph_id, + operation="search", + endpoint=endpoint, + user_tier=user_tier, + repository_plan=repo_access.repository_plan, + ) + + if not limit_check["allowed"]: + reason = limit_check.get("reason", "unknown") + message = limit_check.get("message", "Rate limit exceeded") + + if reason == "no_access" or reason == "endpoint_not_allowed": + raise HTTPException( + status_code=http_status.HTTP_403_FORBIDDEN, + detail=message, + ) + else: + retry_after = str(limit_check.get("detail", {}).get("reset_in", 60)) + raise HTTPException( + status_code=http_status.HTTP_429_TOO_MANY_REQUESTS, + detail=message, + headers={"Retry-After": retry_after}, + ) + finally: + await redis_client.aclose() + + @router.post("", operation_id="search_documents") async def search_documents( graph_id: str, request: SearchRequest, - req: Request, current_user: User = Depends(get_current_user_with_graph), ) -> SearchResponse: """Search filing narratives and text content within a graph.""" + await _check_search_rate_limit(graph_id, current_user, "search") service = _require_search_service() return service.search_documents(graph_id, request) @@ -45,10 +119,10 @@ async def search_documents( async def get_document_section( graph_id: str, document_id: str, - req: Request, current_user: User = Depends(get_current_user_with_graph), ) -> DocumentSection: """Retrieve the full text of a document section by ID.""" + await _check_search_rate_limit(graph_id, current_user, "search") service = _require_search_service() result = service.get_document_section(graph_id, document_id) if result is None: diff --git a/robosystems/routers/offering.py b/robosystems/routers/offering.py index 73823cbf..cab0cfc9 100644 --- a/robosystems/routers/offering.py +++ b/robosystems/routers/offering.py @@ -211,6 +211,7 @@ async def get_service_offerings( plan_info["rate_limits"] = { "queries_per_hour": rate_limits.get("queries_per_hour"), "mcp_queries_per_hour": rate_limits.get("mcp_queries_per_hour"), + "searches_per_hour": rate_limits.get("searches_per_hour"), "agent_calls_per_hour": rate_limits.get("agent_calls_per_hour"), } diff --git a/tests/config/test_rate_limits.py b/tests/config/test_rate_limits.py index 79345604..3e164c5a 100644 --- a/tests/config/test_rate_limits.py +++ b/tests/config/test_rate_limits.py @@ -13,12 +13,12 @@ def test_periods_convert_to_expected_seconds(): assert RateLimitPeriod.DAY.to_seconds() == 86400 -def test_unknown_tier_falls_back_to_free_limits(): - # pick category only present in free config +def test_unknown_tier_falls_back_to_base_limits(): + # pick category only present in base config limit, window = RateLimitConfig.get_rate_limit("mystery", EndpointCategory.AUTH) assert ( - limit == RateLimitConfig.SUBSCRIPTION_RATE_LIMITS["free"][EndpointCategory.AUTH][0] + limit == RateLimitConfig.SUBSCRIPTION_RATE_LIMITS["base"][EndpointCategory.AUTH][0] ) assert window == RateLimitPeriod.MINUTE.to_seconds() @@ -76,6 +76,9 @@ def test_multiplier_can_be_skipped(monkeypatch): # Other graph endpoints ("/v1/graphs/abc/mcp/execute", "POST", EndpointCategory.GRAPH_MCP), ("/v1/graphs/abc/agent/run", "POST", EndpointCategory.GRAPH_AGENT), + # Search endpoints (OpenSearch) + ("/v1/graphs/abc/search", "POST", EndpointCategory.GRAPH_SEARCH), + ("/v1/graphs/abc/search/doc123", "GET", EndpointCategory.GRAPH_SEARCH), ("/v1/graphs/abc/graph/backup", "POST", EndpointCategory.GRAPH_BACKUP), ("/v1/graphs/abc/graph/query", "POST", EndpointCategory.GRAPH_QUERY), ("/v1/graphs/abc/graph/analytics", "GET", EndpointCategory.GRAPH_ANALYTICS), diff --git a/tests/middleware/rate_limits/test_repository_rate_limits.py b/tests/middleware/rate_limits/test_repository_rate_limits.py index 28982bb1..b77b285d 100644 --- a/tests/middleware/rate_limits/test_repository_rate_limits.py +++ b/tests/middleware/rate_limits/test_repository_rate_limits.py @@ -17,6 +17,7 @@ def test_values(self): assert AllowedSharedEndpoints.QUERY.value == "query" assert AllowedSharedEndpoints.MCP.value == "mcp" assert AllowedSharedEndpoints.AGENT.value == "agent" + assert AllowedSharedEndpoints.SEARCH.value == "search" assert AllowedSharedEndpoints.SCHEMA.value == "schema" assert AllowedSharedEndpoints.STATUS.value == "status" @@ -136,6 +137,7 @@ def test_operation_to_category(self, limiter): assert limiter._operation_to_category("query") == EndpointCategory.GRAPH_QUERY assert limiter._operation_to_category("mcp") == EndpointCategory.GRAPH_MCP assert limiter._operation_to_category("agent") == EndpointCategory.GRAPH_AGENT + assert limiter._operation_to_category("search") == EndpointCategory.GRAPH_SEARCH assert limiter._operation_to_category("unknown") == EndpointCategory.GRAPH_READ @pytest.mark.asyncio diff --git a/tests/middleware/rate_limits/test_subscription_rate_limiting.py b/tests/middleware/rate_limits/test_subscription_rate_limiting.py index 0ae0f05d..5f5303ef 100644 --- a/tests/middleware/rate_limits/test_subscription_rate_limiting.py +++ b/tests/middleware/rate_limits/test_subscription_rate_limiting.py @@ -82,41 +82,41 @@ def test_should_use_subscription_limits(self): def test_get_subscription_rate_limit(self): """Test rate limit retrieval for different tiers.""" - # Free tier - limit, window = get_subscription_rate_limit("free", EndpointCategory.GRAPH_READ) - assert limit == 100 + # Base tier + limit, window = get_subscription_rate_limit("base", EndpointCategory.GRAPH_READ) + assert limit == 30 assert window == 60 # 1 minute - limit, window = get_subscription_rate_limit("free", EndpointCategory.GRAPH_MCP) - assert limit == 10 + limit, window = get_subscription_rate_limit("base", EndpointCategory.GRAPH_MCP) + assert limit == 5 assert window == 60 # 1 minute # LadybugDB Standard tier limit, window = get_subscription_rate_limit( "ladybug-standard", EndpointCategory.GRAPH_READ ) - assert limit == 500 + assert limit == 120 assert window == 60 # LadybugDB Standard tier write limit, window = get_subscription_rate_limit( "ladybug-standard", EndpointCategory.GRAPH_WRITE ) - assert limit == 100 + assert limit == 30 assert window == 60 - # LadybugDB Large tier (enterprise-level) + # LadybugDB Large tier — same base as standard, multiplied by graph.yml limit, window = get_subscription_rate_limit( "ladybug-large", EndpointCategory.GRAPH_QUERY ) - assert limit == 1000 + assert limit == 60 # Same base; 1.5x multiplier applied separately assert window == 60 def test_standard_tier_has_appropriate_limits(self): """Test that standard tier has appropriate limits.""" for category in EndpointCategory: standard_limit = get_subscription_rate_limit("ladybug-standard", category) - free_limit = get_subscription_rate_limit("free", category) + free_limit = get_subscription_rate_limit("base", category) # Standard should have higher limits than free assert standard_limit is not None and free_limit is not None assert standard_limit[0] >= free_limit[0] @@ -142,26 +142,26 @@ def mock_request(self): @patch( "robosystems.middleware.rate_limits.rate_limiting.rate_limit_cache.check_rate_limit" ) - def test_subscription_rate_limiting_free_tier( + def test_subscription_rate_limiting_base_tier( self, mock_check_rate_limit, mock_get_user, mock_request ): - """Test rate limiting for anonymous user (free tier).""" - # Setup mocks - anonymous user gets free tier + """Test rate limiting for anonymous user (base tier).""" + # Setup mocks - anonymous user gets base tier mock_get_user.return_value = None # Anonymous user mock_check_rate_limit.return_value = (True, 50) # Allowed with 50 remaining # Call the dependency subscription_aware_rate_limit_dependency(mock_request) - # Verify correct limit was checked (100/minute for free tier graph reads) + # Verify correct limit was checked (30/minute for base tier graph reads) mock_check_rate_limit.assert_called_once_with( - "anon_sub:192.168.1.1:graph_read", 100, 60 + "anon_sub:192.168.1.1:graph_read", 30, 60 ) # Verify request state was updated assert mock_request.state.rate_limit_remaining == 50 - assert mock_request.state.rate_limit_limit == 100 - assert mock_request.state.rate_limit_tier == "free" + assert mock_request.state.rate_limit_limit == 30 + assert mock_request.state.rate_limit_tier == "base" assert mock_request.state.rate_limit_category == "graph_read" @patch("robosystems.middleware.rate_limits.rate_limiting.get_user_from_request") @@ -179,14 +179,14 @@ def test_subscription_rate_limiting_standard_tier( # Call the dependency subscription_aware_rate_limit_dependency(mock_request) - # Verify correct limit was checked (500/minute for standard tier graph reads) + # Verify correct limit was checked (120/minute for standard tier graph reads) mock_check_rate_limit.assert_called_once_with( - "user_sub:user_456:graph_read", 500, 60 + "user_sub:user_456:graph_read", 120, 60 ) # Verify request state was updated assert mock_request.state.rate_limit_remaining == 5000 - assert mock_request.state.rate_limit_limit == 500 + assert mock_request.state.rate_limit_limit == 120 assert mock_request.state.rate_limit_tier == "ladybug-standard" @patch("robosystems.middleware.rate_limits.rate_limiting.get_user_from_request") @@ -204,7 +204,7 @@ def test_subscription_rate_limiting_exceeded( mock_request, ): """Test rate limiting when limit is exceeded.""" - # Setup mocks - anonymous user gets free tier + # Setup mocks - anonymous user gets base tier mock_get_user.return_value = None # Anonymous user mock_check_rate_limit.return_value = (False, 0) # Not allowed, limit exceeded @@ -219,7 +219,7 @@ def test_subscription_rate_limiting_exceeded( # Verify headers assert exc_info.value.headers is not None - assert exc_info.value.headers["X-RateLimit-Tier"] == "free" + assert exc_info.value.headers["X-RateLimit-Tier"] == "base" assert exc_info.value.headers["X-RateLimit-Category"] == "graph_read" # Verify security logging @@ -229,10 +229,10 @@ def test_subscription_rate_limiting_exceeded( @patch( "robosystems.middleware.rate_limits.rate_limiting.rate_limit_cache.check_rate_limit" ) - def test_anonymous_user_gets_free_tier( + def test_anonymous_user_gets_base_tier( self, mock_check_rate_limit, mock_get_user, mock_request ): - """Test that anonymous users get free tier limits.""" + """Test that anonymous users get base tier limits.""" # Setup mocks mock_get_user.return_value = None # Anonymous user mock_check_rate_limit.return_value = (True, 10) @@ -240,11 +240,11 @@ def test_anonymous_user_gets_free_tier( # Call the dependency subscription_aware_rate_limit_dependency(mock_request) - # Verify anonymous user identifier and free tier limits + # Verify anonymous user identifier and base tier limits expected_identifier = f"anon_sub:{mock_request.client.host}:graph_read" - mock_check_rate_limit.assert_called_once_with(expected_identifier, 100, 60) + mock_check_rate_limit.assert_called_once_with(expected_identifier, 30, 60) - assert mock_request.state.rate_limit_tier == "free" + assert mock_request.state.rate_limit_tier == "base" def test_mcp_endpoint_category(self): """Test MCP endpoints get correct category and limits.""" @@ -259,12 +259,12 @@ def test_mcp_endpoint_category(self): ) # Check MCP limits for different tiers - limit, window = get_subscription_rate_limit("free", EndpointCategory.GRAPH_MCP) - assert limit == 10 + limit, window = get_subscription_rate_limit("base", EndpointCategory.GRAPH_MCP) + assert limit == 5 assert window == 60 # Minute limit limit, window = get_subscription_rate_limit( "ladybug-standard", EndpointCategory.GRAPH_MCP ) - assert limit == 100 + assert limit == 30 assert window == 60 diff --git a/tests/middleware/rate_limits/test_subscription_rate_limits.py b/tests/middleware/rate_limits/test_subscription_rate_limits.py index 521a5ee6..62b35eb7 100644 --- a/tests/middleware/rate_limits/test_subscription_rate_limits.py +++ b/tests/middleware/rate_limits/test_subscription_rate_limits.py @@ -21,8 +21,8 @@ def test_subscription_rate_limits_imported(self): def test_get_subscription_rate_limit(self): """Test getting subscription rate limit for tier and category.""" - # Test for various tiers - tiers = ["free", "starter", "pro", "enterprise"] + # Test for actual tiers + tiers = ["base", "ladybug-standard", "ladybug-large", "ladybug-xlarge"] categories = [ EndpointCategory.GRAPH_READ, EndpointCategory.GRAPH_WRITE, @@ -49,10 +49,14 @@ def test_get_subscription_rate_limit_delegates(self, mock_get_rate_limit): """Test that get_subscription_rate_limit delegates to RateLimitConfig.""" mock_get_rate_limit.return_value = (100, 60) - result = get_subscription_rate_limit("pro", EndpointCategory.GRAPH_READ) + result = get_subscription_rate_limit( + "ladybug-standard", EndpointCategory.GRAPH_READ + ) assert result == (100, 60) - mock_get_rate_limit.assert_called_once_with("pro", EndpointCategory.GRAPH_READ) + mock_get_rate_limit.assert_called_once_with( + "ladybug-standard", EndpointCategory.GRAPH_READ + ) def test_get_endpoint_category_query_endpoints(self): """Test endpoint categorization for query endpoints.""" @@ -186,26 +190,18 @@ def test_subscription_rate_limits_structure(self): ) # window in seconds or period def test_rate_limit_hierarchy(self): - """Test that rate limits follow expected hierarchy.""" - # If we have access to actual limits, verify enterprise > pro > starter > free - if SUBSCRIPTION_RATE_LIMITS and len(SUBSCRIPTION_RATE_LIMITS) > 0: - # Get limits for a common category if available - test_category = EndpointCategory.GRAPH_READ - - free_limit = get_subscription_rate_limit("free", test_category) - starter_limit = get_subscription_rate_limit("starter", test_category) - pro_limit = get_subscription_rate_limit("pro", test_category) - enterprise_limit = get_subscription_rate_limit("enterprise", test_category) - - # If all tiers have limits, verify hierarchy - if all([free_limit, starter_limit, pro_limit, enterprise_limit]): - # Higher tiers should have higher or equal limits - assert free_limit is not None and starter_limit is not None - assert free_limit[0] <= starter_limit[0] - assert starter_limit is not None and pro_limit is not None - assert starter_limit[0] <= pro_limit[0] - assert pro_limit is not None and enterprise_limit is not None - assert pro_limit[0] <= enterprise_limit[0] + """Test that rate limits follow expected hierarchy: base <= standard <= large <= xlarge.""" + test_category = EndpointCategory.GRAPH_READ + + base_limit = get_subscription_rate_limit("base", test_category) + standard_limit = get_subscription_rate_limit("ladybug-standard", test_category) + large_limit = get_subscription_rate_limit("ladybug-large", test_category) + xlarge_limit = get_subscription_rate_limit("ladybug-xlarge", test_category) + + assert all([base_limit, standard_limit, large_limit, xlarge_limit]) + assert base_limit[0] <= standard_limit[0] + assert standard_limit[0] <= large_limit[0] + assert large_limit[0] <= xlarge_limit[0] def test_endpoint_category_enum_values(self): """Test that EndpointCategory enum is accessible.""" @@ -224,7 +220,9 @@ def test_functions_use_rate_limit_config(self, mock_config): mock_config.get_endpoint_category.return_value = EndpointCategory.GRAPH_READ # Test get_subscription_rate_limit - result1 = get_subscription_rate_limit("pro", EndpointCategory.GRAPH_READ) + result1 = get_subscription_rate_limit( + "ladybug-standard", EndpointCategory.GRAPH_READ + ) assert result1 == (100, 60) # Test get_endpoint_category diff --git a/tests/routers/graphs/test_search.py b/tests/routers/graphs/test_search.py index 57c000bd..3f005192 100644 --- a/tests/routers/graphs/test_search.py +++ b/tests/routers/graphs/test_search.py @@ -96,6 +96,11 @@ def test_raises_503_when_unavailable(self): class TestSearchDocuments: """Tests for search_documents endpoint.""" + @pytest.fixture(autouse=True) + def _skip_rate_limit(self): + with patch(f"{MODULE}._check_search_rate_limit", return_value=None): + yield + @pytest.mark.unit async def test_returns_search_response(self): mock_service = MagicMock() @@ -107,7 +112,6 @@ async def test_returns_search_response(self): result = await search_documents( graph_id="sec", request=request, - req=MagicMock(), current_user=MagicMock(), ) @@ -128,7 +132,6 @@ async def test_passes_graph_id_to_service(self): await search_documents( graph_id="custom_graph", request=request, - req=MagicMock(), current_user=MagicMock(), ) @@ -151,7 +154,6 @@ async def test_passes_filters_through(self): await search_documents( graph_id="sec", request=request, - req=MagicMock(), current_user=MagicMock(), ) @@ -168,7 +170,6 @@ async def test_raises_503_when_service_unavailable(self): await search_documents( graph_id="sec", request=SearchRequest(query="test"), - req=MagicMock(), current_user=MagicMock(), ) assert exc_info.value.status_code == 503 @@ -178,6 +179,11 @@ async def test_raises_503_when_service_unavailable(self): class TestGetDocumentSection: """Tests for get_document_section endpoint.""" + @pytest.fixture(autouse=True) + def _skip_rate_limit(self): + with patch(f"{MODULE}._check_search_rate_limit", return_value=None): + yield + @pytest.mark.unit async def test_returns_document_section(self): mock_service = MagicMock() @@ -188,7 +194,6 @@ async def test_returns_document_section(self): result = await get_document_section( graph_id="sec", document_id="abc123", - req=MagicMock(), current_user=MagicMock(), ) @@ -207,7 +212,6 @@ async def test_raises_404_when_not_found(self): await get_document_section( graph_id="sec", document_id="nonexistent", - req=MagicMock(), current_user=MagicMock(), ) assert exc_info.value.status_code == 404 @@ -224,7 +228,6 @@ async def test_passes_graph_id_to_service(self): await get_document_section( graph_id="custom_graph", document_id="abc123", - req=MagicMock(), current_user=MagicMock(), ) @@ -239,7 +242,6 @@ async def test_raises_503_when_service_unavailable(self): await get_document_section( graph_id="sec", document_id="abc123", - req=MagicMock(), current_user=MagicMock(), ) assert exc_info.value.status_code == 503