From 68ef48bdd30a6d904f309fd576bf8531404feaf8 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 26 Feb 2026 17:39:52 +0100 Subject: [PATCH 1/7] feat(preprod): Add distribution error endpoint for launchpad Add a dedicated endpoint for launchpad to report distribution processing errors back to the monolith, mirroring the existing size analysis endpoint pattern. This sets installable_app_error_code and installable_app_error_message on the PreprodArtifact. Refs EME-842, EME-422 Co-Authored-By: Claude --- .../endpoints/project_preprod_distribution.py | 67 +++++++++++++++++ src/sentry/preprod/api/endpoints/urls.py | 6 ++ .../test_project_preprod_distribution.py | 71 +++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 src/sentry/preprod/api/endpoints/project_preprod_distribution.py create mode 100644 tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py diff --git a/src/sentry/preprod/api/endpoints/project_preprod_distribution.py b/src/sentry/preprod/api/endpoints/project_preprod_distribution.py new file mode 100644 index 00000000000000..f8fa52238b16bb --- /dev/null +++ b/src/sentry/preprod/api/endpoints/project_preprod_distribution.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import logging + +import orjson +import pydantic +from pydantic import BaseModel +from rest_framework import serializers +from rest_framework.request import Request +from rest_framework.response import Response + +from sentry.api.api_owners import ApiOwner +from sentry.api.api_publish_status import ApiPublishStatus +from sentry.api.base import internal_region_silo_endpoint +from sentry.models.project import Project +from sentry.preprod.api.bases.preprod_artifact_endpoint import PreprodArtifactEndpoint +from sentry.preprod.authentication import ( + LaunchpadRpcPermission, + LaunchpadRpcSignatureAuthentication, +) +from sentry.preprod.models import PreprodArtifact + +logger = logging.getLogger(__name__) + + +class PutDistribution(BaseModel): + error_code: int + error_message: str + + +@internal_region_silo_endpoint +class ProjectPreprodDistributionEndpoint(PreprodArtifactEndpoint): + owner = ApiOwner.EMERGE_TOOLS + publish_status = { + "PUT": ApiPublishStatus.PRIVATE, + } + authentication_classes = (LaunchpadRpcSignatureAuthentication,) + permission_classes = (LaunchpadRpcPermission,) + + def put( + self, + request: Request, + project: Project, + head_artifact_id: int, + head_artifact: PreprodArtifact, + ) -> Response: + try: + j = orjson.loads(request.body) + except orjson.JSONDecodeError: + raise serializers.ValidationError("Invalid json") + try: + put = PutDistribution(**j) + except pydantic.ValidationError: + logger.exception("Could not parse PutDistribution") + raise serializers.ValidationError("Could not parse PutDistribution") + + head_artifact.installable_app_error_code = put.error_code + head_artifact.installable_app_error_message = put.error_message + head_artifact.save( + update_fields=[ + "installable_app_error_code", + "installable_app_error_message", + "date_updated", + ] + ) + + return Response({"artifactId": str(head_artifact.id)}) diff --git a/src/sentry/preprod/api/endpoints/urls.py b/src/sentry/preprod/api/endpoints/urls.py index 70d9d67164dc2a..7d5dcb2fe4d80b 100644 --- a/src/sentry/preprod/api/endpoints/urls.py +++ b/src/sentry/preprod/api/endpoints/urls.py @@ -42,6 +42,7 @@ from .project_preprod_artifact_update import ProjectPreprodArtifactUpdateEndpoint from .project_preprod_build_details import ProjectPreprodBuildDetailsEndpoint from .project_preprod_check_for_updates import ProjectPreprodArtifactCheckForUpdatesEndpoint +from .project_preprod_distribution import ProjectPreprodDistributionEndpoint from .project_preprod_size import ( ProjectPreprodSizeEndpoint, ProjectPreprodSizeWithIdentifierEndpoint, @@ -275,6 +276,11 @@ ProjectPreprodArtifactAssembleGenericEndpoint.as_view(), name="sentry-api-0-project-preprod-artifact-assemble-generic", ), + re_path( + r"^(?P[^/]+)/(?P[^/]+)/files/preprodartifacts/(?P[^/]+)/distribution/$", + ProjectPreprodDistributionEndpoint.as_view(), + name="sentry-api-0-project-preprod-artifact-distribution", + ), re_path( r"^(?P[^/]+)/(?P[^/]+)/files/preprodartifacts/(?P[^/]+)/size/$", ProjectPreprodSizeEndpoint.as_view(), diff --git a/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py b/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py new file mode 100644 index 00000000000000..8dea4668e7586a --- /dev/null +++ b/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py @@ -0,0 +1,71 @@ +import orjson +from django.test import override_settings + +from sentry.preprod.models import PreprodArtifact +from sentry.testutils.auth import generate_service_request_signature +from sentry.testutils.cases import TestCase + +SHARED_SECRET_FOR_TESTS = "test-secret-key" + + +class ProjectPreprodDistributionEndpointTest(TestCase): + def setUp(self) -> None: + super().setUp() + self.file = self.create_file(name="test_artifact.apk", type="application/octet-stream") + self.artifact = self.create_preprod_artifact( + project=self.project, + file_id=self.file.id, + state=PreprodArtifact.ArtifactState.PROCESSED, + ) + + def _put(self, data, secret=SHARED_SECRET_FOR_TESTS): + url = f"/api/0/internal/{self.organization.slug}/{self.project.slug}/files/preprodartifacts/{self.artifact.id}/distribution/" + signature = generate_service_request_signature(url, data, [secret], "Launchpad") + return self.client.put( + url, + data=data, + content_type="application/json", + HTTP_AUTHORIZATION=f"rpcsignature {signature}", + ) + + @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS]) + def test_bad_auth(self) -> None: + response = self._put(b"{}", secret="wrong secret") + assert response.status_code == 401 + + @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS]) + def test_missing_fields(self) -> None: + response = self._put(b"{}") + assert response.status_code == 400 + + @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS]) + def test_bad_json(self) -> None: + response = self._put(b"{") + assert response.status_code == 400 + + @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS]) + def test_set_error(self) -> None: + response = self._put( + orjson.dumps({"error_code": 3, "error_message": "Unsupported artifact type"}) + ) + + assert response.status_code == 200 + self.artifact.refresh_from_db() + assert ( + self.artifact.installable_app_error_code + == PreprodArtifact.InstallableAppErrorCode.PROCESSING_ERROR + ) + assert self.artifact.installable_app_error_message == "Unsupported artifact type" + + @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS]) + def test_requires_launchpad_rpc_authentication(self) -> None: + self.login_as(self.user) + + url = f"/api/0/internal/{self.organization.slug}/{self.project.slug}/files/preprodartifacts/{self.artifact.id}/distribution/" + response = self.client.put( + url, + data=orjson.dumps({"error_code": 3, "error_message": "some error"}), + content_type="application/json", + ) + + assert response.status_code == 401 From d3862943c1e62ca594ca374aac271ee105749332 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 3 Mar 2026 08:39:26 +0100 Subject: [PATCH 2/7] fix(preprod): Validate error_code range and handle non-dict JSON Use parse_request_with_pydantic (which uses parse_obj_as) instead of manual unpacking to properly handle non-dict JSON bodies. Add Field constraint to error_code (0-3) matching the InstallableAppErrorCode enum range. Also make the shared helper's error message generic. Co-Authored-By: Claude --- .../endpoints/project_preprod_distribution.py | 19 +++++-------------- .../api/endpoints/project_preprod_size.py | 4 ++-- .../test_project_preprod_distribution.py | 10 ++++++++++ 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/sentry/preprod/api/endpoints/project_preprod_distribution.py b/src/sentry/preprod/api/endpoints/project_preprod_distribution.py index f8fa52238b16bb..cf10ada0e6db0b 100644 --- a/src/sentry/preprod/api/endpoints/project_preprod_distribution.py +++ b/src/sentry/preprod/api/endpoints/project_preprod_distribution.py @@ -1,11 +1,9 @@ from __future__ import annotations import logging +from typing import Any, cast -import orjson -import pydantic -from pydantic import BaseModel -from rest_framework import serializers +from pydantic import BaseModel, Field from rest_framework.request import Request from rest_framework.response import Response @@ -14,6 +12,7 @@ from sentry.api.base import internal_region_silo_endpoint from sentry.models.project import Project from sentry.preprod.api.bases.preprod_artifact_endpoint import PreprodArtifactEndpoint +from sentry.preprod.api.endpoints.project_preprod_size import parse_request_with_pydantic from sentry.preprod.authentication import ( LaunchpadRpcPermission, LaunchpadRpcSignatureAuthentication, @@ -24,7 +23,7 @@ class PutDistribution(BaseModel): - error_code: int + error_code: int = Field(ge=0, le=3) error_message: str @@ -44,15 +43,7 @@ def put( head_artifact_id: int, head_artifact: PreprodArtifact, ) -> Response: - try: - j = orjson.loads(request.body) - except orjson.JSONDecodeError: - raise serializers.ValidationError("Invalid json") - try: - put = PutDistribution(**j) - except pydantic.ValidationError: - logger.exception("Could not parse PutDistribution") - raise serializers.ValidationError("Could not parse PutDistribution") + put: PutDistribution = parse_request_with_pydantic(request, cast(Any, PutDistribution)) head_artifact.installable_app_error_code = put.error_code head_artifact.installable_app_error_message = put.error_message diff --git a/src/sentry/preprod/api/endpoints/project_preprod_size.py b/src/sentry/preprod/api/endpoints/project_preprod_size.py index eeba8ba62b3565..0342a59e29ab70 100644 --- a/src/sentry/preprod/api/endpoints/project_preprod_size.py +++ b/src/sentry/preprod/api/endpoints/project_preprod_size.py @@ -39,8 +39,8 @@ def parse_request_with_pydantic(request: Request, model: type[T]) -> T: # can be used instead of parse_obj_as return parse_obj_as(model, j) except pydantic.ValidationError: - logger.exception("Could not parse PutSize") - raise serializers.ValidationError("Could not parse PutSize") + logger.exception("Could not parse %s", model.__name__) + raise serializers.ValidationError(f"Could not parse {model.__name__}") @internal_region_silo_endpoint diff --git a/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py b/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py index 8dea4668e7586a..4cd2fea9885b0d 100644 --- a/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py +++ b/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py @@ -57,6 +57,16 @@ def test_set_error(self) -> None: ) assert self.artifact.installable_app_error_message == "Unsupported artifact type" + @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS]) + def test_invalid_error_code(self) -> None: + response = self._put(orjson.dumps({"error_code": 99, "error_message": "bad"})) + assert response.status_code == 400 + + @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS]) + def test_non_dict_json_body(self) -> None: + response = self._put(orjson.dumps([1, 2, 3])) + assert response.status_code == 400 + @override_settings(LAUNCHPAD_RPC_SHARED_SECRET=[SHARED_SECRET_FOR_TESTS]) def test_requires_launchpad_rpc_authentication(self) -> None: self.login_as(self.user) From 941feec3efa6c4e61e7d6901cb6795933a47a034 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:28:53 +0000 Subject: [PATCH 3/7] :hammer_and_wrench: Sync API Urls to TypeScript --- static/app/utils/api/knownSentryApiUrls.generated.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index d64bfdfe5536e1..6d9c87bd69b4c4 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -97,6 +97,7 @@ export type KnownSentryApiUrls = | '/integration-features/' | '/internal/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/$headArtifactId/' | '/internal/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/$headArtifactId/assemble-generic/' + | '/internal/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/$headArtifactId/distribution/' | '/internal/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/$headArtifactId/size/' | '/internal/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/$headArtifactId/size/$identifier/' | '/internal/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/$headArtifactId/update/' From eb19fe76a7afc5e900a29b4f50f3f49412aba443 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 5 Mar 2026 15:35:16 +0100 Subject: [PATCH 4/7] fix(preprod): Handle Annotated types in parse_request_with_pydantic Use getattr for model.__name__ since Annotated type aliases (like PutSize) lack __name__, causing AttributeError on validation failure. Co-Authored-By: Claude --- src/sentry/preprod/api/endpoints/project_preprod_size.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/sentry/preprod/api/endpoints/project_preprod_size.py b/src/sentry/preprod/api/endpoints/project_preprod_size.py index 0342a59e29ab70..15e0cb307cb5e2 100644 --- a/src/sentry/preprod/api/endpoints/project_preprod_size.py +++ b/src/sentry/preprod/api/endpoints/project_preprod_size.py @@ -39,8 +39,9 @@ def parse_request_with_pydantic(request: Request, model: type[T]) -> T: # can be used instead of parse_obj_as return parse_obj_as(model, j) except pydantic.ValidationError: - logger.exception("Could not parse %s", model.__name__) - raise serializers.ValidationError(f"Could not parse {model.__name__}") + name = getattr(model, "__name__", str(model)) + logger.exception("Could not parse %s", name) + raise serializers.ValidationError(f"Could not parse {name}") @internal_region_silo_endpoint From 49ba38900031a13511f86015b117143885a6bf54 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 5 Mar 2026 15:57:12 +0100 Subject: [PATCH 5/7] ref(preprod): Move distribution endpoint to org-scoped URL Move the distribution error endpoint from preprod_internal_urlpatterns to preprod_organization_urlpatterns, dropping the project slug from the URL path to match the other migrated org-scoped endpoints (EME-735). Co-Authored-By: Claude --- .../api/endpoints/project_preprod_distribution.py | 2 -- src/sentry/preprod/api/endpoints/urls.py | 10 +++++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/sentry/preprod/api/endpoints/project_preprod_distribution.py b/src/sentry/preprod/api/endpoints/project_preprod_distribution.py index cf10ada0e6db0b..a9e517d09ae771 100644 --- a/src/sentry/preprod/api/endpoints/project_preprod_distribution.py +++ b/src/sentry/preprod/api/endpoints/project_preprod_distribution.py @@ -10,7 +10,6 @@ from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import internal_region_silo_endpoint -from sentry.models.project import Project from sentry.preprod.api.bases.preprod_artifact_endpoint import PreprodArtifactEndpoint from sentry.preprod.api.endpoints.project_preprod_size import parse_request_with_pydantic from sentry.preprod.authentication import ( @@ -39,7 +38,6 @@ class ProjectPreprodDistributionEndpoint(PreprodArtifactEndpoint): def put( self, request: Request, - project: Project, head_artifact_id: int, head_artifact: PreprodArtifact, ) -> Response: diff --git a/src/sentry/preprod/api/endpoints/urls.py b/src/sentry/preprod/api/endpoints/urls.py index 7d5dcb2fe4d80b..d5cd9fdc5e1a28 100644 --- a/src/sentry/preprod/api/endpoints/urls.py +++ b/src/sentry/preprod/api/endpoints/urls.py @@ -243,6 +243,11 @@ PreprodSnapshotRecompareEndpoint.as_view(), name="sentry-api-0-organization-preprod-snapshots-recompare", ), + re_path( + r"^(?P[^/]+)/preprodartifacts/(?P[^/]+)/distribution/$", + ProjectPreprodDistributionEndpoint.as_view(), + name="sentry-api-0-organization-preprod-artifact-distribution", + ), ] preprod_internal_urlpatterns = [ @@ -276,11 +281,6 @@ ProjectPreprodArtifactAssembleGenericEndpoint.as_view(), name="sentry-api-0-project-preprod-artifact-assemble-generic", ), - re_path( - r"^(?P[^/]+)/(?P[^/]+)/files/preprodartifacts/(?P[^/]+)/distribution/$", - ProjectPreprodDistributionEndpoint.as_view(), - name="sentry-api-0-project-preprod-artifact-distribution", - ), re_path( r"^(?P[^/]+)/(?P[^/]+)/files/preprodartifacts/(?P[^/]+)/size/$", ProjectPreprodSizeEndpoint.as_view(), From f7c89d4e4538289ec4f4eba85d24ba08c235bef9 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:59:57 +0000 Subject: [PATCH 6/7] :hammer_and_wrench: Sync API Urls to TypeScript --- static/app/utils/api/knownSentryApiUrls.generated.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index 6d9c87bd69b4c4..c33ec59d6b85c0 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -97,7 +97,6 @@ export type KnownSentryApiUrls = | '/integration-features/' | '/internal/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/$headArtifactId/' | '/internal/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/$headArtifactId/assemble-generic/' - | '/internal/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/$headArtifactId/distribution/' | '/internal/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/$headArtifactId/size/' | '/internal/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/$headArtifactId/size/$identifier/' | '/internal/$organizationIdOrSlug/$projectIdOrSlug/files/preprodartifacts/$headArtifactId/update/' @@ -479,6 +478,7 @@ export type KnownSentryApiUrls = | '/organizations/$organizationIdOrSlug/preprodartifacts/$artifactId/size-analysis/' | '/organizations/$organizationIdOrSlug/preprodartifacts/$headArtifactId/build-details/' | '/organizations/$organizationIdOrSlug/preprodartifacts/$headArtifactId/delete/' + | '/organizations/$organizationIdOrSlug/preprodartifacts/$headArtifactId/distribution/' | '/organizations/$organizationIdOrSlug/preprodartifacts/$headArtifactId/private-install-details/' | '/organizations/$organizationIdOrSlug/preprodartifacts/list-builds/' | '/organizations/$organizationIdOrSlug/preprodartifacts/size-analysis/compare/$headArtifactId/$baseArtifactId/' From 30224f3a3d6c459325348784703f004dd0bb2aac Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 5 Mar 2026 16:10:02 +0100 Subject: [PATCH 7/7] fix(preprod): Restore project param and update test URLs Add back project parameter to put() since the base class always passes it. Update test URLs to use the new org-scoped route. Co-Authored-By: Claude --- .../preprod/api/endpoints/project_preprod_distribution.py | 2 ++ .../api/endpoints/test_project_preprod_distribution.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/sentry/preprod/api/endpoints/project_preprod_distribution.py b/src/sentry/preprod/api/endpoints/project_preprod_distribution.py index a9e517d09ae771..cf10ada0e6db0b 100644 --- a/src/sentry/preprod/api/endpoints/project_preprod_distribution.py +++ b/src/sentry/preprod/api/endpoints/project_preprod_distribution.py @@ -10,6 +10,7 @@ from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import internal_region_silo_endpoint +from sentry.models.project import Project from sentry.preprod.api.bases.preprod_artifact_endpoint import PreprodArtifactEndpoint from sentry.preprod.api.endpoints.project_preprod_size import parse_request_with_pydantic from sentry.preprod.authentication import ( @@ -38,6 +39,7 @@ class ProjectPreprodDistributionEndpoint(PreprodArtifactEndpoint): def put( self, request: Request, + project: Project, head_artifact_id: int, head_artifact: PreprodArtifact, ) -> Response: diff --git a/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py b/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py index 4cd2fea9885b0d..b2cb393c8534b6 100644 --- a/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py +++ b/tests/sentry/preprod/api/endpoints/test_project_preprod_distribution.py @@ -19,7 +19,7 @@ def setUp(self) -> None: ) def _put(self, data, secret=SHARED_SECRET_FOR_TESTS): - url = f"/api/0/internal/{self.organization.slug}/{self.project.slug}/files/preprodartifacts/{self.artifact.id}/distribution/" + url = f"/api/0/organizations/{self.organization.slug}/preprodartifacts/{self.artifact.id}/distribution/" signature = generate_service_request_signature(url, data, [secret], "Launchpad") return self.client.put( url, @@ -71,7 +71,7 @@ def test_non_dict_json_body(self) -> None: def test_requires_launchpad_rpc_authentication(self) -> None: self.login_as(self.user) - url = f"/api/0/internal/{self.organization.slug}/{self.project.slug}/files/preprodartifacts/{self.artifact.id}/distribution/" + url = f"/api/0/organizations/{self.organization.slug}/preprodartifacts/{self.artifact.id}/distribution/" response = self.client.put( url, data=orjson.dumps({"error_code": 3, "error_message": "some error"}),