diff --git a/src/sentry/api/base.py b/src/sentry/api/base.py index e7e66d1d602ed8..5525813514905e 100644 --- a/src/sentry/api/base.py +++ b/src/sentry/api/base.py @@ -154,7 +154,7 @@ def apply_cors_headers( "sentry-trace, baggage, X-CSRFToken" ) response["Access-Control-Expose-Headers"] = ( - "X-Sentry-Error, X-Sentry-Direct-Hit, X-Hits, X-Max-Hits, Endpoint, Retry-After, Link" + "X-Sentry-Error, X-Sentry-Direct-Hit, X-Sentry-Warning, X-Hits, X-Max-Hits, Endpoint, Retry-After, Link" ) if request.META.get("HTTP_ORIGIN") == "null": diff --git a/src/sentry/core/endpoints/organization_projects.py b/src/sentry/core/endpoints/organization_projects.py index 190cff2ac9a838..3a2d7b3301c696 100644 --- a/src/sentry/core/endpoints/organization_projects.py +++ b/src/sentry/core/endpoints/organization_projects.py @@ -7,6 +7,7 @@ from rest_framework.request import Request from rest_framework.response import Response +from sentry import options from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import region_silo_endpoint from sentry.api.bases.organization import OrganizationAndStaffPermission, OrganizationEndpoint @@ -162,13 +163,28 @@ def get(self, request: Request, organization: Organization) -> Response: if get_all_projects: queryset = queryset.order_by("slug").select_related("organization") - return Response( + + # Fetch MAX + 1 to detect if there are more without expensive count() + max_projects = options.get("api.organization-projects-max-results") + projects = list(queryset[: max_projects + 1]) + has_more = len(projects) > max_projects + + response = Response( serialize( - list(queryset), + projects[:max_projects], request.user, ProjectSummarySerializer(collapse=collapse, dataset=dataset), ) ) + + if has_more: + response["X-Sentry-Warning"] = ( + f"This organization has more than {max_projects} projects. " + f"Only the first {max_projects} are shown. " + f"Please use pagination or filter your query." + ) + + return response else: expand = set() if request.GET.get("transactionStats"): diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index f3181f47495440..ec88e72a0d7584 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -3470,6 +3470,13 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) +# Maximum number of projects to return when all_projects parameter is used +register( + "api.organization-projects-max-results", + default=500, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) + register( "performance.event-tracker.sample-rate.transactions", default=0.0, diff --git a/tests/sentry/api/test_base.py b/tests/sentry/api/test_base.py index e577ac9a8a25d8..1712c0836413be 100644 --- a/tests/sentry/api/test_base.py +++ b/tests/sentry/api/test_base.py @@ -133,7 +133,7 @@ def test_basic_cors(self) -> None: "sentry-trace, baggage, X-CSRFToken" ) assert response["Access-Control-Expose-Headers"] == ( - "X-Sentry-Error, X-Sentry-Direct-Hit, X-Hits, X-Max-Hits, " + "X-Sentry-Error, X-Sentry-Direct-Hit, X-Sentry-Warning, X-Hits, X-Max-Hits, " "Endpoint, Retry-After, Link" ) assert response["Access-Control-Allow-Methods"] == "GET, HEAD, OPTIONS" @@ -161,7 +161,7 @@ def test_allow_credentials_subdomain(self) -> None: "sentry-trace, baggage, X-CSRFToken" ) assert response["Access-Control-Expose-Headers"] == ( - "X-Sentry-Error, X-Sentry-Direct-Hit, X-Hits, X-Max-Hits, " + "X-Sentry-Error, X-Sentry-Direct-Hit, X-Sentry-Warning, X-Hits, X-Max-Hits, " "Endpoint, Retry-After, Link" ) assert response["Access-Control-Allow-Methods"] == "GET, HEAD, OPTIONS" @@ -189,7 +189,7 @@ def test_allow_credentials_root_domain(self) -> None: "sentry-trace, baggage, X-CSRFToken" ) assert response["Access-Control-Expose-Headers"] == ( - "X-Sentry-Error, X-Sentry-Direct-Hit, X-Hits, X-Max-Hits, " + "X-Sentry-Error, X-Sentry-Direct-Hit, X-Sentry-Warning, X-Hits, X-Max-Hits, " "Endpoint, Retry-After, Link" ) assert response["Access-Control-Allow-Methods"] == "GET, HEAD, OPTIONS" @@ -218,7 +218,7 @@ def test_allow_credentials_allowed_domain(self) -> None: "sentry-trace, baggage, X-CSRFToken" ) assert response["Access-Control-Expose-Headers"] == ( - "X-Sentry-Error, X-Sentry-Direct-Hit, X-Hits, X-Max-Hits, " + "X-Sentry-Error, X-Sentry-Direct-Hit, X-Sentry-Warning, X-Hits, X-Max-Hits, " "Endpoint, Retry-After, Link" ) assert response["Access-Control-Allow-Methods"] == "GET, HEAD, OPTIONS" @@ -291,7 +291,7 @@ def test_cors_not_configured_is_valid(self) -> None: "sentry-trace, baggage, X-CSRFToken" ) assert response["Access-Control-Expose-Headers"] == ( - "X-Sentry-Error, X-Sentry-Direct-Hit, X-Hits, X-Max-Hits, " + "X-Sentry-Error, X-Sentry-Direct-Hit, X-Sentry-Warning, X-Hits, X-Max-Hits, " "Endpoint, Retry-After, Link" ) assert response["Access-Control-Allow-Methods"] == "GET, HEAD, OPTIONS" diff --git a/tests/sentry/core/endpoints/test_organization_projects.py b/tests/sentry/core/endpoints/test_organization_projects.py index 921e27242ff06f..6fb400bf13d028 100644 --- a/tests/sentry/core/endpoints/test_organization_projects.py +++ b/tests/sentry/core/endpoints/test_organization_projects.py @@ -4,6 +4,7 @@ from sentry.models.apikey import ApiKey from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase +from sentry.testutils.helpers.options import override_options from sentry.testutils.silo import assume_test_silo_mode from sentry.testutils.skips import requires_snuba @@ -312,6 +313,39 @@ def test_expand_context_options(self) -> None: } assert not response.data[1].get("options") + @override_options({"api.organization-projects-max-results": 3}) + def test_all_projects_limit_exceeded(self) -> None: + # Create 4 projects to exceed the limit of 3 + for i in range(4): + self.create_project(teams=[self.team], name=f"project-{i}", slug=f"project-{i}") + + response = self.get_success_response( + self.organization.slug, qs_params={"all_projects": "1"} + ) + # Should return 3 projects (capped) + assert len(response.data) == 3 + # Should include truncation warning header + assert "X-Sentry-Warning" in response + assert "more than 3 projects" in response["X-Sentry-Warning"].lower() + assert "first 3" in response["X-Sentry-Warning"].lower() + + @override_options({"api.organization-projects-max-results": 3}) + def test_all_projects_at_limit(self) -> None: + # Create exactly 3 projects - should succeed without warning + projects = [] + for i in range(3): + projects.append( + self.create_project(teams=[self.team], name=f"project-{i}", slug=f"project-{i}") + ) + + response = self.get_success_response( + self.organization.slug, qs_params={"all_projects": "1"} + ) + # Should return all 3 projects + assert len(response.data) == 3 + # Should NOT include truncation warning header (exactly at limit) + assert "X-Sentry-Warning" not in response + class OrganizationProjectsCountTest(APITestCase): endpoint = "sentry-api-0-organization-projects-count"