From ec81bc5ef66765579bbe340ef93346f899f0508b Mon Sep 17 00:00:00 2001 From: Alc-Alc Date: Wed, 4 Nov 2026 13:25:50 +0530 Subject: [PATCH 1/2] fix(api): return clean 4xx from v1 API without reporting a 500 --- pydotorg/resources.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/pydotorg/resources.py b/pydotorg/resources.py index 6c8f013d5..ef67cf48e 100644 --- a/pydotorg/resources.py +++ b/pydotorg/resources.py @@ -4,9 +4,9 @@ from django.contrib.auth.models import AnonymousUser from tastypie.authentication import ApiKeyAuthentication from tastypie.authorization import Authorization -from tastypie.exceptions import Unauthorized -from tastypie.http import HttpUnauthorized -from tastypie.resources import ModelResource +from tastypie.exceptions import ImmediateHttpResponse, NotFound, Unauthorized, UnsupportedDeserializationFormat +from tastypie.http import HttpNotFound, HttpUnauthorized, HttpUnsupportedMediaType +from tastypie.resources import ModelResource, sanitize from tastypie.throttle import CacheThrottle API_KEY_AUTHENTICATED_ATTR = "_pydotorg_api_key_authenticated" @@ -184,3 +184,21 @@ class Meta: detail_allowed_methods = ["get", "delete"] throttle = CacheThrottle(throttle_at=600) # default is 150 req/hr abstract = True + + def deserialize(self, request, data, format="application/json"): # noqa: A002 - tastypie hook signature + """Return a 415 for unsupported request content types without firing the 500 signal.""" + try: + return super().deserialize(request, data, format=format) + except UnsupportedDeserializationFormat as exc: + response = self.error_response( + request, {"error": sanitize(str(exc))}, response_class=HttpUnsupportedMediaType + ) + raise ImmediateHttpResponse(response=response) from exc + + def get_detail(self, request, **kwargs): + """Return a 404 for malformed detail lookups without firing the 500 signal.""" + try: + return super().get_detail(request, **kwargs) + except NotFound as exc: + response = self.error_response(request, {"error": sanitize(str(exc))}, response_class=HttpNotFound) + raise ImmediateHttpResponse(response=response) from exc From e8129f70d9d3f8f54b76a2471a3ef663d49ae402 Mon Sep 17 00:00:00 2001 From: Alc-Alc Date: Wed, 4 Nov 2026 13:26:01 +0530 Subject: [PATCH 2/2] test(api): cover v1 415 and non-integer detail 404 --- apps/downloads/tests/test_views.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/downloads/tests/test_views.py b/apps/downloads/tests/test_views.py index b5eb75246..c1bed04a1 100644 --- a/apps/downloads/tests/test_views.py +++ b/apps/downloads/tests/test_views.py @@ -737,6 +737,31 @@ def test_v1_release_file_delete_by_release_still_works(self): self.assertEqual(response.status_code, 200) self.assertEqual(len(self.get_json(response)), 0) + def test_unsupported_request_content_type_returns_415(self): + url = self.create_url("os") + cases = [ + ("application/yaml", "name: Unsupported OS\nslug: unsupported-os\n"), + ("application/x-www-form-urlencoded", "name=Unsupported+OS&slug=unsupported-os"), + ] + + for content_type, body in cases: + with self.subTest(content_type=content_type): + response = self.client.post( + url, body, content_type=content_type, headers={"authorization": self.Authorization} + ) + + self.assertEqual(response.status_code, 415) + self.assertIn(content_type, response.json()["error"]) + self.assertFalse(OS.objects.filter(slug="unsupported-os").exists()) + + def test_non_integer_detail_lookup_returns_404(self): + url = self.create_url("release", "latest") + + response = self.client.get(url) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["error"], "Invalid resource lookup data provided (mismatched type).") + class DownloadApiV2ViewsTest(BaseDownloadApiViewsTest, BaseDownloadTests, APITestCase): api_version = "v2"