Repository: mozmeao/basket

Issue:
Include error JSON in CTMS errors
CTMS reports failed statuses with ``raise_for_status``, like:

https://github.com/mozmeao/basket/blob/ce9439490a6554ee85ed55f21cdec882c4a3ec67/basket/news/backends/ctms.py#L569-L571

In Sentry, this reports:
* The status code (such as `422`)
* The reason (such as `"Unprocessable Entity"`)
* A summary message (such as `"422 Client Error: Unprocessable Entity for url: https://ctms.stage.mozilla-ess.mozit.cloud/ctms/{email_id}"`

For a 422 and other errors, the JSON body of the response also has information, which is useful for diagnosing the error, and should be included in the context sent to Sentry


Discussion:

Resolving patch:
diff --git a/basket/news/backends/ctms.py b/basket/news/backends/ctms.py
--- a/basket/news/backends/ctms.py
+++ b/basket/news/backends/ctms.py
@@ -440,6 +440,57 @@ def __str__(self):
         )
 
 
+class CTMSNotFoundByEmailIDError(CTMSError):
+    """
+    A CTMS record was not found by the primary email_id.
+
+    This replaces a 404 HTTPError when the email_id is part of the URL.
+    """
+
+    def __init__(self, email_id):
+        self.email_id = email_id
+
+    def __repr__(self):
+        return f"{self.__class__.__name__}({self.email_id!r})"
+
+    def __str__(self):
+        return f"Contact not found with email ID {self.email_id!r}"
+
+
+class CTMSUniqueIDConflictError(CTMSError):
+    """
+    The update was rejected because it would create a duplicate ID.
+
+    This replaces a 409 HTTPError.
+    """
+
+    def __init__(self, detail):
+        self.detail = detail
+
+    def __repr__(self):
+        return f"{self.__class__.__name__}({self.detail!r})"
+
+    def __str__(self):
+        return f"Unique ID conflict: {self.detail!r}"
+
+
+class CTMSValidationError(CTMSError):
+    """
+    An invalid parameter was sent in the request.
+
+    This replaces a 422 HTTPError, used by CTMS to report a validation error.
+    """
+
+    def __init__(self, detail):
+        self.detail = detail
+
+    def __repr__(self):
+        return f"{self.__class__.__name__}({self.detail!r})"
+
+    def __str__(self):
+        return f"CTMS rejected the invalid request: {self.detail!r}"
+
+
 class CTMSInterface:
     """Basic Interface to the CTMS API"""
 
@@ -497,7 +548,7 @@ def get_by_alternate_id(
         if not ids:
             raise CTMSNoIdsError(self.all_ids)
         resp = self.session.get("/ctms", params=ids)
-        resp.raise_for_status()
+        self._check_response(resp)
         return resp.json()
 
     @time_request
@@ -513,7 +564,7 @@ def post_to_create(self, data):
         @return: The created contact data
         """
         resp = self.session.post("/ctms", json=data)
-        resp.raise_for_status()
+        self._check_response(resp)
         return resp.json()
 
     @time_request
@@ -528,7 +579,7 @@ def get_by_email_id(self, email_id):
         @raises: request.HTTPError, status_code 404, on unknown email_id
         """
         resp = self.session.get(f"/ctms/{email_id}")
-        resp.raise_for_status()
+        self._check_response(resp, email_id)
         return resp.json()
 
     @time_request
@@ -541,7 +592,7 @@ def put_by_email_id(self, email_id, data):
         @return: The created or replaced contact data
         """
         resp = self.session.put(f"/ctms/{email_id}", json=data)
-        resp.raise_for_status()
+        self._check_response(resp, email_id)
         return resp.json()
 
     def put(self, data):
@@ -575,9 +626,28 @@ def patch_by_email_id(self, email_id, data):
         @return: The updated contact data
         """
         resp = self.session.patch(f"/ctms/{email_id}", json=data)
-        resp.raise_for_status()
+        self._check_response(resp, email_id)
         return resp.json()
 
+    def _check_response(self, response, email_id=None):
+        """
+        Check a CTMS response, and raise exceptions as needed.
+
+        @param response: The response from CTMS
+        @param email_id: The email_id, implies a 404 is a CTMSNotFoundByEmailIDError
+        """
+        if response.status_code == 404 and email_id:
+            raise CTMSNotFoundByEmailIDError(email_id)
+        if response.status_code == 409:
+            body = response.json()
+            raise CTMSUniqueIDConflictError(body["detail"])
+        if response.status_code == 422:
+            body = response.json()
+            raise CTMSValidationError(body["detail"])
+
+        # Raise HTTPError for other non-2xx status
+        response.raise_for_status()
+
 
 class CTMSMultipleContactsError(CTMSError):
     """Multiple contacts were returned when one was expected."""
@@ -730,11 +800,7 @@ def add(self, data):
                 raise CTMSNotConfigured()
             else:
                 return None
-        try:
-            return self.interface.post_to_create(to_vendor(data))
-        except Exception:
-            sentry_sdk.capture_exception()
-            return None
+        return self.interface.post_to_create(to_vendor(data))
 
     def update(self, existing_data, update_data):
         """
@@ -753,12 +819,8 @@ def update(self, existing_data, update_data):
         if not email_id:
             # TODO: When CTMS is primary, this should be an error
             return None
-        try:
-            ctms_data = to_vendor(update_data, existing_data)
-            return self.interface.patch_by_email_id(email_id, ctms_data)
-        except Exception:
-            sentry_sdk.capture_exception()
-            return None
+        ctms_data = to_vendor(update_data, existing_data)
+        return self.interface.patch_by_email_id(email_id, ctms_data)
 
     def update_by_alt_id(self, alt_id_name, alt_id_value, update_data):
         """
diff --git a/basket/news/tasks.py b/basket/news/tasks.py
--- a/basket/news/tasks.py
+++ b/basket/news/tasks.py
@@ -28,7 +28,11 @@
 from basket.base.utils import email_is_testing
 from basket.news.backends.acoustic import acoustic_tx, acoustic
 from basket.news.backends.common import NewsletterException
-from basket.news.backends.ctms import ctms, CTMSNotFoundByAltIDError
+from basket.news.backends.ctms import (
+    ctms,
+    CTMSNotFoundByAltIDError,
+    CTMSUniqueIDConflictError,
+)
 from basket.news.backends.sfdc import sfdc, SFDCDisabled
 from basket.news.backends.sfdc import from_vendor as from_sfdc
 from basket.news.celery import app as celery_app
@@ -231,7 +235,7 @@ def wrapped(self, *args, **kwargs):
             SilverpopResponseException,
         ) as e:
             # These could all be connection issues, so try again later.
-            # IOError covers URLError and SSLError.
+            # IOError covers URLError, SSLError, and requests.HTTPError.
             if ignore_error(e):
                 with sentry_sdk.push_scope() as scope:
                     scope.set_tag("action", "ignored")
@@ -649,35 +653,31 @@ def upsert_contact(api_call_type, data, user_data):
 
 @et_task
 def sfdc_add_update(update_data, user_data=None):
-    # for use with maintenance mode only
-    # TODO remove after maintenance is over and queue is processed
+    """
+    Add or update contact data when maintainance mode is completed.
+
+    The first version was temporary, with:
+    TODO remove after maintenance is over and queue is processed
+
+    The next version allowed SFDC / CTMS dual-write mode.
+
+    This version only writes to CTMS, so it is now misnamed, but task
+    renames require coordination.
+    """
     if user_data:
-        sfdc.update(user_data, update_data)
         ctms.update(user_data, update_data)
-    else:
-        ctms_data = update_data.copy()
-        ctms_contact = ctms.add(ctms_data)
-        if ctms_contact:
-            update_data["email_id"] = ctms_contact["email"]["email_id"]
+        return
 
-        try:
-            sfdc.add(update_data)
-        except sfapi.SalesforceMalformedRequest as e:  # noqa
-            # possibly a duplicate email. try the update below.
-            user_data = get_user_data(email=update_data["email"], extra_fields=["id"])
-            if user_data:
-                # we have a user, delete generated token and email_id
-                # and continue with an update
-                update_data.pop("token", None)
-                update_data.pop("email_id", None)
-                sfdc.update(user_data, update_data)
-                ctms.update(user_data, update_data)
-            else:
-                # still no user, try the add one more time
-                ctms_contact = ctms.add(update_data)
-                if ctms_contact:
-                    update_data["email_id"] = ctms_contact["email"]["email_id"]
-                sfdc.add(update_data)
+    try:
+        ctms.add(update_data)
+    except CTMSUniqueIDConflictError:
+        # Try as an update
+        user_data = get_user_data(email=update_data["email"])
+        if not user_data:
+            raise
+        update_data.pop("token", None)
+        update_data.pop("email_id", None)
+        ctms.update(user_data, update_data)
 
 
 @et_task
diff --git a/basket/news/utils.py b/basket/news/utils.py
--- a/basket/news/utils.py
+++ b/basket/news/utils.py
@@ -25,7 +25,12 @@
 from email_validator import validate_email, EmailNotValidError
 
 from basket.news.backends.common import NewsletterException
-from basket.news.backends.ctms import ctms, CTMSError, CTMSNotConfigured
+from basket.news.backends.ctms import (
+    ctms,
+    CTMSError,
+    CTMSNotConfigured,
+    CTMSNotFoundByAltIDError,
+)
 from basket.news.backends.sfdc import sfdc, SFDCDisabled
 from basket.news.models import APIUser, BlockedEmail
 from basket.news.newsletters import (
@@ -391,22 +396,23 @@ def get_user_data(
                 amo_id=amo_id,
                 fxa_id=fxa_id,
             )
+        except CTMSNotFoundByAltIDError:
+            return None
         except requests.exceptions.HTTPError as error:
-            if error.response.status_code != 404:
-                if sfdc_enabled:
-                    sentry_sdk.capture_exception()
-                elif error.response.status_code == 401:
-                    raise NewsletterException(
-                        "Email service provider auth failure",
-                        error_code=errors.BASKET_EMAIL_PROVIDER_AUTH_FAILURE,
-                        status_code=500,
-                    )
-                else:
-                    raise NewsletterException(
-                        str(error),
-                        error_code=errors.BASKET_NETWORK_FAILURE,
-                        status_code=400,
-                    )
+            if sfdc_enabled:
+                sentry_sdk.capture_exception()
+            elif error.response.status_code == 401:
+                raise NewsletterException(
+                    "Email service provider auth failure",
+                    error_code=errors.BASKET_EMAIL_PROVIDER_AUTH_FAILURE,
+                    status_code=500,
+                )
+            else:
+                raise NewsletterException(
+                    str(error),
+                    error_code=errors.BASKET_NETWORK_FAILURE,
+                    status_code=400,
+                )
         except CTMSNotConfigured:
             raise NewsletterException(
                 "Email service provider auth failure",


Test patch:
diff --git a/basket/news/tests/test_ctms.py b/basket/news/tests/test_ctms.py
--- a/basket/news/tests/test_ctms.py
+++ b/basket/news/tests/test_ctms.py
@@ -16,6 +16,9 @@
     CTMSNoIdsError,
     CTMSMultipleContactsError,
     CTMSNotFoundByAltIDError,
+    CTMSNotFoundByEmailIDError,
+    CTMSUniqueIDConflictError,
+    CTMSValidationError,
     from_vendor,
     to_vendor,
 )
@@ -834,11 +837,10 @@ def test_post_to_create_data_failure(self):
             ]
         }
         interface = mock_interface("POST", 422, expected)
-        with self.assertRaises(HTTPError) as context:
+        with self.assertRaises(CTMSValidationError) as context:
             interface.post_to_create({})
         error = context.exception
-        assert error.response.status_code == 422
-        assert error.response.json() == expected
+        assert error.detail == expected["detail"]
 
     def test_post_to_create_auth_failure(self):
         expected = {"detail": "Incorrect username or password"}
@@ -849,6 +851,14 @@ def test_post_to_create_auth_failure(self):
         assert error.response.status_code == 400
         assert error.response.json() == expected
 
+    def test_post_to_create_conflict(self):
+        expected = {"detail": "Contact already exists"}
+        interface = mock_interface("POST", 409, expected)
+        with self.assertRaises(CTMSUniqueIDConflictError) as context:
+            interface.post_to_create({"email": {"primary_email": "test@example.com"}})
+        error = context.exception
+        assert error.detail == expected["detail"]
+
 
 class CTMSExceptionTests(TestCase):
     def test_ctms_no_ids(self):
@@ -893,6 +903,50 @@ def test_ctms_not_found_by_alt_id(self):
         assert repr(exc) == "CTMSNotFoundByAltIDError('token', 'foo')"
         assert str(exc) == "No contacts returned for token='foo'"
 
+    def test_ctms_not_found_by_email_id(self):
+        exc = CTMSNotFoundByEmailIDError("4a6dd742-...")
+        assert repr(exc) == "CTMSNotFoundByEmailIDError('4a6dd742-...')"
+        assert str(exc) == "Contact not found with email ID '4a6dd742-...'"
+
+    def test_ctms_unique_id_conflict(self):
+        detail = (
+            "Contact with primary_email, basket_token, mofo_email_id, or fxa_id"
+            " already exists"
+        )
+        exc = CTMSUniqueIDConflictError(detail)
+        assert repr(exc) == (
+            "CTMSUniqueIDConflictError('Contact with primary_email, basket_token,"
+            " mofo_email_id, or fxa_id already exists')"
+        )
+        assert str(exc) == (
+            "Unique ID conflict: 'Contact with primary_email, basket_token,"
+            " mofo_email_id, or fxa_id already exists'"
+        )
+
+    def test_ctms_validation_error(self):
+        # Error when newsletter source is not a URL
+        detail = [
+            {
+                "loc": ["body", "newsletters", 0, "source"],
+                "msg": "invalid or missing URL scheme",
+                "type": "value_error.url.scheme",
+            },
+            {
+                "loc": ["body", "newsletters"],
+                "msg": "unhashable type: 'list'",
+                "type": "type_error",
+            },
+        ]
+        detail_repr = (
+            "[{'loc': ['body', 'newsletters', 0, 'source'],"
+            " 'msg': 'invalid or missing URL scheme', 'type': 'value_error.url.scheme'},"
+            " {'loc': ['body', 'newsletters'],"
+            " 'msg': \"unhashable type: 'list'\", 'type': 'type_error'}]"
+        )
+        exc = CTMSValidationError(detail)
+        assert repr(exc) == f"CTMSValidationError({detail_repr})"
+        assert str(exc) == f"CTMS rejected the invalid request: {detail_repr}"
+
 
 class CTMSTests(TestCase):
 
@@ -936,9 +990,9 @@ def test_get_by_email_id(self):
     def test_get_by_email_id_not_found(self):
         """If a contact is not found by email_id, an exception is raised."""
         ctms = CTMS(mock_interface("GET", 404, {"detail": "Unknown contact_id"}))
-        with self.assertRaises(HTTPError) as context:
+        with self.assertRaises(CTMSNotFoundByEmailIDError) as context:
             ctms.get(email_id="unknown-id")
-        assert context.exception.response.status_code == 404
+        assert context.exception.email_id == "unknown-id"
 
     def test_get_by_token(self):
         """If token is passed, GET /ctms?basket_token={token} is called."""
diff --git a/basket/news/tests/test_tasks.py b/basket/news/tests/test_tasks.py
--- a/basket/news/tests/test_tasks.py
+++ b/basket/news/tests/test_tasks.py
@@ -13,6 +13,7 @@
 import simple_salesforce as sfapi
 from celery.exceptions import Retry
 from mock import ANY, Mock, call, patch
+from requests.exceptions import ConnectionError as RequestsConnectionError
 
 from basket.news.backends.ctms import CTMSNotFoundByAltIDError
 from basket.news.backends.sfdc import SFDCDisabled
@@ -1013,13 +1014,11 @@ def test_retry_task(self, info):
 
 
 class ETTaskTests(TestCase):
-    @patch("basket.news.tasks.exponential_backoff")
-    def test_retry_increase(self, mock_backoff):
+    def _test_retry_increase(self, mock_backoff, error):
         """
         The delay for retrying a task should increase geometrically by a
         power of 2. I really hope I said that correctly.
         """
-        error = URLError(reason=Exception("foo bar!"))
 
         @et_task
         def myfunc():
@@ -1034,6 +1033,16 @@ def myfunc():
         mock_backoff.assert_called_with(4)
         myfunc.retry.assert_called_with(countdown=mock_backoff())
 
+    @patch("basket.news.tasks.exponential_backoff")
+    def test_urlerror(self, mock_backoff):
+        self._test_retry_increase(mock_backoff, URLError(reason=Exception("foo bar!")))
+
+    @patch("basket.news.tasks.exponential_backoff")
+    def test_requests_connection_error(self, mock_backoff):
+        self._test_retry_increase(
+            mock_backoff, RequestsConnectionError("Connection aborted.")
+        )
+
 
 class AddFxaActivityTests(TestCase):
     def _base_test(self, user_agent=False, fxa_id="123", first_device=True):