Repository: canonical/snapcraft

Issue:
When asking to release to a branch that's too long, a traceback is printed that gives no hints as to the source of the error
- [When asking to release to a branch that's too long, a traceback is printed that gives no hints as to the source of the error](https://bugs.launchpad.net/snapcraft/+bug/1750177)



Discussion:

Resolving patch:
diff --git a/snapcraft/storeapi/errors.py b/snapcraft/storeapi/errors.py
--- a/snapcraft/storeapi/errors.py
+++ b/snapcraft/storeapi/errors.py
@@ -15,6 +15,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import contextlib
+import logging
 from simplejson.scanner import JSONDecodeError
 from typing import List  # noqa
 
@@ -22,6 +23,9 @@
 from snapcraft import formatting_utils
 
 
+logger = logging.getLogger(__name__)
+
+
 class StoreError(SnapcraftError):
     """Base class for all storeapi exceptions.
 
@@ -29,6 +33,12 @@ class StoreError(SnapcraftError):
 
     """
 
+    def __init__(self, **kwargs):
+        with contextlib.suppress(KeyError, AttributeError):
+            logger.debug('Store error response: {}'.format(
+                kwargs['response'].__dict__))
+        super().__init__(**kwargs)
+
 
 class InvalidCredentialsError(StoreError):
 
@@ -109,7 +119,7 @@ def __init__(self, message, response=None):
             if extra_error_message:
                 message += ': {}'.format(extra_error_message)
 
-        super().__init__(message=message)
+        super().__init__(response=response, message=message)
 
 
 class StoreTwoFactorAuthenticationRequired(StoreAuthenticationError):
@@ -131,7 +141,8 @@ class DeveloperAgreementSignError(StoreError):
         'Text: {text!r}')
 
     def __init__(self, response):
-        super().__init__(reason=response.reason, text=response.text)
+        super().__init__(
+            response=response, reason=response.reason, text=response.text)
 
 
 class NeedTermsSignedError(StoreError):
@@ -161,7 +172,7 @@ def __init__(self, response):
                         'error_list'] if 'extra' in error]
         except JSONDecodeError:
             pass
-        super().__init__(error=error, extra=extra)
+        super().__init__(response=response, error=error, extra=extra)
 
 
 class StoreKeyRegistrationError(StoreError):
@@ -177,7 +188,7 @@ def __init__(self, response):
                     error['message'] for error in response_json['error_list'])
         except JSONDecodeError:
             pass
-        super().__init__(error=error)
+        super().__init__(response=response, error=error)
 
 
 class StoreRegistrationError(StoreError):
@@ -262,7 +273,8 @@ class StoreUploadError(StoreError):
         'Text: {text!r}')
 
     def __init__(self, response):
-        super().__init__(reason=response.reason, text=response.text)
+        super().__init__(
+            response=response, reason=response.reason, text=response.text)
 
 
 class StorePushError(StoreError):
@@ -288,8 +300,9 @@ def __init__(self, snap_name, response):
             except AttributeError:
                 response_json['text'] = 'error while pushing'
 
-        super().__init__(snap_name=snap_name, status_code=response.status_code,
-                         **response_json)
+        super().__init__(
+            response=response, snap_name=snap_name,
+            status_code=response.status_code, **response_json)
 
 
 class StoreReviewError(StoreError):
@@ -349,12 +362,13 @@ def __init__(self, snap_name, response):
             401: self.__fmt_error_401_or_403,
             403: self.__fmt_error_401_or_403,
             404: self.__fmt_error_404,
+            500: self.__fmt_error_500,
         }
 
         fmt_error = self.fmt_errors.get(
             response.status_code, self.__fmt_error_unknown)
 
-        super().__init__(message=fmt_error(response).format(
+        super().__init__(response=response, message=fmt_error(response).format(
             snap_name=snap_name))
 
     def __to_json(self, response):
@@ -396,6 +410,17 @@ def __fmt_error_401_or_403(self, response):
     def __fmt_error_404(self, response):
         return self.__FMT_NOT_REGISTERED
 
+    def __fmt_error_500(self, response):
+        response_json = self.__to_json(response)
+        message = ('The store encountered an internal error. The status of '
+                   'store and associated services can be checked at:\n'
+                   'https://status.snapcraft.io/')
+
+        if 'error_list' in response_json:
+            message = _error_list_to_message(response_json)
+
+        return message
+
     def __fmt_error_unknown(self, response):
         response_json = self.__to_json(response)
 
@@ -445,8 +470,9 @@ def __init__(self, snap_name, response, metadata):
         elif 'error_list' in response_json:
             response_json['text'] = response_json['error_list'][0]['message']
 
-        super().__init__(snap_name=snap_name, status_code=response.status_code,
-                         **response_json)
+        super().__init__(
+            response=response, snap_name=snap_name,
+            status_code=response.status_code, **response_json)
 
 
 class StoreValidationError(StoreError):
@@ -462,7 +488,7 @@ def __init__(self, snap_id, response, message=None):
         except (AttributeError, JSONDecodeError):
             response_json = {'text': message or response}
 
-        super().__init__(status_code=response.status_code,
+        super().__init__(response=response, status_code=response.status_code,
                          **response_json)
 
 
@@ -480,7 +506,7 @@ def __init__(self, response):
         except JSONDecodeError:
             pass
 
-        super().__init__(error=error)
+        super().__init__(response=response, error=error)
 
 
 class StoreSnapRevisionsError(StoreError):
@@ -500,7 +526,7 @@ def __init__(self, response, snap_id, series, arch):
             pass
 
         super().__init__(
-            snap_id=snap_id, arch=arch or 'any arch',
+            response=response, snap_id=snap_id, arch=arch or 'any arch',
             series=series or 'any', error=error)
 
 
@@ -531,7 +557,7 @@ def __init__(self, response):
             error = '{} {}'.format(
                 response.status_code, response.reason)
 
-        super().__init__(error=error)
+        super().__init__(response=response, error=error)
 
 
 class StoreChannelClosingPermissionError(StoreError):


Test patch:
diff --git a/tests/fake_servers/api.py b/tests/fake_servers/api.py
--- a/tests/fake_servers/api.py
+++ b/tests/fake_servers/api.py
@@ -479,6 +479,9 @@ def snap_release(self, request):
                 'permission': 'channel',
                 'channels': ['no-permission'],
             }).encode()
+        elif 'bad-channel' in channels:
+            response_code = 500
+            payload = json.dumps({}).encode()
         elif (
                 name == 'test-snap' or
                 name.startswith('test-snapcraft')):
diff --git a/tests/integration/store/test_store_release.py b/tests/integration/store/test_store_release.py
--- a/tests/integration/store/test_store_release.py
+++ b/tests/integration/store/test_store_release.py
@@ -98,3 +98,38 @@ def test_release_to_channel_without_permission(self):
         self.assertThat(error.output, Contains(
             "Received 403: Lacking permission to release to channel(s) "
             "'no-permission'"))
+
+    def test_release_internal_error(self):
+        if not self.is_store_fake():
+            self.skipTest("The real store won't return the proper response")
+
+        self.addCleanup(self.logout)
+        self.login()
+
+        # Change to a random name and version.
+        name = self.get_unique_name()
+        version = self.get_unique_version()
+        self.copy_project_to_cwd('basic')
+        self.update_name_and_version(name, version)
+
+        self.run_snapcraft('snap')
+
+        # Register the snap
+        self.register(name)
+        # Upload the snap
+        snap_file_path = '{}_{}_{}.snap'.format(name, version, 'all')
+        self.assertThat(
+            os.path.join(snap_file_path), FileExists())
+
+        output = self.run_snapcraft(['push', snap_file_path])
+        expected = r'.*Ready to release!.*'.format(name)
+        self.assertThat(output, MatchesRegex(expected, flags=re.DOTALL))
+
+        # Attempt to release it
+        error = self.assertRaises(
+            subprocess.CalledProcessError,
+            self.run_snapcraft, ['release', name, '1', 'bad-channel'])
+        self.assertThat(error.output, Contains(
+            'store encountered an internal error. The status of store and '
+            'associated services can be checked at:\n'
+            'https://status.snapcraft.io/'))
diff --git a/tests/unit/store/test_store_client.py b/tests/unit/store/test_store_client.py
--- a/tests/unit/store/test_store_client.py
+++ b/tests/unit/store/test_store_client.py
@@ -1018,13 +1018,20 @@ def test_close_broken_store_plain(self):
         self.assertThat(
             str(raised),
             Equals('Could not close channel: 200 OK'))
-        self.assertThat(
-            self.fake_logger.output.splitlines()[-3:],
-            Equals([
-                'Invalid response from the server on channel closing:',
-                '200 OK',
-                'b\'plain data\'',
-            ]))
+
+        expected_lines = [
+            'Invalid response from the server on channel closing:',
+            '200 OK',
+            'b\'plain data\'',
+        ]
+
+        actual_lines = []
+        for line in self.fake_logger.output.splitlines():
+            line = line.strip()
+            if line in expected_lines:
+                actual_lines.append(line)
+
+        self.assertThat(actual_lines, Equals(expected_lines))
 
     def test_close_broken_store_json(self):
         self.client.login('dummy', 'test correct password')
@@ -1034,13 +1041,20 @@ def test_close_broken_store_json(self):
         self.assertThat(
             str(raised),
             Equals('Could not close channel: 200 OK'))
-        self.assertThat(
-            self.fake_logger.output.splitlines()[-3:],
-            Equals([
-                'Invalid response from the server on channel closing:',
-                '200 OK',
-                'b\'{"closed_channels": ["broken-json"]}\'',
-            ]))
+
+        expected_lines = [
+            'Invalid response from the server on channel closing:',
+            '200 OK',
+            'b\'{"closed_channels": ["broken-json"]}\'',
+        ]
+
+        actual_lines = []
+        for line in self.fake_logger.output.splitlines():
+            line = line.strip()
+            if line in expected_lines:
+                actual_lines.append(line)
+
+        self.assertThat(actual_lines, Equals(expected_lines))
 
     def test_close_successfully(self):
         # Successfully closing a channels returns 'closed_channels'