Repository: certbot/certbot

Issue:
Give friendlier errors on new error types from server
Currently, if the client receives an unrecognized error type from the server (e.g. rate limited), it gives an unfriendly error:

```
An unexpected error occurred.
ClientError: (<Response [429]>, DeserializationError("Could not decode 'typ' (u'urn:acme:error:rateLimited'): Deserialization error: Error type not recognized",))
```

Instead, the client should expect that error types will be added that it does not recognize, and report them to the user similarly to other errors, including the error detail message.

I think this is fairly high priority to fix, because otherwise it prohibits Boulder from using meaningful error types.



Discussion:
(At least) at the time I was writing the code, specification didn't allow (or wasn't clear if it's possible for) error types to be extended by the concrete implementation. In fact, it's slightly confusing even now: "To facilitate automatic response to errors, this document defines the following standard tokens for use in the "type" field (within the "urn:acme:" namespace):". Only the word "standard" hints that there might be some non-standard types... Can we update spec to be explicit about it? Are types allowed to be outside the `urn:acme:` namespace? Do we allow unrecognized types (such as this one, as it's not mentioned in the spec) in the `urn:acme:` namespace?

This should be tagged with `spec-compliance` rather than `ui/ux`.

I have a PR against the IETF ACME repo: https://github.com/ietf-wg-acme/acme/pull/17

Given https://github.com/letsencrypt/letsencrypt/pull/974 I think we can lower down prio, and move to 1.0-nice. @bmw?

Didn't see your comment here when I changed the milestone.

As you can see, I agree with you. While it's not an ideal solution, we can do the same thing we did in #974 if new error types are introduced as a quick fix until a long term solution is in place.


Resolving patch:
diff --git a/acme/acme/messages.py b/acme/acme/messages.py
--- a/acme/acme/messages.py
+++ b/acme/acme/messages.py
@@ -2,12 +2,13 @@
 import collections
 
 from acme import challenges
+from acme import errors
 from acme import fields
 from acme import jose
 from acme import util
 
 
-class Error(jose.JSONObjectWithFields, Exception):
+class Error(jose.JSONObjectWithFields, errors.Error):
     """ACME error.
 
     https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00
@@ -17,55 +18,40 @@ class Error(jose.JSONObjectWithFields, Exception):
     :ivar unicode detail:
 
     """
-    ERROR_TYPE_NAMESPACE = 'urn:acme:error:'
-    ERROR_TYPE_DESCRIPTIONS = {
-        'badCSR': 'The CSR is unacceptable (e.g., due to a short key)',
-        'badNonce': 'The client sent an unacceptable anti-replay nonce',
-        'connection': 'The server could not connect to the client for DV',
-        'dnssec': 'The server could not validate a DNSSEC signed domain',
-        'malformed': 'The request message was malformed',
-        'rateLimited': 'There were too many requests of a given type',
-        'serverInternal': 'The server experienced an internal error',
-        'tls': 'The server experienced a TLS error during DV',
-        'unauthorized': 'The client lacks sufficient authorization',
-        'unknownHost': 'The server could not resolve a domain name',
-    }
+    ERROR_TYPE_DESCRIPTIONS = dict(
+        ('urn:acme:error:' + name, description) for name, description in (
+            ('badCSR', 'The CSR is unacceptable (e.g., due to a short key)'),
+            ('badNonce', 'The client sent an unacceptable anti-replay nonce'),
+            ('connection', 'The server could not connect to the client for DV'),
+            ('dnssec', 'The server could not validate a DNSSEC signed domain'),
+            ('malformed', 'The request message was malformed'),
+            ('rateLimited', 'There were too many requests of a given type'),
+            ('serverInternal', 'The server experienced an internal error'),
+            ('tls', 'The server experienced a TLS error during DV'),
+            ('unauthorized', 'The client lacks sufficient authorization'),
+            ('unknownHost', 'The server could not resolve a domain name'),
+        )
+    )
 
     typ = jose.Field('type')
     title = jose.Field('title', omitempty=True)
     detail = jose.Field('detail')
 
-    @typ.encoder
-    def typ(value):  # pylint: disable=missing-docstring,no-self-argument
-        return Error.ERROR_TYPE_NAMESPACE + value
-
-    @typ.decoder
-    def typ(value):  # pylint: disable=missing-docstring,no-self-argument
-        # pylint thinks isinstance(value, Error), so startswith is not found
-        # pylint: disable=no-member
-        if not value.startswith(Error.ERROR_TYPE_NAMESPACE):
-            raise jose.DeserializationError('Missing error type prefix')
-
-        without_prefix = value[len(Error.ERROR_TYPE_NAMESPACE):]
-        if without_prefix not in Error.ERROR_TYPE_DESCRIPTIONS:
-            raise jose.DeserializationError('Error type not recognized')
-
-        return without_prefix
-
     @property
     def description(self):
         """Hardcoded error description based on its type.
 
+        :returns: Description if standard ACME error or ``None``.
         :rtype: unicode
 
         """
-        return self.ERROR_TYPE_DESCRIPTIONS[self.typ]
+        return self.ERROR_TYPE_DESCRIPTIONS.get(self.typ)
 
     def __str__(self):
-        if self.typ is not None:
-            return ' :: '.join([self.typ, self.description, self.detail])
-        else:
-            return str(self.detail)
+        return ' :: '.join(
+            part for part in
+            (self.typ, self.description, self.detail, self.title)
+            if part is not None)
 
 
 class _Constant(jose.JSONDeSerializable, collections.Hashable):


Test patch:
diff --git a/acme/acme/messages_test.py b/acme/acme/messages_test.py
--- a/acme/acme/messages_test.py
+++ b/acme/acme/messages_test.py
@@ -18,41 +18,30 @@ class ErrorTest(unittest.TestCase):
 
     def setUp(self):
         from acme.messages import Error
-        self.error = Error(detail='foo', typ='malformed', title='title')
-        self.jobj = {'detail': 'foo', 'title': 'some title'}
-
-    def test_typ_prefix(self):
-        self.assertEqual('malformed', self.error.typ)
-        self.assertEqual(
-            'urn:acme:error:malformed', self.error.to_partial_json()['type'])
-        self.assertEqual(
-            'malformed', self.error.from_json(self.error.to_partial_json()).typ)
-
-    def test_typ_decoder_missing_prefix(self):
-        from acme.messages import Error
-        self.jobj['type'] = 'malformed'
-        self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj)
-        self.jobj['type'] = 'not valid bare type'
-        self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj)
+        self.error = Error(
+            detail='foo', typ='urn:acme:error:malformed', title='title')
+        self.jobj = {
+            'detail': 'foo',
+            'title': 'some title',
+            'type': 'urn:acme:error:malformed',
+        }
+        self.error_custom = Error(typ='custom', detail='bar')
+        self.jobj_cusom = {'type': 'custom', 'detail': 'bar'}
 
-    def test_typ_decoder_not_recognized(self):
+    def test_from_json_hashable(self):
         from acme.messages import Error
-        self.jobj['type'] = 'urn:acme:error:baz'
-        self.assertRaises(jose.DeserializationError, Error.from_json, self.jobj)
+        hash(Error.from_json(self.error.to_json()))
 
     def test_description(self):
         self.assertEqual(
             'The request message was malformed', self.error.description)
-
-    def test_from_json_hashable(self):
-        from acme.messages import Error
-        hash(Error.from_json(self.error.to_json()))
+        self.assertTrue(self.error_custom.description is None)
 
     def test_str(self):
         self.assertEqual(
-            'malformed :: The request message was malformed :: foo',
-            str(self.error))
-        self.assertEqual('foo', str(self.error.update(typ=None)))
+            'urn:acme:error:malformed :: The request message was '
+            'malformed :: foo :: title', str(self.error))
+        self.assertEqual('custom :: bar', str(self.error_custom))
 
 
 class ConstantTest(unittest.TestCase):
@@ -232,7 +221,7 @@ def setUp(self):
         from acme.messages import Error
         from acme.messages import STATUS_INVALID
         self.status = STATUS_INVALID
-        error = Error(typ='serverInternal',
+        error = Error(typ='urn:acme:error:serverInternal',
                       detail='Unable to communicate with DNS server')
         self.challb = ChallengeBody(
             uri='http://challb', chall=self.chall, status=self.status,