Skip to content

Commit baab7de

Browse files
authored
Merge pull request #84 from thinkt4nk/chore/send-messages-failure-retry
send messages retry failure handling
2 parents 7e3dd45 + 8c573a5 commit baab7de

File tree

6 files changed

+88
-45
lines changed

6 files changed

+88
-45
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 & 37 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
@@ -378,41 +379,13 @@ def test_multiple_events_only_following_true(self):
378379
self.assertEquals(email.subject, '')
379380
self.assertEquals(email.scheduled, datetime(2013, 1, 2))
380381

381-
@freeze_time('2013-1-2')
382-
def test_bulk_multiple_events_only_following_true(self):
383-
"""
384-
Handles bulk creating events and tests the unique constraint of the duplicated subscription which would cause
385-
a bulk create error if it wasn't handled
386-
"""
387-
source = G(Source)
388-
e = G(Entity)
389-
other_e = G(Entity)
390-
391-
G(Subscription, entity=e, source=source, medium=self.email_medium, only_following=True)
392-
G(Subscription, entity=e, source=source, medium=self.email_medium, only_following=True)
393-
G(Subscription, entity=other_e, source=source, medium=self.email_medium, only_following=True)
394-
email_context = {
395-
'entity_emailer_template': 'template',
396-
'entity_emailer_subject': 'hi',
397-
}
398-
G(Event, source=source, context=email_context)
399-
event = G(Event, source=source, context=email_context)
400-
G(EventActor, event=event, entity=e)
401-
402-
EntityEmailerInterface.bulk_convert_events_to_emails()
403-
404-
email = Email.objects.get()
405-
self.assertEquals(set(email.recipients.all()), set([e]))
406-
self.assertEquals(email.event.context, email_context)
407-
self.assertEquals(email.subject, '')
408-
self.assertEquals(email.scheduled, datetime(2013, 1, 2))
409-
410382

411383
@freeze_time('2014-01-05')
412384
class SendUnsentScheduledEmailsTest(TestCase):
413385
def setUp(self):
414386
G(Medium, name='email')
415387

388+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
416389
@patch('entity_emailer.interface.get_subscribed_email_addresses')
417390
@patch.object(Event, 'render', spec_set=True)
418391
def test_sends_all_scheduled_emails_no_email_addresses(self, render_mock, address_mock):
@@ -423,6 +396,7 @@ def test_sends_all_scheduled_emails_no_email_addresses(self, render_mock, addres
423396
EntityEmailerInterface.send_unsent_scheduled_emails()
424397
self.assertEqual(len(mail.outbox), 0)
425398

399+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
426400
@patch('entity_emailer.interface.get_subscribed_email_addresses')
427401
@patch.object(Event, 'render', spec_set=True)
428402
def test_sends_all_scheduled_emails(self, render_mock, address_mock):
@@ -436,6 +410,7 @@ def test_sends_all_scheduled_emails(self, render_mock, address_mock):
436410

437411
self.assertEqual(2, mock_connection.return_value.__enter__.return_value.send_messages.call_count)
438412

413+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
439414
@patch('entity_emailer.interface.pre_send')
440415
@patch('entity_emailer.interface.get_subscribed_email_addresses')
441416
@patch.object(Event, 'render', spec_set=True)
@@ -468,6 +443,7 @@ def test_send_signals(self, render_mock, address_mock, mock_pre_send):
468443
})
469444
self.assertIsInstance(kwargs['message'], EmailMultiAlternatives)
470445

446+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
471447
@patch('entity_emailer.interface.get_subscribed_email_addresses')
472448
@patch.object(Event, 'render', spec_set=True)
473449
def test_sends_email_with_specified_from_address(self, render_mock, address_mock):
@@ -482,6 +458,7 @@ def test_sends_email_with_specified_from_address(self, render_mock, address_mock
482458
args = mock_connection.return_value.__enter__.return_value.send_messages.call_args
483459
self.assertEqual(args[0][0][0].from_email, from_address)
484460

461+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
485462
@patch('entity_emailer.interface.get_subscribed_email_addresses')
486463
@patch.object(Event, 'render', spec_set=True)
487464
def test_sends_no_future_emails(self, render_mock, address_mock):
@@ -491,6 +468,7 @@ def test_sends_no_future_emails(self, render_mock, address_mock):
491468
EntityEmailerInterface.send_unsent_scheduled_emails()
492469
self.assertEqual(len(mail.outbox), 0)
493470

471+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
494472
@patch('entity_emailer.interface.get_subscribed_email_addresses')
495473
@patch.object(Event, 'render', spec_set=True)
496474
def test_sends_no_sent_emails(self, render_mock, address_mock):
@@ -500,6 +478,7 @@ def test_sends_no_sent_emails(self, render_mock, address_mock):
500478
EntityEmailerInterface.send_unsent_scheduled_emails()
501479
self.assertEqual(len(mail.outbox), 0)
502480

481+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
503482
@patch('entity_emailer.interface.get_subscribed_email_addresses')
504483
@patch.object(Event, 'render', spec_set=True)
505484
def test_updates_times(self, render_mock, address_mock):
@@ -510,6 +489,7 @@ def test_updates_times(self, render_mock, address_mock):
510489
sent_email = Email.objects.filter(sent__isnull=False)
511490
self.assertEqual(sent_email.count(), 1)
512491

492+
@override_settings(DISABLE_DURABILITY_CHECKING=True)
513493
@patch('entity_emailer.interface.email_exception')
514494
@patch('entity_emailer.interface.get_subscribed_email_addresses')
515495
@patch.object(Event, 'render', spec_set=True)
@@ -535,19 +515,26 @@ def test_exceptions(self, render_mock, address_mock, mock_email_exception):
535515
with patch(settings.EMAIL_BACKEND) as mock_connection:
536516
EntityEmailerInterface.send_unsent_scheduled_emails()
537517

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

541521
# Assert that only one email is actually sent through backend
542522
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)
523+
# Assert that one email raised an exception and that the tries count was updated
524+
exception_email = Email.objects.get(exception__isnull=False, num_tries=1)
545525
self.assertIsNotNone(exception_email)
546526
self.assertTrue('Exception: test' in exception_email.exception)
547527

528+
def test_send_unsent_emails_is_durable(self):
529+
"""
530+
Verify that the process to send unsent emails is durable and will not be rolled-back on exit from any caller
531+
"""
532+
self.assertRaises(utils.ProgrammingError, EntityEmailerInterface.send_unsent_scheduled_emails)
533+
534+
@override_settings(DISABLE_DURABILITY_CHECKING=True, ENTITY_EMAILER_MAX_SEND_MESSAGE_TRIES=2)
548535
@patch.object(Event, 'render', spec_set=True)
549536
@patch('entity_emailer.interface.get_subscribed_email_addresses')
550-
def test_send_exceptions(self, mock_get_subscribed_addresses, mock_render):
537+
def test_send_exceptions_and_retry(self, mock_get_subscribed_addresses, mock_render):
551538
"""
552539
Verifies that when a single email raises an exception from within the backend, the batch is still
553540
updated as sent and the failed email is saved with the exception
@@ -574,16 +561,44 @@ def to_dict(self):
574561

575562
EntityEmailerInterface.send_unsent_scheduled_emails()
576563

577-
# Verify that both emails are marked as sent
578-
self.assertEquals(2, Email.objects.filter(sent__isnull=False).count())
564+
# Verify that only one email is marked as sent
565+
self.assertEquals(1, Email.objects.filter(sent__isnull=False).count())
579566
# Verify that the failed email was saved with the exception
580-
actual_failed_email = Email.objects.get(sent__isnull=False, exception__isnull=False)
567+
actual_failed_email = Email.objects.get(exception__isnull=False, num_tries=1)
581568
self.assertEquals(failed_email.id, actual_failed_email.id)
582569
self.assertEquals(
583570
'test: {}'.format(json.dumps({'message': 'test'})),
584571
actual_failed_email.exception
585572
)
586573

574+
# Verify that a subsequent attempt to send unscheduled emails will retry the failed email
575+
with patch(settings.EMAIL_BACKEND) as mock_connection:
576+
# Mock side effect for sending email
577+
mock_connection.return_value.__enter__.return_value.send_messages.side_effect = (
578+
TestEmailSendMessageException('test')
579+
)
580+
581+
EntityEmailerInterface.send_unsent_scheduled_emails()
582+
583+
# Verify only called once, with the failed email
584+
self.assertEquals(1, mock_connection.return_value.__enter__.return_value.send_messages.call_count)
585+
586+
# Verify num tries updated
587+
actual_failed_email = Email.objects.get(exception__isnull=False, num_tries=2)
588+
self.assertEquals(failed_email.id, actual_failed_email.id)
589+
590+
# Verify that a subsequent attempt to send unscheduled emails will find no emails to send
591+
with patch(settings.EMAIL_BACKEND) as mock_connection:
592+
593+
EntityEmailerInterface.send_unsent_scheduled_emails()
594+
595+
# Verify only called once, with the failed email
596+
self.assertEquals(0, mock_connection.return_value.__enter__.return_value.send_messages.call_count)
597+
598+
# Verify num tries not updated
599+
actual_failed_email = Email.objects.get(exception__isnull=False, num_tries=2)
600+
self.assertEquals(failed_email.id, actual_failed_email.id)
601+
587602

588603
class CreateEmailObjectTest(TestCase):
589604
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)