We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Thumbnail endpoint returns 500 if the upstream image URL 404s
## Description
<!-- Concisely describe the bug. Compare your experience with what you expected to happen. -->
<!-- For example: "I clicked the 'submit' button and instead of seeing a thank you message, I saw a blank page." -->
I was looking through production logs to see where some 500 errors were coming from and I found a handful of logs that look like this:

Field | Value
-- | --
`@ingestionTime` | 1659625225674
`@log` | 140733703834:/prod/api/nginx
`@logStream` | i-05a7072edd44e97d9
`@timestamp` | 1659625221900
body_bytes_sent | 66
host_header | api.openverse.engineering
http_referrer | https://search.openverse.engineering/
http_user_agent | Mozilla/5.0  (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36  (KHTML, like Gecko) Chrome/103.0.5060.134 Mobile Safari/537.36  (compatible; Googlebot/2.1; +http://www.google.com/bot.html)
http_x_forwarded_for | [redacted]
remote_addr | [redacted]
request | GET /v1/images/81954166-168e-41a6-bff5-eb966cb940ec/thumb/ HTTP/1.1
request_time | 1.175
status | 500
time_local | 04/Aug/2022:15:00:21 +0000
upstream_response_time | 1.175

Indeed, if you visit `https://api.openverse.engineering/v1/images/81954166-168e-41a6-bff5-eb966cb940ec/thumb/` you will get a 500 back with a message saying that the upstream request resulted in a 404. I think these should just 404 all the way, instead of resulting in a 500. 

I wonder if these should also be marked as dead links? It should already be anyway because the upstream url 404s and this is how we detect dead links during the search request, but clearly some still get through :thinking: 

## Reproduction
<!-- Provide detailed steps to reproduce the bug. -->
1. <!-- Step 1 ... --> Visit https://api.openverse.engineering/v1/images/81954166-168e-41a6-bff5-eb966cb940ec/thumb/
2. <!-- Step 2 ... --> See the 500 response code with the 404 body details.

## Resolution
<!-- Replace the [ ] with [x] to check the box. -->
- [ ] 🙋 I would be interested in resolving this bug.


--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `api/catalog/api/views/media_views.py`
Content:
```
1 import json
2 import logging as log
3 from http.client import RemoteDisconnected
4 from urllib.error import HTTPError
5 from urllib.parse import urlencode
6 from urllib.request import Request, urlopen
7 
8 from django.conf import settings
9 from django.http.response import HttpResponse
10 from rest_framework import status
11 from rest_framework.decorators import action
12 from rest_framework.response import Response
13 from rest_framework.viewsets import ReadOnlyModelViewSet
14 
15 from catalog.api.controllers import search_controller
16 from catalog.api.models import ContentProvider
17 from catalog.api.serializers.provider_serializers import ProviderSerializer
18 from catalog.api.utils.exceptions import get_api_exception
19 from catalog.api.utils.pagination import StandardPagination
20 from catalog.custom_auto_schema import CustomAutoSchema
21 
22 
23 class MediaViewSet(ReadOnlyModelViewSet):
24     swagger_schema = CustomAutoSchema
25 
26     lookup_field = "identifier"
27     # TODO: https://github.com/encode/django-rest-framework/pull/6789
28     lookup_value_regex = (
29         r"[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}"
30     )
31 
32     pagination_class = StandardPagination
33 
34     # Populate these in the corresponding subclass
35     model_class = None
36     query_serializer_class = None
37     default_index = None
38     qa_index = None
39 
40     def __init__(self, *args, **kwargs):
41         super().__init__(*args, **kwargs)
42         required_fields = [
43             self.model_class,
44             self.query_serializer_class,
45             self.default_index,
46             self.qa_index,
47         ]
48         if any(val is None for val in required_fields):
49             msg = "Viewset fields are not completely populated."
50             raise ValueError(msg)
51 
52     def get_queryset(self):
53         return self.model_class.objects.all()
54 
55     # Standard actions
56 
57     def list(self, request, *_, **__):
58         self.paginator.page_size = request.query_params.get("page_size")
59         page_size = self.paginator.page_size
60         self.paginator.page = request.query_params.get("page")
61         page = self.paginator.page
62 
63         params = self.query_serializer_class(
64             data=request.query_params, context={"request": request}
65         )
66         params.is_valid(raise_exception=True)
67 
68         hashed_ip = hash(self._get_user_ip(request))
69         qa = params.validated_data["qa"]
70         filter_dead = params.validated_data["filter_dead"]
71 
72         search_index = self.qa_index if qa else self.default_index
73         try:
74             results, num_pages, num_results = search_controller.search(
75                 params,
76                 search_index,
77                 page_size,
78                 hashed_ip,
79                 request,
80                 filter_dead,
81                 page,
82             )
83             self.paginator.page_count = num_pages
84             self.paginator.result_count = num_results
85         except ValueError as e:
86             raise get_api_exception(getattr(e, "message", str(e)))
87 
88         serializer = self.get_serializer(results, many=True)
89         return self.get_paginated_response(serializer.data)
90 
91     # Extra actions
92 
93     @action(detail=False, serializer_class=ProviderSerializer, pagination_class=None)
94     def stats(self, *_, **__):
95         source_counts = search_controller.get_sources(self.default_index)
96         context = self.get_serializer_context() | {
97             "source_counts": source_counts,
98         }
99 
100         providers = ContentProvider.objects.filter(
101             media_type=self.default_index, filter_content=False
102         )
103         serializer = self.get_serializer(providers, many=True, context=context)
104         return Response(serializer.data)
105 
106     @action(detail=True)
107     def related(self, request, identifier=None, *_, **__):
108         try:
109             results, num_results = search_controller.related_media(
110                 uuid=identifier,
111                 index=self.default_index,
112                 request=request,
113                 filter_dead=True,
114             )
115             self.paginator.result_count = num_results
116             self.paginator.page_count = 1
117             # `page_size` refers to the maximum number of related images to return.
118             self.paginator.page_size = 10
119         except ValueError as e:
120             raise get_api_exception(getattr(e, "message", str(e)))
121         # If there are no hits in the search controller
122         except IndexError:
123             raise get_api_exception("Could not find items.", 404)
124 
125         serializer = self.get_serializer(results, many=True)
126         return self.get_paginated_response(serializer.data)
127 
128     def report(self, request, *_, **__):
129         media = self.get_object()
130         identifier = media.identifier
131         serializer = self.get_serializer(data=request.data)
132         if not serializer.is_valid():
133             raise get_api_exception("Invalid input.", 400)
134         report = serializer.save(identifier=identifier)
135 
136         serializer = self.get_serializer(report)
137         return Response(data=serializer.data, status=status.HTTP_201_CREATED)
138 
139     def thumbnail(self, image_url, request, *_, **__):
140         serializer = self.get_serializer(data=request.query_params)
141         serializer.is_valid(raise_exception=True)
142         return self._get_proxied_image(
143             image_url,
144             accept_header=request.headers.get("Accept", "image/*"),
145             **serializer.validated_data,
146         )
147 
148     # Helper functions
149 
150     @staticmethod
151     def _get_user_ip(request):
152         """
153         Read request headers to find the correct IP address.
154         It is assumed that X-Forwarded-For has been sanitized by the load
155         balancer and thus cannot be rewritten by malicious users.
156         :param request: A Django request object.
157         :return: An IP address.
158         """
159         x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
160         if x_forwarded_for:
161             ip = x_forwarded_for.split(",")[0]
162         else:
163             ip = request.META.get("REMOTE_ADDR")
164         return ip
165 
166     @staticmethod
167     def _thumbnail_proxy_comm(
168         path: str,
169         params: dict,
170         headers: tuple[tuple[str, str]] = (),
171     ):
172         proxy_url = settings.THUMBNAIL_PROXY_URL
173         query_string = urlencode(params)
174         upstream_url = f"{proxy_url}/{path}?{query_string}"
175         log.debug(f"Image proxy upstream URL: {upstream_url}")
176 
177         try:
178             req = Request(upstream_url)
179             for key, val in headers:
180                 req.add_header(key, val)
181             upstream_response = urlopen(req, timeout=10)
182 
183             res_status = upstream_response.status
184             content_type = upstream_response.headers.get("Content-Type")
185             log.debug(
186                 "Image proxy response "
187                 f"status: {res_status}, content-type: {content_type}"
188             )
189 
190             return upstream_response, res_status, content_type
191         except (HTTPError, RemoteDisconnected, TimeoutError) as exc:
192             raise get_api_exception(f"Failed to render thumbnail: {exc}")
193         except Exception as exc:
194             raise get_api_exception(
195                 f"Failed to render thumbnail due to unidentified exception: {exc}"
196             )
197 
198     @staticmethod
199     def _get_proxied_image(
200         image_url: str,
201         accept_header: str = "image/*",
202         is_full_size: bool = False,
203         is_compressed: bool = True,
204     ):
205         width = settings.THUMBNAIL_WIDTH_PX
206         if is_full_size:
207             info_res, *_ = MediaViewSet._thumbnail_proxy_comm(
208                 "info", {"url": image_url}
209             )
210             info = json.loads(info_res.read())
211             width = info["width"]
212 
213         params = {
214             "url": image_url,
215             "width": width,
216         }
217 
218         if is_compressed:
219             params |= {
220                 "quality": settings.THUMBNAIL_JPG_QUALITY,
221                 "compression": settings.THUMBNAIL_PNG_COMPRESSION,
222             }
223         else:
224             params |= {
225                 "quality": 100,
226                 "compression": 0,
227             }
228 
229         if "webp" in accept_header:
230             params["type"] = "auto"  # Use ``Accept`` header to determine output type.
231 
232         img_res, res_status, content_type = MediaViewSet._thumbnail_proxy_comm(
233             "resize", params, (("Accept", accept_header),)
234         )
235         response = HttpResponse(
236             img_res.read(), status=res_status, content_type=content_type
237         )
238         return response
239
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/api/catalog/api/views/media_views.py b/api/catalog/api/views/media_views.py
--- a/api/catalog/api/views/media_views.py
+++ b/api/catalog/api/views/media_views.py
@@ -9,9 +9,12 @@
 from django.http.response import HttpResponse
 from rest_framework import status
 from rest_framework.decorators import action
+from rest_framework.exceptions import APIException
 from rest_framework.response import Response
 from rest_framework.viewsets import ReadOnlyModelViewSet
 
+from sentry_sdk import capture_exception
+
 from catalog.api.controllers import search_controller
 from catalog.api.models import ContentProvider
 from catalog.api.serializers.provider_serializers import ProviderSerializer
@@ -20,6 +23,11 @@
 from catalog.custom_auto_schema import CustomAutoSchema
 
 
+class UpstreamThumbnailException(APIException):
+    status_code = status.HTTP_424_FAILED_DEPENDENCY
+    default_detail = "Could not render thumbnail due to upstream provider error."
+
+
 class MediaViewSet(ReadOnlyModelViewSet):
     swagger_schema = CustomAutoSchema
 
@@ -189,9 +197,11 @@
 
             return upstream_response, res_status, content_type
         except (HTTPError, RemoteDisconnected, TimeoutError) as exc:
-            raise get_api_exception(f"Failed to render thumbnail: {exc}")
+            capture_exception(exc)
+            raise UpstreamThumbnailException(f"Failed to render thumbnail: {exc}")
         except Exception as exc:
-            raise get_api_exception(
+            capture_exception(exc)
+            raise UpstreamThumbnailException(
                 f"Failed to render thumbnail due to unidentified exception: {exc}"
             )

{"golden_diff": "diff --git a/api/catalog/api/views/media_views.py b/api/catalog/api/views/media_views.py\n--- a/api/catalog/api/views/media_views.py\n+++ b/api/catalog/api/views/media_views.py\n@@ -9,9 +9,12 @@\n from django.http.response import HttpResponse\n from rest_framework import status\n from rest_framework.decorators import action\n+from rest_framework.exceptions import APIException\n from rest_framework.response import Response\n from rest_framework.viewsets import ReadOnlyModelViewSet\n \n+from sentry_sdk import capture_exception\n+\n from catalog.api.controllers import search_controller\n from catalog.api.models import ContentProvider\n from catalog.api.serializers.provider_serializers import ProviderSerializer\n@@ -20,6 +23,11 @@\n from catalog.custom_auto_schema import CustomAutoSchema\n \n \n+class UpstreamThumbnailException(APIException):\n+    status_code = status.HTTP_424_FAILED_DEPENDENCY\n+    default_detail = \"Could not render thumbnail due to upstream provider error.\"\n+\n+\n class MediaViewSet(ReadOnlyModelViewSet):\n     swagger_schema = CustomAutoSchema\n \n@@ -189,9 +197,11 @@\n \n             return upstream_response, res_status, content_type\n         except (HTTPError, RemoteDisconnected, TimeoutError) as exc:\n-            raise get_api_exception(f\"Failed to render thumbnail: {exc}\")\n+            capture_exception(exc)\n+            raise UpstreamThumbnailException(f\"Failed to render thumbnail: {exc}\")\n         except Exception as exc:\n-            raise get_api_exception(\n+            capture_exception(exc)\n+            raise UpstreamThumbnailException(\n                 f\"Failed to render thumbnail due to unidentified exception: {exc}\"\n             )\n", "issue": "Thumbnail endpoint returns 500 if the upstream image URL 404s\n## Description\r\n<!-- Concisely describe the bug. Compare your experience with what you expected to happen. -->\r\n<!-- For example: \"I clicked the 'submit' button and instead of seeing a thank you message, I saw a blank page.\" -->\r\nI was looking through production logs to see where some 500 errors were coming from and I found a handful of logs that look like this:\r\n\r\nField | Value\r\n-- | --\r\n`@ingestionTime` | 1659625225674\r\n`@log` | 140733703834:/prod/api/nginx\r\n`@logStream` | i-05a7072edd44e97d9\r\n`@timestamp` | 1659625221900\r\nbody_bytes_sent | 66\r\nhost_header | api.openverse.engineering\r\nhttp_referrer | https://search.openverse.engineering/\r\nhttp_user_agent | Mozilla/5.0  (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36  (KHTML, like Gecko) Chrome/103.0.5060.134 Mobile Safari/537.36  (compatible; Googlebot/2.1; +http://www.google.com/bot.html)\r\nhttp_x_forwarded_for | [redacted]\r\nremote_addr | [redacted]\r\nrequest | GET /v1/images/81954166-168e-41a6-bff5-eb966cb940ec/thumb/ HTTP/1.1\r\nrequest_time | 1.175\r\nstatus | 500\r\ntime_local | 04/Aug/2022:15:00:21 +0000\r\nupstream_response_time | 1.175\r\n\r\nIndeed, if you visit `https://api.openverse.engineering/v1/images/81954166-168e-41a6-bff5-eb966cb940ec/thumb/` you will get a 500 back with a message saying that the upstream request resulted in a 404. I think these should just 404 all the way, instead of resulting in a 500. \r\n\r\nI wonder if these should also be marked as dead links? It should already be anyway because the upstream url 404s and this is how we detect dead links during the search request, but clearly some still get through :thinking: \r\n\r\n## Reproduction\r\n<!-- Provide detailed steps to reproduce the bug. -->\r\n1. <!-- Step 1 ... --> Visit https://api.openverse.engineering/v1/images/81954166-168e-41a6-bff5-eb966cb940ec/thumb/\r\n2. <!-- Step 2 ... --> See the 500 response code with the 404 body details.\r\n\r\n## Resolution\r\n<!-- Replace the [ ] with [x] to check the box. -->\r\n- [ ] \ud83d\ude4b I would be interested in resolving this bug.\r\n\n", "before_files": [{"content": "import json\nimport logging as log\nfrom http.client import RemoteDisconnected\nfrom urllib.error import HTTPError\nfrom urllib.parse import urlencode\nfrom urllib.request import Request, urlopen\n\nfrom django.conf import settings\nfrom django.http.response import HttpResponse\nfrom rest_framework import status\nfrom rest_framework.decorators import action\nfrom rest_framework.response import Response\nfrom rest_framework.viewsets import ReadOnlyModelViewSet\n\nfrom catalog.api.controllers import search_controller\nfrom catalog.api.models import ContentProvider\nfrom catalog.api.serializers.provider_serializers import ProviderSerializer\nfrom catalog.api.utils.exceptions import get_api_exception\nfrom catalog.api.utils.pagination import StandardPagination\nfrom catalog.custom_auto_schema import CustomAutoSchema\n\n\nclass MediaViewSet(ReadOnlyModelViewSet):\n    swagger_schema = CustomAutoSchema\n\n    lookup_field = \"identifier\"\n    # TODO: https://github.com/encode/django-rest-framework/pull/6789\n    lookup_value_regex = (\n        r\"[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}\"\n    )\n\n    pagination_class = StandardPagination\n\n    # Populate these in the corresponding subclass\n    model_class = None\n    query_serializer_class = None\n    default_index = None\n    qa_index = None\n\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        required_fields = [\n            self.model_class,\n            self.query_serializer_class,\n            self.default_index,\n            self.qa_index,\n        ]\n        if any(val is None for val in required_fields):\n            msg = \"Viewset fields are not completely populated.\"\n            raise ValueError(msg)\n\n    def get_queryset(self):\n        return self.model_class.objects.all()\n\n    # Standard actions\n\n    def list(self, request, *_, **__):\n        self.paginator.page_size = request.query_params.get(\"page_size\")\n        page_size = self.paginator.page_size\n        self.paginator.page = request.query_params.get(\"page\")\n        page = self.paginator.page\n\n        params = self.query_serializer_class(\n            data=request.query_params, context={\"request\": request}\n        )\n        params.is_valid(raise_exception=True)\n\n        hashed_ip = hash(self._get_user_ip(request))\n        qa = params.validated_data[\"qa\"]\n        filter_dead = params.validated_data[\"filter_dead\"]\n\n        search_index = self.qa_index if qa else self.default_index\n        try:\n            results, num_pages, num_results = search_controller.search(\n                params,\n                search_index,\n                page_size,\n                hashed_ip,\n                request,\n                filter_dead,\n                page,\n            )\n            self.paginator.page_count = num_pages\n            self.paginator.result_count = num_results\n        except ValueError as e:\n            raise get_api_exception(getattr(e, \"message\", str(e)))\n\n        serializer = self.get_serializer(results, many=True)\n        return self.get_paginated_response(serializer.data)\n\n    # Extra actions\n\n    @action(detail=False, serializer_class=ProviderSerializer, pagination_class=None)\n    def stats(self, *_, **__):\n        source_counts = search_controller.get_sources(self.default_index)\n        context = self.get_serializer_context() | {\n            \"source_counts\": source_counts,\n        }\n\n        providers = ContentProvider.objects.filter(\n            media_type=self.default_index, filter_content=False\n        )\n        serializer = self.get_serializer(providers, many=True, context=context)\n        return Response(serializer.data)\n\n    @action(detail=True)\n    def related(self, request, identifier=None, *_, **__):\n        try:\n            results, num_results = search_controller.related_media(\n                uuid=identifier,\n                index=self.default_index,\n                request=request,\n                filter_dead=True,\n            )\n            self.paginator.result_count = num_results\n            self.paginator.page_count = 1\n            # `page_size` refers to the maximum number of related images to return.\n            self.paginator.page_size = 10\n        except ValueError as e:\n            raise get_api_exception(getattr(e, \"message\", str(e)))\n        # If there are no hits in the search controller\n        except IndexError:\n            raise get_api_exception(\"Could not find items.\", 404)\n\n        serializer = self.get_serializer(results, many=True)\n        return self.get_paginated_response(serializer.data)\n\n    def report(self, request, *_, **__):\n        media = self.get_object()\n        identifier = media.identifier\n        serializer = self.get_serializer(data=request.data)\n        if not serializer.is_valid():\n            raise get_api_exception(\"Invalid input.\", 400)\n        report = serializer.save(identifier=identifier)\n\n        serializer = self.get_serializer(report)\n        return Response(data=serializer.data, status=status.HTTP_201_CREATED)\n\n    def thumbnail(self, image_url, request, *_, **__):\n        serializer = self.get_serializer(data=request.query_params)\n        serializer.is_valid(raise_exception=True)\n        return self._get_proxied_image(\n            image_url,\n            accept_header=request.headers.get(\"Accept\", \"image/*\"),\n            **serializer.validated_data,\n        )\n\n    # Helper functions\n\n    @staticmethod\n    def _get_user_ip(request):\n        \"\"\"\n        Read request headers to find the correct IP address.\n        It is assumed that X-Forwarded-For has been sanitized by the load\n        balancer and thus cannot be rewritten by malicious users.\n        :param request: A Django request object.\n        :return: An IP address.\n        \"\"\"\n        x_forwarded_for = request.META.get(\"HTTP_X_FORWARDED_FOR\")\n        if x_forwarded_for:\n            ip = x_forwarded_for.split(\",\")[0]\n        else:\n            ip = request.META.get(\"REMOTE_ADDR\")\n        return ip\n\n    @staticmethod\n    def _thumbnail_proxy_comm(\n        path: str,\n        params: dict,\n        headers: tuple[tuple[str, str]] = (),\n    ):\n        proxy_url = settings.THUMBNAIL_PROXY_URL\n        query_string = urlencode(params)\n        upstream_url = f\"{proxy_url}/{path}?{query_string}\"\n        log.debug(f\"Image proxy upstream URL: {upstream_url}\")\n\n        try:\n            req = Request(upstream_url)\n            for key, val in headers:\n                req.add_header(key, val)\n            upstream_response = urlopen(req, timeout=10)\n\n            res_status = upstream_response.status\n            content_type = upstream_response.headers.get(\"Content-Type\")\n            log.debug(\n                \"Image proxy response \"\n                f\"status: {res_status}, content-type: {content_type}\"\n            )\n\n            return upstream_response, res_status, content_type\n        except (HTTPError, RemoteDisconnected, TimeoutError) as exc:\n            raise get_api_exception(f\"Failed to render thumbnail: {exc}\")\n        except Exception as exc:\n            raise get_api_exception(\n                f\"Failed to render thumbnail due to unidentified exception: {exc}\"\n            )\n\n    @staticmethod\n    def _get_proxied_image(\n        image_url: str,\n        accept_header: str = \"image/*\",\n        is_full_size: bool = False,\n        is_compressed: bool = True,\n    ):\n        width = settings.THUMBNAIL_WIDTH_PX\n        if is_full_size:\n            info_res, *_ = MediaViewSet._thumbnail_proxy_comm(\n                \"info\", {\"url\": image_url}\n            )\n            info = json.loads(info_res.read())\n            width = info[\"width\"]\n\n        params = {\n            \"url\": image_url,\n            \"width\": width,\n        }\n\n        if is_compressed:\n            params |= {\n                \"quality\": settings.THUMBNAIL_JPG_QUALITY,\n                \"compression\": settings.THUMBNAIL_PNG_COMPRESSION,\n            }\n        else:\n            params |= {\n                \"quality\": 100,\n                \"compression\": 0,\n            }\n\n        if \"webp\" in accept_header:\n            params[\"type\"] = \"auto\"  # Use ``Accept`` header to determine output type.\n\n        img_res, res_status, content_type = MediaViewSet._thumbnail_proxy_comm(\n            \"resize\", params, ((\"Accept\", accept_header),)\n        )\n        response = HttpResponse(\n            img_res.read(), status=res_status, content_type=content_type\n        )\n        return response\n", "path": "api/catalog/api/views/media_views.py"}], "after_files": [{"content": "import json\nimport logging as log\nfrom http.client import RemoteDisconnected\nfrom urllib.error import HTTPError\nfrom urllib.parse import urlencode\nfrom urllib.request import Request, urlopen\n\nfrom django.conf import settings\nfrom django.http.response import HttpResponse\nfrom rest_framework import status\nfrom rest_framework.decorators import action\nfrom rest_framework.exceptions import APIException\nfrom rest_framework.response import Response\nfrom rest_framework.viewsets import ReadOnlyModelViewSet\n\nfrom sentry_sdk import capture_exception\n\nfrom catalog.api.controllers import search_controller\nfrom catalog.api.models import ContentProvider\nfrom catalog.api.serializers.provider_serializers import ProviderSerializer\nfrom catalog.api.utils.exceptions import get_api_exception\nfrom catalog.api.utils.pagination import StandardPagination\nfrom catalog.custom_auto_schema import CustomAutoSchema\n\n\nclass UpstreamThumbnailException(APIException):\n    status_code = status.HTTP_424_FAILED_DEPENDENCY\n    default_detail = \"Could not render thumbnail due to upstream provider error.\"\n\n\nclass MediaViewSet(ReadOnlyModelViewSet):\n    swagger_schema = CustomAutoSchema\n\n    lookup_field = \"identifier\"\n    # TODO: https://github.com/encode/django-rest-framework/pull/6789\n    lookup_value_regex = (\n        r\"[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}\"\n    )\n\n    pagination_class = StandardPagination\n\n    # Populate these in the corresponding subclass\n    model_class = None\n    query_serializer_class = None\n    default_index = None\n    qa_index = None\n\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        required_fields = [\n            self.model_class,\n            self.query_serializer_class,\n            self.default_index,\n            self.qa_index,\n        ]\n        if any(val is None for val in required_fields):\n            msg = \"Viewset fields are not completely populated.\"\n            raise ValueError(msg)\n\n    def get_queryset(self):\n        return self.model_class.objects.all()\n\n    # Standard actions\n\n    def list(self, request, *_, **__):\n        self.paginator.page_size = request.query_params.get(\"page_size\")\n        page_size = self.paginator.page_size\n        self.paginator.page = request.query_params.get(\"page\")\n        page = self.paginator.page\n\n        params = self.query_serializer_class(\n            data=request.query_params, context={\"request\": request}\n        )\n        params.is_valid(raise_exception=True)\n\n        hashed_ip = hash(self._get_user_ip(request))\n        qa = params.validated_data[\"qa\"]\n        filter_dead = params.validated_data[\"filter_dead\"]\n\n        search_index = self.qa_index if qa else self.default_index\n        try:\n            results, num_pages, num_results = search_controller.search(\n                params,\n                search_index,\n                page_size,\n                hashed_ip,\n                request,\n                filter_dead,\n                page,\n            )\n            self.paginator.page_count = num_pages\n            self.paginator.result_count = num_results\n        except ValueError as e:\n            raise get_api_exception(getattr(e, \"message\", str(e)))\n\n        serializer = self.get_serializer(results, many=True)\n        return self.get_paginated_response(serializer.data)\n\n    # Extra actions\n\n    @action(detail=False, serializer_class=ProviderSerializer, pagination_class=None)\n    def stats(self, *_, **__):\n        source_counts = search_controller.get_sources(self.default_index)\n        context = self.get_serializer_context() | {\n            \"source_counts\": source_counts,\n        }\n\n        providers = ContentProvider.objects.filter(\n            media_type=self.default_index, filter_content=False\n        )\n        serializer = self.get_serializer(providers, many=True, context=context)\n        return Response(serializer.data)\n\n    @action(detail=True)\n    def related(self, request, identifier=None, *_, **__):\n        try:\n            results, num_results = search_controller.related_media(\n                uuid=identifier,\n                index=self.default_index,\n                request=request,\n                filter_dead=True,\n            )\n            self.paginator.result_count = num_results\n            self.paginator.page_count = 1\n            # `page_size` refers to the maximum number of related images to return.\n            self.paginator.page_size = 10\n        except ValueError as e:\n            raise get_api_exception(getattr(e, \"message\", str(e)))\n        # If there are no hits in the search controller\n        except IndexError:\n            raise get_api_exception(\"Could not find items.\", 404)\n\n        serializer = self.get_serializer(results, many=True)\n        return self.get_paginated_response(serializer.data)\n\n    def report(self, request, *_, **__):\n        media = self.get_object()\n        identifier = media.identifier\n        serializer = self.get_serializer(data=request.data)\n        if not serializer.is_valid():\n            raise get_api_exception(\"Invalid input.\", 400)\n        report = serializer.save(identifier=identifier)\n\n        serializer = self.get_serializer(report)\n        return Response(data=serializer.data, status=status.HTTP_201_CREATED)\n\n    def thumbnail(self, image_url, request, *_, **__):\n        serializer = self.get_serializer(data=request.query_params)\n        serializer.is_valid(raise_exception=True)\n        return self._get_proxied_image(\n            image_url,\n            accept_header=request.headers.get(\"Accept\", \"image/*\"),\n            **serializer.validated_data,\n        )\n\n    # Helper functions\n\n    @staticmethod\n    def _get_user_ip(request):\n        \"\"\"\n        Read request headers to find the correct IP address.\n        It is assumed that X-Forwarded-For has been sanitized by the load\n        balancer and thus cannot be rewritten by malicious users.\n        :param request: A Django request object.\n        :return: An IP address.\n        \"\"\"\n        x_forwarded_for = request.META.get(\"HTTP_X_FORWARDED_FOR\")\n        if x_forwarded_for:\n            ip = x_forwarded_for.split(\",\")[0]\n        else:\n            ip = request.META.get(\"REMOTE_ADDR\")\n        return ip\n\n    @staticmethod\n    def _thumbnail_proxy_comm(\n        path: str,\n        params: dict,\n        headers: tuple[tuple[str, str]] = (),\n    ):\n        proxy_url = settings.THUMBNAIL_PROXY_URL\n        query_string = urlencode(params)\n        upstream_url = f\"{proxy_url}/{path}?{query_string}\"\n        log.debug(f\"Image proxy upstream URL: {upstream_url}\")\n\n        try:\n            req = Request(upstream_url)\n            for key, val in headers:\n                req.add_header(key, val)\n            upstream_response = urlopen(req, timeout=10)\n\n            res_status = upstream_response.status\n            content_type = upstream_response.headers.get(\"Content-Type\")\n            log.debug(\n                \"Image proxy response \"\n                f\"status: {res_status}, content-type: {content_type}\"\n            )\n\n            return upstream_response, res_status, content_type\n        except (HTTPError, RemoteDisconnected, TimeoutError) as exc:\n            capture_exception(exc)\n            raise UpstreamThumbnailException(f\"Failed to render thumbnail: {exc}\")\n        except Exception as exc:\n            capture_exception(exc)\n            raise UpstreamThumbnailException(\n                f\"Failed to render thumbnail due to unidentified exception: {exc}\"\n            )\n\n    @staticmethod\n    def _get_proxied_image(\n        image_url: str,\n        accept_header: str = \"image/*\",\n        is_full_size: bool = False,\n        is_compressed: bool = True,\n    ):\n        width = settings.THUMBNAIL_WIDTH_PX\n        if is_full_size:\n            info_res, *_ = MediaViewSet._thumbnail_proxy_comm(\n                \"info\", {\"url\": image_url}\n            )\n            info = json.loads(info_res.read())\n            width = info[\"width\"]\n\n        params = {\n            \"url\": image_url,\n            \"width\": width,\n        }\n\n        if is_compressed:\n            params |= {\n                \"quality\": settings.THUMBNAIL_JPG_QUALITY,\n                \"compression\": settings.THUMBNAIL_PNG_COMPRESSION,\n            }\n        else:\n            params |= {\n                \"quality\": 100,\n                \"compression\": 0,\n            }\n\n        if \"webp\" in accept_header:\n            params[\"type\"] = \"auto\"  # Use ``Accept`` header to determine output type.\n\n        img_res, res_status, content_type = MediaViewSet._thumbnail_proxy_comm(\n            \"resize\", params, ((\"Accept\", accept_header),)\n        )\n        response = HttpResponse(\n            img_res.read(), status=res_status, content_type=content_type\n        )\n        return response\n", "path": "api/catalog/api/views/media_views.py"}]}