Skip to content

Commit 5b7b51d

Browse files
committed
send messages retry failure handling
1 parent 7e3dd45 commit 5b7b51d

File tree

6 files changed

+88
-16
lines changed

6 files changed

+88
-16
lines changed

entity_emailer/interface.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1+
from datetime import datetime
12
import json
23
import sys
34
import traceback
45

5-
from datetime import datetime
6-
from django.db import transaction
6+
from ambition_utils.transaction import durable
7+
from django.conf import settings
78
from django.core import mail
9+
from django.db import transaction
810
from entity_event import context_loader
911

1012
from entity_emailer.models import Email
1113
from entity_emailer.signals import pre_send, email_exception
12-
1314
from entity_emailer.utils import get_medium, get_from_email_address, get_subscribed_email_addresses, \
1415
create_email_message, extract_email_subject_from_html_content
1516

@@ -20,6 +21,7 @@ class EntityEmailerInterface(object):
2021
"""
2122

2223
@classmethod
24+
@durable
2325
def send_unsent_scheduled_emails(cls):
2426
"""
2527
Send out any scheduled emails that are unsent
@@ -30,7 +32,8 @@ def send_unsent_scheduled_emails(cls):
3032
email_medium = get_medium()
3133
to_send = Email.objects.filter(
3234
scheduled__lte=current_time,
33-
sent__isnull=True
35+
sent__isnull=True,
36+
num_tries__lt=settings.ENTITY_EMAILER_MAX_SEND_MESSAGE_TRIES
3437
).select_related(
3538
'event__source'
3639
).prefetch_related(
@@ -95,12 +98,13 @@ def send_unsent_scheduled_emails(cls):
9598
try:
9699
# Send mail
97100
connection.send_messages([email.get('message')])
101+
# Update the email model sent value
102+
email_model = email.get('model')
103+
email_model.sent = current_time
104+
email_model.save(update_fields=['sent'])
98105
except Exception as e:
99106
cls.save_email_exception(email.get('model'), e)
100107

101-
# Update the emails as sent
102-
to_send.update(sent=current_time)
103-
104108
@staticmethod
105109
def convert_events_to_emails():
106110
"""
@@ -163,7 +167,8 @@ def save_email_exception(cls, email, e):
163167
exception_message += ': {}'.format(json.dumps(e.to_dict()))
164168

165169
email.exception = exception_message
166-
email.save(update_fields=['exception'])
170+
email.num_tries += 1
171+
email.save(update_fields=['exception', 'num_tries'])
167172

168173
# Fire the email exception event
169174
email_exception.send(
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 2.2.13 on 2021-03-02 21:05
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('entity_emailer', '0003_email_exception'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='email',
15+
name='num_tries',
16+
field=models.IntegerField(default=0),
17+
),
18+
]

entity_emailer/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ class Email(models.Model):
105105
# The time that the email was actually sent, or None if the email is still waiting to be sent
106106
sent = models.DateTimeField(null=True, default=None)
107107

108+
# Number of attempts to send this email message, used only in retry logic
109+
num_tries = models.IntegerField(default=0)
110+
108111
# Any exception that occurred when attempting to send the email last
109112
exception = models.TextField(default=None, null=True)
110113

entity_emailer/tests/interface_tests.py

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django.core import mail
66
from django.core.mail import EmailMultiAlternatives
77
from django.core.management import call_command
8+
from django.db import utils
89
from django.test import TestCase, SimpleTestCase
910
from django.test.utils import override_settings
1011
from django_dynamic_fixture import G
@@ -413,6 +414,7 @@ class SendUnsentScheduledEmailsTest(TestCase):
413414
def setUp(self):
414415
G(Medium, name='email')
415416

417+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
416418
@patch('entity_emailer.interface.get_subscribed_email_addresses')
417419
@patch.object(Event, 'render', spec_set=True)
418420
def test_sends_all_scheduled_emails_no_email_addresses(self, render_mock, address_mock):
@@ -423,6 +425,7 @@ def test_sends_all_scheduled_emails_no_email_addresses(self, render_mock, addres
423425
EntityEmailerInterface.send_unsent_scheduled_emails()
424426
self.assertEqual(len(mail.outbox), 0)
425427

428+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
426429
@patch('entity_emailer.interface.get_subscribed_email_addresses')
427430
@patch.object(Event, 'render', spec_set=True)
428431
def test_sends_all_scheduled_emails(self, render_mock, address_mock):
@@ -436,6 +439,7 @@ def test_sends_all_scheduled_emails(self, render_mock, address_mock):
436439

437440
self.assertEqual(2, mock_connection.return_value.__enter__.return_value.send_messages.call_count)
438441

442+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
439443
@patch('entity_emailer.interface.pre_send')
440444
@patch('entity_emailer.interface.get_subscribed_email_addresses')
441445
@patch.object(Event, 'render', spec_set=True)
@@ -468,6 +472,7 @@ def test_send_signals(self, render_mock, address_mock, mock_pre_send):
468472
})
469473
self.assertIsInstance(kwargs['message'], EmailMultiAlternatives)
470474

475+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
471476
@patch('entity_emailer.interface.get_subscribed_email_addresses')
472477
@patch.object(Event, 'render', spec_set=True)
473478
def test_sends_email_with_specified_from_address(self, render_mock, address_mock):
@@ -482,6 +487,7 @@ def test_sends_email_with_specified_from_address(self, render_mock, address_mock
482487
args = mock_connection.return_value.__enter__.return_value.send_messages.call_args
483488
self.assertEqual(args[0][0][0].from_email, from_address)
484489

490+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
485491
@patch('entity_emailer.interface.get_subscribed_email_addresses')
486492
@patch.object(Event, 'render', spec_set=True)
487493
def test_sends_no_future_emails(self, render_mock, address_mock):
@@ -491,6 +497,7 @@ def test_sends_no_future_emails(self, render_mock, address_mock):
491497
EntityEmailerInterface.send_unsent_scheduled_emails()
492498
self.assertEqual(len(mail.outbox), 0)
493499

500+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
494501
@patch('entity_emailer.interface.get_subscribed_email_addresses')
495502
@patch.object(Event, 'render', spec_set=True)
496503
def test_sends_no_sent_emails(self, render_mock, address_mock):
@@ -500,6 +507,7 @@ def test_sends_no_sent_emails(self, render_mock, address_mock):
500507
EntityEmailerInterface.send_unsent_scheduled_emails()
501508
self.assertEqual(len(mail.outbox), 0)
502509

510+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
503511
@patch('entity_emailer.interface.get_subscribed_email_addresses')
504512
@patch.object(Event, 'render', spec_set=True)
505513
def test_updates_times(self, render_mock, address_mock):
@@ -510,6 +518,7 @@ def test_updates_times(self, render_mock, address_mock):
510518
sent_email = Email.objects.filter(sent__isnull=False)
511519
self.assertEqual(sent_email.count(), 1)
512520

521+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
513522
@patch('entity_emailer.interface.email_exception')
514523
@patch('entity_emailer.interface.get_subscribed_email_addresses')
515524
@patch.object(Event, 'render', spec_set=True)
@@ -535,19 +544,26 @@ def test_exceptions(self, render_mock, address_mock, mock_email_exception):
535544
with patch(settings.EMAIL_BACKEND) as mock_connection:
536545
EntityEmailerInterface.send_unsent_scheduled_emails()
537546

538-
# Assert that both emails were marked as sent
539-
self.assertEqual(Email.objects.filter(sent__isnull=False).count(), 2)
547+
# Assert that only one email was marked as sent
548+
self.assertEqual(Email.objects.filter(sent__isnull=False).count(), 1)
540549

541550
# Assert that only one email is actually sent through backend
542551
self.assertEquals(1, mock_connection.call_count)
543-
# Assert that one email raised an exception
544-
exception_email = Email.objects.get(sent__isnull=False, exception__isnull=False)
552+
# Assert that one email raised an exception and that the tries count was updated
553+
exception_email = Email.objects.get(exception__isnull=False, num_tries=1)
545554
self.assertIsNotNone(exception_email)
546555
self.assertTrue('Exception: test' in exception_email.exception)
547556

557+
def test_send_unsent_emails_is_durable(self):
558+
"""
559+
Verify that the process to send unsent emails is durable and will not be rolled-back on exit from any caller
560+
"""
561+
self.assertRaises(utils.ProgrammingError, EntityEmailerInterface.send_unsent_scheduled_emails)
562+
563+
@override_settings(DISABLE_DURABILITY_CHECKING=True, ENTITY_EMAILER_MAX_SEND_MESSAGE_TRIES=2)
548564
@patch.object(Event, 'render', spec_set=True)
549565
@patch('entity_emailer.interface.get_subscribed_email_addresses')
550-
def test_send_exceptions(self, mock_get_subscribed_addresses, mock_render):
566+
def test_send_exceptions_and_retry(self, mock_get_subscribed_addresses, mock_render):
551567
"""
552568
Verifies that when a single email raises an exception from within the backend, the batch is still
553569
updated as sent and the failed email is saved with the exception
@@ -574,16 +590,44 @@ def to_dict(self):
574590

575591
EntityEmailerInterface.send_unsent_scheduled_emails()
576592

577-
# Verify that both emails are marked as sent
578-
self.assertEquals(2, Email.objects.filter(sent__isnull=False).count())
593+
# Verify that only one email is marked as sent
594+
self.assertEquals(1, Email.objects.filter(sent__isnull=False).count())
579595
# Verify that the failed email was saved with the exception
580-
actual_failed_email = Email.objects.get(sent__isnull=False, exception__isnull=False)
596+
actual_failed_email = Email.objects.get(exception__isnull=False, num_tries=1)
581597
self.assertEquals(failed_email.id, actual_failed_email.id)
582598
self.assertEquals(
583599
'test: {}'.format(json.dumps({'message': 'test'})),
584600
actual_failed_email.exception
585601
)
586602

603+
# Verify that a subsequent attempt to send unscheduled emails will retry the failed email
604+
with patch(settings.EMAIL_BACKEND) as mock_connection:
605+
# Mock side effect for sending email
606+
mock_connection.return_value.__enter__.return_value.send_messages.side_effect = (
607+
TestEmailSendMessageException('test')
608+
)
609+
610+
EntityEmailerInterface.send_unsent_scheduled_emails()
611+
612+
# Verify only called once, with the failed email
613+
self.assertEquals(1, mock_connection.return_value.__enter__.return_value.send_messages.call_count)
614+
615+
# Verify num tries updated
616+
actual_failed_email = Email.objects.get(exception__isnull=False, num_tries=2)
617+
self.assertEquals(failed_email.id, actual_failed_email.id)
618+
619+
# Verify that a subsequent attempt to send unscheduled emails will find no emails to send
620+
with patch(settings.EMAIL_BACKEND) as mock_connection:
621+
622+
EntityEmailerInterface.send_unsent_scheduled_emails()
623+
624+
# Verify only called once, with the failed email
625+
self.assertEquals(0, mock_connection.return_value.__enter__.return_value.send_messages.call_count)
626+
627+
# Verify num tries not updated
628+
actual_failed_email = Email.objects.get(exception__isnull=False, num_tries=2)
629+
self.assertEquals(failed_email.id, actual_failed_email.id)
630+
587631

588632
class CreateEmailObjectTest(TestCase):
589633
def test_no_html(self):

requirements/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ django-db-mutex>=2.0.0
44
django-entity>=5.0.0
55
django-entity-event>=2.0.0
66
ambition-django-uuidfield>=0.5.0
7+
ambition-utils>=2.2.0
78

89
beautifulsoup4>=4.3.2

settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def configure_settings():
3535
raise RuntimeError('Unsupported test DB {0}'.format(test_db))
3636

3737
settings.configure(
38+
ENTITY_EMAILER_MAX_SEND_MESSAGE_TRIES=3,
3839
DATABASES={
3940
'default': db_config,
4041
},

0 commit comments

Comments
 (0)