diff --git a/entity_emailer/interface.py b/entity_emailer/interface.py index 6afde87..4ae4246 100644 --- a/entity_emailer/interface.py +++ b/entity_emailer/interface.py @@ -163,8 +163,15 @@ def save_email_exception(cls, email, e): exception_message = str(e) # Duck typing exception for sendgrid api backend rather than place hard dependency + # Actually expecting python_http_client HTTPError here, but we'll support any reasonable interface + # with the same name if hasattr(e, 'to_dict'): - exception_message += ': {}'.format(json.dumps(e.to_dict())) + if callable(e.to_dict): + exception_dict = e.to_dict() + else: + exception_dict = e.to_dict + # Set the exception message to the exception's serialized dump + exception_message += ': {}'.format(json.dumps(exception_dict)) email.exception = exception_message email.num_tries += 1 diff --git a/entity_emailer/tests/interface_tests.py b/entity_emailer/tests/interface_tests.py index 4b6877b..e1a3b44 100644 --- a/entity_emailer/tests/interface_tests.py +++ b/entity_emailer/tests/interface_tests.py @@ -549,6 +549,7 @@ def test_send_exceptions_and_retry(self, mock_get_subscribed_addresses, mock_ren self.assertEquals(2, Email.objects.filter(sent__isnull=True).count()) class TestEmailSendMessageException(Exception): + @property def to_dict(self): return {'message': str(self)} @@ -599,6 +600,46 @@ def to_dict(self): actual_failed_email = Email.objects.get(exception__isnull=False, num_tries=2) self.assertEquals(failed_email.id, actual_failed_email.id) + @override_settings(DISABLE_DURABILITY_CHECKING=True, ENTITY_EMAILER_MAX_SEND_MESSAGE_TRIES=2) + @patch.object(Event, 'render', spec_set=True) + @patch('entity_emailer.interface.get_subscribed_email_addresses') + def test_send_exception_with_to_dict_method(self, mock_get_subscribed_addresses, mock_render): + """ + Verifies that when a single email raises an exception from within the backend, the batch is still + updated as sent and the failed email is saved with the exception + """ + # Create test emails to send + g_email(context={}, scheduled=datetime.min) + failed_email = g_email(context={}, scheduled=datetime.min) + mock_get_subscribed_addresses.return_value = ['test1@example.com'] + mock_render.return_value = ('foo', 'bar',) + + # Verify baseline, namely that both emails are not marked as sent and that neither has an exception saved + self.assertEquals(2, Email.objects.filter(sent__isnull=True).count()) + + class TestEmailSendMessageException(Exception): + def to_dict(self): + return {'message': str(self)} + + with patch(settings.EMAIL_BACKEND) as mock_connection: + # Mock side effects for sending emails + mock_connection.return_value.__enter__.return_value.send_messages.side_effect = [ + None, + TestEmailSendMessageException('test'), + ] + + EntityEmailerInterface.send_unsent_scheduled_emails() + + # Verify that only one email is marked as sent + self.assertEquals(1, Email.objects.filter(sent__isnull=False).count()) + # Verify that the failed email was saved with the exception + actual_failed_email = Email.objects.get(exception__isnull=False, num_tries=1) + self.assertEquals(failed_email.id, actual_failed_email.id) + self.assertEquals( + 'test: {}'.format(json.dumps({'message': 'test'})), + actual_failed_email.exception + ) + class CreateEmailObjectTest(TestCase): def test_no_html(self): diff --git a/entity_emailer/version.py b/entity_emailer/version.py index a33997d..55fa725 100644 --- a/entity_emailer/version.py +++ b/entity_emailer/version.py @@ -1 +1 @@ -__version__ = '2.1.0' +__version__ = '2.1.1' diff --git a/release_notes.rst b/release_notes.rst index e81de7a..17403cd 100644 --- a/release_notes.rst +++ b/release_notes.rst @@ -1,6 +1,10 @@ Release Notes ============= +v2.1.1 +------ +* Fixed error handling with email backend exceptions that implement to_dict + v2.1.0 ------ * More durable handling and retry logic for failures in bulk send unsent emails process