Repository: learningequality/kolibri

Issue:
RemoteChannel Viewset throws 500 error when server is not connected to internet
<!--
Instructions:
 * Fill out the sections below, replace …'s with information about your issue
 * Use the 'preview' function above this text box to verify formatting before submitting
-->

### Observed behavior

For example, when clicking "Manage" on a Channel on Device > Channels when offline, we make a request to studio via the RemoteChannelViewset channel detail view, the server returns an HTML Django Error page, with this

```
ConnectionError at /api/content/remotechannel/a9b25ac9814742c883ce1b0579448337/
HTTPSConnectionPool(host='studio.learningequality.org', port=443): Max retries exceeded with url: /api/public/v1/channels/lookup/a9b25ac9814742c883ce1b0579448337 (Caused by NewConnectionError('<urllib3.connection.VerifiedHTTPSConnection object at 0x10857a850>: Failed to establish a new connection: [Errno 8] nodename nor servname provided, or not known'))
```

And there is a larger error that gets logged.

The client code knows to just ignore this, so nothing happens with the end user. 

### Expected behavior

If we know that this Viewset isn't working because the server is offline, we can handle the NEwConnectionError gracefully and return a different error code like a 503 (service not found) or something more appropriate in a JSON response and not log so much traceback info to the terminal.

### User-facing consequences

This error seems to log a 100+ line traceback, which will go in the logs for a behavior that we know about and should be able to handle gracefully.

### Errors and logs
<!--
Relevant logs from:
 * the command line
 * ~/.kolibri/logs/kolibri.txt
 * the browser console

Please wrap errors in triple backticks for clean formatting like this:
```
01:10 info: something happened
01:12 error: something bad happened
```
-->

…

### Steps to reproduce

1. Turn off internet connection
1. Go to Manage a channel from Device > Channels
1. See a lot logs on the terminal and in the devtools network tab

### Context
Kolibri 0.13 beta 7


Discussion:

Resolving patch:
diff --git a/kolibri/core/content/api.py b/kolibri/core/content/api.py
--- a/kolibri/core/content/api.py
+++ b/kolibri/core/content/api.py
@@ -26,6 +26,7 @@
 from le_utils.constants import content_kinds
 from le_utils.constants import languages
 from rest_framework import mixins
+from rest_framework import status
 from rest_framework import viewsets
 from rest_framework.decorators import detail_route
 from rest_framework.decorators import list_route
@@ -1330,9 +1331,14 @@ def list(self, request, *args, **kwargs):
         baseurl = request.GET.get("baseurl", None)
         keyword = request.GET.get("keyword", None)
         language = request.GET.get("language", None)
-        channels = self._make_channel_endpoint_request(
-            baseurl=baseurl, keyword=keyword, language=language
-        )
+        try:
+            channels = self._make_channel_endpoint_request(
+                baseurl=baseurl, keyword=keyword, language=language
+            )
+        except requests.exceptions.ConnectionError:
+            return Response(
+                {"status": "offline"}, status=status.HTTP_503_SERVICE_UNAVAILABLE
+            )
         return Response(channels)
 
     def retrieve(self, request, pk=None):
@@ -1342,9 +1348,14 @@ def retrieve(self, request, pk=None):
         baseurl = request.GET.get("baseurl", None)
         keyword = request.GET.get("keyword", None)
         language = request.GET.get("language", None)
-        channels = self._make_channel_endpoint_request(
-            identifier=pk, baseurl=baseurl, keyword=keyword, language=language
-        )
+        try:
+            channels = self._make_channel_endpoint_request(
+                identifier=pk, baseurl=baseurl, keyword=keyword, language=language
+            )
+        except requests.exceptions.ConnectionError:
+            return Response(
+                {"status": "offline"}, status=status.HTTP_503_SERVICE_UNAVAILABLE
+            )
         if not channels:
             raise Http404
         return Response(channels[0])
@@ -1365,8 +1376,13 @@ def retrieve_list(self, request, pk=None):
         baseurl = request.GET.get("baseurl", None)
         keyword = request.GET.get("keyword", None)
         language = request.GET.get("language", None)
-        return Response(
-            self._make_channel_endpoint_request(
-                identifier=pk, baseurl=baseurl, keyword=keyword, language=language
+        try:
+            return Response(
+                self._make_channel_endpoint_request(
+                    identifier=pk, baseurl=baseurl, keyword=keyword, language=language
+                )
+            )
+        except requests.exceptions.ConnectionError:
+            return Response(
+                {"status": "offline"}, status=status.HTTP_503_SERVICE_UNAVAILABLE
             )
-        )


Test patch:
diff --git a/kolibri/core/content/test/test_content_app.py b/kolibri/core/content/test/test_content_app.py
--- a/kolibri/core/content/test/test_content_app.py
+++ b/kolibri/core/content/test/test_content_app.py
@@ -1813,5 +1813,22 @@ def test_channel_info_404(self):
         )
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
+    @mock.patch.object(requests, "get", side_effect=requests.exceptions.ConnectionError)
+    def test_channel_info_offline(self, mock_get):
+        response = self.client.get(
+            reverse("kolibri:core:remotechannel-detail", kwargs={"pk": "abc"}),
+            format="json",
+        )
+        self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
+        self.assertEqual(response.json()["status"], "offline")
+
+    @mock.patch.object(requests, "get", side_effect=requests.exceptions.ConnectionError)
+    def test_channel_list_offline(self, mock_get):
+        response = self.client.get(
+            reverse("kolibri:core:remotechannel-list"), format="json"
+        )
+        self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
+        self.assertEqual(response.json()["status"], "offline")
+
     def tearDown(self):
         cache.clear()