From 8ec1ed2bcac985461ca3884391e1094c6ea8c80b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 5 Dec 2025 02:00:13 +0000 Subject: [PATCH 01/15] Limit organization projects endpoint to 1000 Co-authored-by: david --- .../core/endpoints/organization_projects.py | 14 ++++++++++- .../endpoints/test_organization_projects.py | 24 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/sentry/core/endpoints/organization_projects.py b/src/sentry/core/endpoints/organization_projects.py index 190cff2ac9a838..389a2343a4894c 100644 --- a/src/sentry/core/endpoints/organization_projects.py +++ b/src/sentry/core/endpoints/organization_projects.py @@ -162,9 +162,21 @@ def get(self, request: Request, organization: Organization) -> Response: if get_all_projects: queryset = queryset.order_by("slug").select_related("organization") + + # Limit to 1000 projects to prevent timeouts + project_count = queryset.count() + if project_count > 1000: + return Response( + { + "detail": "This organization has too many projects to return them all at once. " + "Please use pagination or filter your query to reduce the number of projects." + }, + status=400, + ) + return Response( serialize( - list(queryset), + list(queryset[:1000]), request.user, ProjectSummarySerializer(collapse=collapse, dataset=dataset), ) diff --git a/tests/sentry/core/endpoints/test_organization_projects.py b/tests/sentry/core/endpoints/test_organization_projects.py index 921e27242ff06f..6b2761f537c2f6 100644 --- a/tests/sentry/core/endpoints/test_organization_projects.py +++ b/tests/sentry/core/endpoints/test_organization_projects.py @@ -312,6 +312,30 @@ def test_expand_context_options(self) -> None: } assert not response.data[1].get("options") + def test_all_projects_limit_exceeded(self) -> None: + # Create 1001 projects to exceed the limit + for i in range(1001): + self.create_project(teams=[self.team], name=f"project-{i}", slug=f"project-{i}") + + response = self.get_error_response( + self.organization.slug, qs_params={"all_projects": "1"}, status_code=400 + ) + assert "too many projects" in response.data["detail"].lower() + + def test_all_projects_at_limit(self) -> None: + # Create exactly 1000 projects - should succeed + projects = [] + for i in range(1000): + 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 1000 projects + assert len(response.data) == 1000 + class OrganizationProjectsCountTest(APITestCase): endpoint = "sentry-api-0-organization-projects-count" From 1e2393ef3d54f6360733ca53c03dd33f28a64d1c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 5 Dec 2025 02:02:14 +0000 Subject: [PATCH 02/15] Add X-Sentry-Project-Truncated header for project list Co-authored-by: david --- src/sentry/api/base.py | 2 +- .../core/endpoints/organization_projects.py | 19 ++++++++++--------- .../endpoints/test_organization_projects.py | 15 +++++++++++---- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/src/sentry/api/base.py b/src/sentry/api/base.py index e7e66d1d602ed8..287686c3a13ce5 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-Project-Truncated, 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 389a2343a4894c..1c103dd7f71a23 100644 --- a/src/sentry/core/endpoints/organization_projects.py +++ b/src/sentry/core/endpoints/organization_projects.py @@ -165,22 +165,23 @@ def get(self, request: Request, organization: Organization) -> Response: # Limit to 1000 projects to prevent timeouts project_count = queryset.count() - if project_count > 1000: - return Response( - { - "detail": "This organization has too many projects to return them all at once. " - "Please use pagination or filter your query to reduce the number of projects." - }, - status=400, - ) - return Response( + response = Response( serialize( list(queryset[:1000]), request.user, ProjectSummarySerializer(collapse=collapse, dataset=dataset), ) ) + + if project_count > 1000: + response["X-Sentry-Project-Truncated"] = ( + f"This organization has {project_count} projects. " + f"Only the first 1000 are shown. " + f"Please use pagination or filter your query." + ) + + return response else: expand = set() if request.GET.get("transactionStats"): diff --git a/tests/sentry/core/endpoints/test_organization_projects.py b/tests/sentry/core/endpoints/test_organization_projects.py index 6b2761f537c2f6..efe179a84d29a8 100644 --- a/tests/sentry/core/endpoints/test_organization_projects.py +++ b/tests/sentry/core/endpoints/test_organization_projects.py @@ -317,13 +317,18 @@ def test_all_projects_limit_exceeded(self) -> None: for i in range(1001): self.create_project(teams=[self.team], name=f"project-{i}", slug=f"project-{i}") - response = self.get_error_response( - self.organization.slug, qs_params={"all_projects": "1"}, status_code=400 + response = self.get_success_response( + self.organization.slug, qs_params={"all_projects": "1"} ) - assert "too many projects" in response.data["detail"].lower() + # Should return 1000 projects (capped) + assert len(response.data) == 1000 + # Should include truncation warning header + assert "X-Sentry-Project-Truncated" in response + assert "1001 projects" in response["X-Sentry-Project-Truncated"] + assert "1000" in response["X-Sentry-Project-Truncated"] def test_all_projects_at_limit(self) -> None: - # Create exactly 1000 projects - should succeed + # Create exactly 1000 projects - should succeed without warning projects = [] for i in range(1000): projects.append( @@ -335,6 +340,8 @@ def test_all_projects_at_limit(self) -> None: ) # Should return all 1000 projects assert len(response.data) == 1000 + # Should NOT include truncation warning header (exactly at limit) + assert "X-Sentry-Project-Truncated" not in response class OrganizationProjectsCountTest(APITestCase): From 2031a5c89eb312c2b353d6b60bde8c1e58d1ac21 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 02:03:21 +0000 Subject: [PATCH 03/15] :hammer_and_wrench: apply pre-commit fixes --- src/sentry/core/endpoints/organization_projects.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/sentry/core/endpoints/organization_projects.py b/src/sentry/core/endpoints/organization_projects.py index 1c103dd7f71a23..8ae23c3a2764ec 100644 --- a/src/sentry/core/endpoints/organization_projects.py +++ b/src/sentry/core/endpoints/organization_projects.py @@ -162,10 +162,10 @@ def get(self, request: Request, organization: Organization) -> Response: if get_all_projects: queryset = queryset.order_by("slug").select_related("organization") - + # Limit to 1000 projects to prevent timeouts project_count = queryset.count() - + response = Response( serialize( list(queryset[:1000]), @@ -173,14 +173,14 @@ def get(self, request: Request, organization: Organization) -> Response: ProjectSummarySerializer(collapse=collapse, dataset=dataset), ) ) - + if project_count > 1000: response["X-Sentry-Project-Truncated"] = ( f"This organization has {project_count} projects. " f"Only the first 1000 are shown. " f"Please use pagination or filter your query." ) - + return response else: expand = set() From 4a75f7dca60215f63ca59ae8da6b5cee5db9d9e5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 5 Dec 2025 02:06:56 +0000 Subject: [PATCH 04/15] Refactor: Use X-Sentry-Warning header for project truncation Co-authored-by: david --- src/sentry/api/base.py | 2 +- src/sentry/core/endpoints/organization_projects.py | 2 +- tests/sentry/core/endpoints/test_organization_projects.py | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/sentry/api/base.py b/src/sentry/api/base.py index 287686c3a13ce5..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-Sentry-Project-Truncated, 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 8ae23c3a2764ec..be876442b66cd1 100644 --- a/src/sentry/core/endpoints/organization_projects.py +++ b/src/sentry/core/endpoints/organization_projects.py @@ -175,7 +175,7 @@ def get(self, request: Request, organization: Organization) -> Response: ) if project_count > 1000: - response["X-Sentry-Project-Truncated"] = ( + response["X-Sentry-Warning"] = ( f"This organization has {project_count} projects. " f"Only the first 1000 are shown. " f"Please use pagination or filter your query." diff --git a/tests/sentry/core/endpoints/test_organization_projects.py b/tests/sentry/core/endpoints/test_organization_projects.py index efe179a84d29a8..7e8a509e09fe75 100644 --- a/tests/sentry/core/endpoints/test_organization_projects.py +++ b/tests/sentry/core/endpoints/test_organization_projects.py @@ -323,9 +323,9 @@ def test_all_projects_limit_exceeded(self) -> None: # Should return 1000 projects (capped) assert len(response.data) == 1000 # Should include truncation warning header - assert "X-Sentry-Project-Truncated" in response - assert "1001 projects" in response["X-Sentry-Project-Truncated"] - assert "1000" in response["X-Sentry-Project-Truncated"] + assert "X-Sentry-Warning" in response + assert "1001 projects" in response["X-Sentry-Warning"] + assert "1000" in response["X-Sentry-Warning"] def test_all_projects_at_limit(self) -> None: # Create exactly 1000 projects - should succeed without warning @@ -341,7 +341,7 @@ def test_all_projects_at_limit(self) -> None: # Should return all 1000 projects assert len(response.data) == 1000 # Should NOT include truncation warning header (exactly at limit) - assert "X-Sentry-Project-Truncated" not in response + assert "X-Sentry-Warning" not in response class OrganizationProjectsCountTest(APITestCase): From dc09c286b19cf6f6ec2d36b3ee8907abc569af20 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 5 Dec 2025 02:15:24 +0000 Subject: [PATCH 05/15] feat: Add global success handlers for API warnings Co-authored-by: david --- static/app/api.tsx | 20 ++++++++++++++++++++ static/app/views/app/index.tsx | 3 ++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/static/app/api.tsx b/static/app/api.tsx index dbb56d9f8fea57..29562c241ded64 100644 --- a/static/app/api.tsx +++ b/static/app/api.tsx @@ -130,6 +130,12 @@ const globalErrorHandlers: Array< (resp: ResponseMeta, options: RequestOptions) => boolean > = []; +/** + * Global handlers for successful responses + */ +const globalSuccessHandlers: Array<(resp: ResponseMeta, options: RequestOptions) => void> = + []; + export const initApiClientErrorHandling = () => globalErrorHandlers.push((resp: ResponseMeta, options: RequestOptions) => { const pageAllowsAnon = ALLOWED_ANON_PAGES.find(regex => @@ -184,6 +190,18 @@ export const initApiClientErrorHandling = () => return true; }); +export const initApiClientWarningHandling = () => + globalSuccessHandlers.push((resp: ResponseMeta) => { + const warningMessage = resp.getResponseHeader('X-Sentry-Warning'); + + if (warningMessage) { + // Dynamically import to avoid circular dependency + import('sentry/actionCreators/indicator').then(({addMessage}) => { + addMessage(warningMessage, 'warning', {duration: 10000}); + }); + } + }); + /** * Construct a full request URL */ @@ -608,6 +626,8 @@ export class Client { const responseData = isResponseJSON ? responseJSON : responseText; if (ok) { + // Call global success handlers (e.g., for warning headers) + globalSuccessHandlers.forEach(handler => handler(responseMeta, options)); successHandler(responseMeta, statusText, responseData); } else { // There's no reason we should be here with a 200 response, but we get diff --git a/static/app/views/app/index.tsx b/static/app/views/app/index.tsx index f2f8edc1897183..00c91daba90538 100644 --- a/static/app/views/app/index.tsx +++ b/static/app/views/app/index.tsx @@ -8,7 +8,7 @@ import { } from 'sentry/actionCreators/developmentAlerts'; import {fetchGuides} from 'sentry/actionCreators/guides'; import {fetchOrganizations} from 'sentry/actionCreators/organizations'; -import {initApiClientErrorHandling} from 'sentry/api'; +import {initApiClientErrorHandling, initApiClientWarningHandling} from 'sentry/api'; import ErrorBoundary from 'sentry/components/errorBoundary'; import GlobalModal from 'sentry/components/globalModal'; import {useGlobalModal} from 'sentry/components/globalModal/useGlobalModal'; @@ -158,6 +158,7 @@ function App() { } initApiClientErrorHandling(); + initApiClientWarningHandling(); fetchGuides(); // When the app is unloaded clear the organizationst list From 873c381ca592ddd1de1090ae867b866bb77578a9 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 02:16:41 +0000 Subject: [PATCH 06/15] :hammer_and_wrench: apply pre-commit fixes --- static/app/api.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/static/app/api.tsx b/static/app/api.tsx index 29562c241ded64..43018f572dd735 100644 --- a/static/app/api.tsx +++ b/static/app/api.tsx @@ -133,8 +133,9 @@ const globalErrorHandlers: Array< /** * Global handlers for successful responses */ -const globalSuccessHandlers: Array<(resp: ResponseMeta, options: RequestOptions) => void> = - []; +const globalSuccessHandlers: Array< + (resp: ResponseMeta, options: RequestOptions) => void +> = []; export const initApiClientErrorHandling = () => globalErrorHandlers.push((resp: ResponseMeta, options: RequestOptions) => { @@ -193,7 +194,7 @@ export const initApiClientErrorHandling = () => export const initApiClientWarningHandling = () => globalSuccessHandlers.push((resp: ResponseMeta) => { const warningMessage = resp.getResponseHeader('X-Sentry-Warning'); - + if (warningMessage) { // Dynamically import to avoid circular dependency import('sentry/actionCreators/indicator').then(({addMessage}) => { From bd23e59241f72b95e631075660f0c9490b2e0855 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 5 Dec 2025 02:19:41 +0000 Subject: [PATCH 07/15] Optimize project fetching and warning for large organizations Co-authored-by: david --- src/sentry/core/endpoints/organization_projects.py | 13 ++++++++----- .../core/endpoints/test_organization_projects.py | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/sentry/core/endpoints/organization_projects.py b/src/sentry/core/endpoints/organization_projects.py index be876442b66cd1..de41b126a1310a 100644 --- a/src/sentry/core/endpoints/organization_projects.py +++ b/src/sentry/core/endpoints/organization_projects.py @@ -164,20 +164,23 @@ def get(self, request: Request, organization: Organization) -> Response: queryset = queryset.order_by("slug").select_related("organization") # Limit to 1000 projects to prevent timeouts - project_count = queryset.count() + # Fetch MAX + 1 to detect if there are more without expensive count() + MAX_PROJECTS = 1000 + projects = list(queryset[: MAX_PROJECTS + 1]) + has_more = len(projects) > MAX_PROJECTS response = Response( serialize( - list(queryset[:1000]), + projects[:MAX_PROJECTS], request.user, ProjectSummarySerializer(collapse=collapse, dataset=dataset), ) ) - if project_count > 1000: + if has_more: response["X-Sentry-Warning"] = ( - f"This organization has {project_count} projects. " - f"Only the first 1000 are shown. " + 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." ) diff --git a/tests/sentry/core/endpoints/test_organization_projects.py b/tests/sentry/core/endpoints/test_organization_projects.py index 7e8a509e09fe75..71ce18acb4c4a2 100644 --- a/tests/sentry/core/endpoints/test_organization_projects.py +++ b/tests/sentry/core/endpoints/test_organization_projects.py @@ -324,8 +324,8 @@ def test_all_projects_limit_exceeded(self) -> None: assert len(response.data) == 1000 # Should include truncation warning header assert "X-Sentry-Warning" in response - assert "1001 projects" in response["X-Sentry-Warning"] - assert "1000" in response["X-Sentry-Warning"] + assert "more than 1000 projects" in response["X-Sentry-Warning"].lower() + assert "first 1000" in response["X-Sentry-Warning"].lower() def test_all_projects_at_limit(self) -> None: # Create exactly 1000 projects - should succeed without warning From 556918a7ae90385eda12f585a835a785e70bcc8f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 5 Dec 2025 02:22:15 +0000 Subject: [PATCH 08/15] Refactor: Use constant for max projects in organization projects Co-authored-by: david --- .../core/endpoints/organization_projects.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/sentry/core/endpoints/organization_projects.py b/src/sentry/core/endpoints/organization_projects.py index de41b126a1310a..ff80a1af393a8f 100644 --- a/src/sentry/core/endpoints/organization_projects.py +++ b/src/sentry/core/endpoints/organization_projects.py @@ -32,6 +32,9 @@ "Invalid stats_period. Valid choices are '', '1h', '24h', '7d', '14d', '30d', and '90d'" ) +# Maximum number of projects to return when all_projects parameter is used +MAX_ALL_PROJECTS = 1000 + DATASETS = { "": discover, # in case they pass an empty query string fall back on default "discover": discover, @@ -163,15 +166,13 @@ def get(self, request: Request, organization: Organization) -> Response: if get_all_projects: queryset = queryset.order_by("slug").select_related("organization") - # Limit to 1000 projects to prevent timeouts # Fetch MAX + 1 to detect if there are more without expensive count() - MAX_PROJECTS = 1000 - projects = list(queryset[: MAX_PROJECTS + 1]) - has_more = len(projects) > MAX_PROJECTS + projects = list(queryset[: MAX_ALL_PROJECTS + 1]) + has_more = len(projects) > MAX_ALL_PROJECTS response = Response( serialize( - projects[:MAX_PROJECTS], + projects[:MAX_ALL_PROJECTS], request.user, ProjectSummarySerializer(collapse=collapse, dataset=dataset), ) @@ -179,8 +180,8 @@ def get(self, request: Request, organization: Organization) -> Response: 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"This organization has more than {MAX_ALL_PROJECTS} projects. " + f"Only the first {MAX_ALL_PROJECTS} are shown. " f"Please use pagination or filter your query." ) From ada47847191fb0412ca81db438df744348c70f10 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 5 Dec 2025 02:29:12 +0000 Subject: [PATCH 09/15] Refactor: Make max projects configurable and fix warning message type Co-authored-by: david --- .../core/endpoints/organization_projects.py | 15 +++++++-------- src/sentry/options/defaults.py | 7 +++++++ static/app/api.tsx | 2 +- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/sentry/core/endpoints/organization_projects.py b/src/sentry/core/endpoints/organization_projects.py index ff80a1af393a8f..1804ee214ea771 100644 --- a/src/sentry/core/endpoints/organization_projects.py +++ b/src/sentry/core/endpoints/organization_projects.py @@ -21,6 +21,7 @@ from sentry.apidocs.examples.organization_examples import OrganizationExamples from sentry.apidocs.parameters import CursorQueryParam, GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer +from sentry import options from sentry.constants import ObjectStatus from sentry.models.organization import Organization from sentry.models.project import Project @@ -32,9 +33,6 @@ "Invalid stats_period. Valid choices are '', '1h', '24h', '7d', '14d', '30d', and '90d'" ) -# Maximum number of projects to return when all_projects parameter is used -MAX_ALL_PROJECTS = 1000 - DATASETS = { "": discover, # in case they pass an empty query string fall back on default "discover": discover, @@ -167,12 +165,13 @@ def get(self, request: Request, organization: Organization) -> Response: queryset = queryset.order_by("slug").select_related("organization") # Fetch MAX + 1 to detect if there are more without expensive count() - projects = list(queryset[: MAX_ALL_PROJECTS + 1]) - has_more = len(projects) > MAX_ALL_PROJECTS + max_projects = options.get("api.organization-projects-all-max") + projects = list(queryset[: max_projects + 1]) + has_more = len(projects) > max_projects response = Response( serialize( - projects[:MAX_ALL_PROJECTS], + projects[:max_projects], request.user, ProjectSummarySerializer(collapse=collapse, dataset=dataset), ) @@ -180,8 +179,8 @@ def get(self, request: Request, organization: Organization) -> Response: if has_more: response["X-Sentry-Warning"] = ( - f"This organization has more than {MAX_ALL_PROJECTS} projects. " - f"Only the first {MAX_ALL_PROJECTS} are shown. " + 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." ) diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 519f355990be36..2ae12e5ffbdaf6 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -3462,6 +3462,13 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) +# Maximum number of projects to return when all_projects parameter is used +register( + "api.organization-projects-all-max", + default=1000, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) + register( "performance.event-tracker.sample-rate.transactions", default=0.0, diff --git a/static/app/api.tsx b/static/app/api.tsx index 43018f572dd735..0a298995d42806 100644 --- a/static/app/api.tsx +++ b/static/app/api.tsx @@ -198,7 +198,7 @@ export const initApiClientWarningHandling = () => if (warningMessage) { // Dynamically import to avoid circular dependency import('sentry/actionCreators/indicator').then(({addMessage}) => { - addMessage(warningMessage, 'warning', {duration: 10000}); + addMessage(warningMessage, '', {duration: 10000}); }); } }); From 5395679ffd8995e4c00fbe2532d76ad50fa0d0bc Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 02:30:25 +0000 Subject: [PATCH 10/15] :hammer_and_wrench: apply pre-commit fixes --- src/sentry/core/endpoints/organization_projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/core/endpoints/organization_projects.py b/src/sentry/core/endpoints/organization_projects.py index 1804ee214ea771..17f1c017c77519 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 @@ -21,7 +22,6 @@ from sentry.apidocs.examples.organization_examples import OrganizationExamples from sentry.apidocs.parameters import CursorQueryParam, GlobalParams from sentry.apidocs.utils import inline_sentry_response_serializer -from sentry import options from sentry.constants import ObjectStatus from sentry.models.organization import Organization from sentry.models.project import Project From b680dfe3b411aba3362a318792568f1102b56f2f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 5 Dec 2025 02:34:45 +0000 Subject: [PATCH 11/15] Refactor: Rename api.organization-projects-all-max to max-results Co-authored-by: david --- src/sentry/core/endpoints/organization_projects.py | 2 +- src/sentry/options/defaults.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/core/endpoints/organization_projects.py b/src/sentry/core/endpoints/organization_projects.py index 17f1c017c77519..3a2d7b3301c696 100644 --- a/src/sentry/core/endpoints/organization_projects.py +++ b/src/sentry/core/endpoints/organization_projects.py @@ -165,7 +165,7 @@ def get(self, request: Request, organization: Organization) -> Response: queryset = queryset.order_by("slug").select_related("organization") # Fetch MAX + 1 to detect if there are more without expensive count() - max_projects = options.get("api.organization-projects-all-max") + max_projects = options.get("api.organization-projects-max-results") projects = list(queryset[: max_projects + 1]) has_more = len(projects) > max_projects diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 2ae12e5ffbdaf6..ba6f965cd1a660 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -3464,7 +3464,7 @@ # Maximum number of projects to return when all_projects parameter is used register( - "api.organization-projects-all-max", + "api.organization-projects-max-results", default=1000, flags=FLAG_AUTOMATOR_MODIFIABLE, ) From ea773443043a93d350714fd63c7c4eb8c2f8ecb3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 5 Dec 2025 02:37:05 +0000 Subject: [PATCH 12/15] Reduce api.organization-projects-max-results default to 500 Co-authored-by: david --- src/sentry/options/defaults.py | 2 +- .../endpoints/test_organization_projects.py | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index ba6f965cd1a660..8d9bf7315a6b44 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -3465,7 +3465,7 @@ # Maximum number of projects to return when all_projects parameter is used register( "api.organization-projects-max-results", - default=1000, + default=500, flags=FLAG_AUTOMATOR_MODIFIABLE, ) diff --git a/tests/sentry/core/endpoints/test_organization_projects.py b/tests/sentry/core/endpoints/test_organization_projects.py index 71ce18acb4c4a2..02c0b0f8ac5116 100644 --- a/tests/sentry/core/endpoints/test_organization_projects.py +++ b/tests/sentry/core/endpoints/test_organization_projects.py @@ -313,24 +313,24 @@ def test_expand_context_options(self) -> None: assert not response.data[1].get("options") def test_all_projects_limit_exceeded(self) -> None: - # Create 1001 projects to exceed the limit - for i in range(1001): + # Create 501 projects to exceed the limit + for i in range(501): 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 1000 projects (capped) - assert len(response.data) == 1000 + # Should return 500 projects (capped) + assert len(response.data) == 500 # Should include truncation warning header assert "X-Sentry-Warning" in response - assert "more than 1000 projects" in response["X-Sentry-Warning"].lower() - assert "first 1000" in response["X-Sentry-Warning"].lower() + assert "more than 500 projects" in response["X-Sentry-Warning"].lower() + assert "first 500" in response["X-Sentry-Warning"].lower() def test_all_projects_at_limit(self) -> None: - # Create exactly 1000 projects - should succeed without warning + # Create exactly 500 projects - should succeed without warning projects = [] - for i in range(1000): + for i in range(500): projects.append( self.create_project(teams=[self.team], name=f"project-{i}", slug=f"project-{i}") ) @@ -338,8 +338,8 @@ def test_all_projects_at_limit(self) -> None: response = self.get_success_response( self.organization.slug, qs_params={"all_projects": "1"} ) - # Should return all 1000 projects - assert len(response.data) == 1000 + # Should return all 500 projects + assert len(response.data) == 500 # Should NOT include truncation warning header (exactly at limit) assert "X-Sentry-Warning" not in response From ca89983a6df0fd5bc4b6a1d5c70fd8d852c5c563 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 5 Dec 2025 02:39:46 +0000 Subject: [PATCH 13/15] Remove unused global success handlers Co-authored-by: david --- static/app/api.tsx | 21 --------------------- static/app/views/app/index.tsx | 3 +-- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/static/app/api.tsx b/static/app/api.tsx index 0a298995d42806..dbb56d9f8fea57 100644 --- a/static/app/api.tsx +++ b/static/app/api.tsx @@ -130,13 +130,6 @@ const globalErrorHandlers: Array< (resp: ResponseMeta, options: RequestOptions) => boolean > = []; -/** - * Global handlers for successful responses - */ -const globalSuccessHandlers: Array< - (resp: ResponseMeta, options: RequestOptions) => void -> = []; - export const initApiClientErrorHandling = () => globalErrorHandlers.push((resp: ResponseMeta, options: RequestOptions) => { const pageAllowsAnon = ALLOWED_ANON_PAGES.find(regex => @@ -191,18 +184,6 @@ export const initApiClientErrorHandling = () => return true; }); -export const initApiClientWarningHandling = () => - globalSuccessHandlers.push((resp: ResponseMeta) => { - const warningMessage = resp.getResponseHeader('X-Sentry-Warning'); - - if (warningMessage) { - // Dynamically import to avoid circular dependency - import('sentry/actionCreators/indicator').then(({addMessage}) => { - addMessage(warningMessage, '', {duration: 10000}); - }); - } - }); - /** * Construct a full request URL */ @@ -627,8 +608,6 @@ export class Client { const responseData = isResponseJSON ? responseJSON : responseText; if (ok) { - // Call global success handlers (e.g., for warning headers) - globalSuccessHandlers.forEach(handler => handler(responseMeta, options)); successHandler(responseMeta, statusText, responseData); } else { // There's no reason we should be here with a 200 response, but we get diff --git a/static/app/views/app/index.tsx b/static/app/views/app/index.tsx index 00c91daba90538..f2f8edc1897183 100644 --- a/static/app/views/app/index.tsx +++ b/static/app/views/app/index.tsx @@ -8,7 +8,7 @@ import { } from 'sentry/actionCreators/developmentAlerts'; import {fetchGuides} from 'sentry/actionCreators/guides'; import {fetchOrganizations} from 'sentry/actionCreators/organizations'; -import {initApiClientErrorHandling, initApiClientWarningHandling} from 'sentry/api'; +import {initApiClientErrorHandling} from 'sentry/api'; import ErrorBoundary from 'sentry/components/errorBoundary'; import GlobalModal from 'sentry/components/globalModal'; import {useGlobalModal} from 'sentry/components/globalModal/useGlobalModal'; @@ -158,7 +158,6 @@ function App() { } initApiClientErrorHandling(); - initApiClientWarningHandling(); fetchGuides(); // When the app is unloaded clear the organizationst list From e1c3fb3e2bf1b53997412b4e9d8e113a96208716 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 5 Dec 2025 02:43:40 +0000 Subject: [PATCH 14/15] Refactor: Adjust organization projects API limit Co-authored-by: david --- .../endpoints/test_organization_projects.py | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/tests/sentry/core/endpoints/test_organization_projects.py b/tests/sentry/core/endpoints/test_organization_projects.py index 02c0b0f8ac5116..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,25 +313,27 @@ 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 501 projects to exceed the limit - for i in range(501): + # 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 500 projects (capped) - assert len(response.data) == 500 + # Should return 3 projects (capped) + assert len(response.data) == 3 # Should include truncation warning header assert "X-Sentry-Warning" in response - assert "more than 500 projects" in response["X-Sentry-Warning"].lower() - assert "first 500" in response["X-Sentry-Warning"].lower() + 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 500 projects - should succeed without warning + # Create exactly 3 projects - should succeed without warning projects = [] - for i in range(500): + for i in range(3): projects.append( self.create_project(teams=[self.team], name=f"project-{i}", slug=f"project-{i}") ) @@ -338,8 +341,8 @@ def test_all_projects_at_limit(self) -> None: response = self.get_success_response( self.organization.slug, qs_params={"all_projects": "1"} ) - # Should return all 500 projects - assert len(response.data) == 500 + # 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 From c42a0dbafb328b8fc03d3e8c4c659ea0f03974d4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 5 Dec 2025 16:58:26 +0000 Subject: [PATCH 15/15] Test: Add X-Sentry-Warning to exposed headers Co-authored-by: david --- tests/sentry/api/test_base.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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"