Skip to content

Add retry support for Celery email tasks - #687

Open
tanishka-kuwar wants to merge 1 commit into
Kuldeeep18:mainfrom
tanishka-kuwar:fix/461-celery-email-retries
Open

Add retry support for Celery email tasks#687
tanishka-kuwar wants to merge 1 commit into
Kuldeeep18:mainfrom
tanishka-kuwar:fix/461-celery-email-retries

Conversation

@tanishka-kuwar

@tanishka-kuwar tanishka-kuwar commented Jul 19, 2026

Copy link
Copy Markdown

Pull Request

🔗 Related Issue

Closes #461


📝 Summary of Changes

This PR improves the reliability of Celery email tasks by adding retry support.

Changes made:

  • Added bind=True to the Celery task.
  • Enabled acks_late=True.
  • Configured max_retries=3.
  • Configured default_retry_delay=900 (15 minutes).
  • Added retry handling using self.retry(...).
  • Preserved the existing next_execution_time scheduling behavior.
  • Updated the related unit test.

🏷️ Type of Change

  • 🐛 Bug fix
  • ✨ New feature
  • ♻️ Refactor
  • 📝 Documentation update
  • 🎨 UI / Style change
  • 🔧 Chore

🧪 Testing

Tested the changes by running the campaign test suite.

Steps to test:

  1. Navigate to the backend directory.
  2. Run python manage.py test campaigns.
  3. Verify that all tests pass successfully.

N/A (Backend change)


✅ Checklist

  • No merge conflicts
  • Changes follow the project guidelines
  • Documentation updated (not applicable)
  • Related issue linked
  • Changes tested locally

Summary by CodeRabbit

  • Bug Fixes
    • Improved email delivery reliability by automatically retrying failed sends up to three times.
    • Prevented campaign leads from advancing when email delivery fails or all retries are exhausted.
    • Scheduled the next delivery attempt after a temporary failure.

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 07d924d5-d2ac-4a45-899f-677438297ad0

📥 Commits

Reviewing files that changed from the base of the PR and between 4a33158 and 1b39d9c.

📒 Files selected for processing (3)
  • .gitignore
  • backend/campaigns/tasks.py
  • backend/campaigns/tests.py

📝 Walkthrough

Walkthrough

The email-sending Celery task now retries transient failures up to three times, acknowledges work late, records the next execution time, and preserves retry propagation. Its failure test calls the task implementation directly, and .gitignore explicitly ignores the virtual-environment directory.

Changes

Email task retry handling

Layer / File(s) Summary
Configure and execute email retries
backend/campaigns/tasks.py, backend/campaigns/tests.py
send_email_step is now a bound Celery task with late acknowledgements and retry limits. Failures call self.retry(...), exhausted retries are logged, retry exceptions propagate, and the failure test invokes .run(...) directly.

Environment ignore pattern

Layer / File(s) Summary
Correct virtual-environment ignore entry
.gitignore
The virtual-environment entry now explicitly targets the .venv/ directory.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

  • #490 — The change directly adds Celery retry handling for failed email campaign steps.

Suggested reviewers: ramyacm23

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning Retries and acks_late were added, but the PR misses exponential backoff, EmailLog failure tracking, and campaign failed-send counts required by #461. Implement the missing backoff schedule, persist failed sends in EmailLog, and surface failed-send counts in the campaign dashboard.
Out of Scope Changes check ⚠️ Warning The .gitignore tweak is unrelated to Celery email retry support and appears outside the linked issue scope. Remove the .gitignore change or split it into a separate cleanup PR.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: adding retry support for Celery email tasks.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/campaigns/tasks.py (1)

577-588: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Allow broker redeliveries to bypass the next_execution_time null check.

While the newly added acks_late=True parameter ensures that tasks are re-queued if a worker crashes, the atomic guard here will silently drop those redelivered tasks.

If a worker crashes after line 583 but before successfully sending the email, next_execution_time remains None. When Celery subsequently redelivers the unacknowledged task, this exact query will fail to match because next_execution_time is null, leaving the lead permanently stuck.

You must bypass the isnull=False check for broker redeliveries to ensure crashed tasks are actually resumed.

💡 Proposed fix to respect `acks_late` redeliveries
-        # Atomic guard: claim this send by nullifying next_execution_time.
-        # Only one concurrent caller can win; prevents duplicate sends.
-        claimed = CampaignLead.objects.filter(
-            id=campaign_lead_id,
-            current_step_id=step_id,
-            next_execution_time__isnull=False,
-        ).update(next_execution_time=None)
+        # Atomic guard: claim this send by nullifying next_execution_time.
+        # Allow Celery broker redeliveries (e.g. after a worker crash) to bypass the null check.
+        is_redelivered = self.request.delivery_info and self.request.delivery_info.get('redelivered')
+        
+        filter_kwargs = {
+            "id": campaign_lead_id,
+            "current_step_id": step_id,
+        }
+        if not is_redelivered:
+            filter_kwargs["next_execution_time__isnull"] = False
+
+        claimed = CampaignLead.objects.filter(**filter_kwargs).update(next_execution_time=None)
         if not claimed:
             logger.info(
                 f"Skipping duplicate send for {clead.lead.email} on step {step.step_order}"
             )
             return
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/campaigns/tasks.py` around lines 577 - 588, Update the atomic claim
logic around CampaignLead.objects.filter in the task to distinguish broker
redeliveries from fresh deliveries and bypass the
next_execution_time__isnull=False condition for redelivered tasks. Preserve the
existing guard for fresh tasks so concurrent sends remain deduplicated, while
allowing an acks_late redelivery to reclaim a lead whose next_execution_time is
already null.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.gitignore:
- Line 15: Replace the NUL-padded entry in .gitignore with a plain `.venv/`
pattern, preserving the intended virtualenv ignore rule.

In `@backend/campaigns/tasks.py`:
- Around line 626-637: Update the retry handling around self.retry and
MaxRetriesExceededError to use exponential countdowns of 1, 2, and 4 minutes
based on the current retry attempt instead of the fixed 15-minute
next_execution_time. When MaxRetriesExceededError occurs, place clead in the
established terminal failure status and clear or disable next_execution_time so
polling cannot enqueue it again. Record the permanent failure in EmailLog with
the relevant lead, error, and failure context.

In `@backend/campaigns/tests.py`:
- Around line 677-678: Update the test around send_email_step.run to wrap the
direct task invocation in assertRaises for celery.exceptions.Retry, while
preserving the existing send_gmail patch and subsequent database-state
assertions.

---

Outside diff comments:
In `@backend/campaigns/tasks.py`:
- Around line 577-588: Update the atomic claim logic around
CampaignLead.objects.filter in the task to distinguish broker redeliveries from
fresh deliveries and bypass the next_execution_time__isnull=False condition for
redelivered tasks. Preserve the existing guard for fresh tasks so concurrent
sends remain deduplicated, while allowing an acks_late redelivery to reclaim a
lead whose next_execution_time is already null.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 07d924d5-d2ac-4a45-899f-677438297ad0

📥 Commits

Reviewing files that changed from the base of the PR and between 4a33158 and 1b39d9c.

📒 Files selected for processing (3)
  • .gitignore
  • backend/campaigns/tasks.py
  • backend/campaigns/tests.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/campaigns/tasks.py (1)

577-588: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Allow broker redeliveries to bypass the next_execution_time null check.

While the newly added acks_late=True parameter ensures that tasks are re-queued if a worker crashes, the atomic guard here will silently drop those redelivered tasks.

If a worker crashes after line 583 but before successfully sending the email, next_execution_time remains None. When Celery subsequently redelivers the unacknowledged task, this exact query will fail to match because next_execution_time is null, leaving the lead permanently stuck.

You must bypass the isnull=False check for broker redeliveries to ensure crashed tasks are actually resumed.

💡 Proposed fix to respect `acks_late` redeliveries
-        # Atomic guard: claim this send by nullifying next_execution_time.
-        # Only one concurrent caller can win; prevents duplicate sends.
-        claimed = CampaignLead.objects.filter(
-            id=campaign_lead_id,
-            current_step_id=step_id,
-            next_execution_time__isnull=False,
-        ).update(next_execution_time=None)
+        # Atomic guard: claim this send by nullifying next_execution_time.
+        # Allow Celery broker redeliveries (e.g. after a worker crash) to bypass the null check.
+        is_redelivered = self.request.delivery_info and self.request.delivery_info.get('redelivered')
+        
+        filter_kwargs = {
+            "id": campaign_lead_id,
+            "current_step_id": step_id,
+        }
+        if not is_redelivered:
+            filter_kwargs["next_execution_time__isnull"] = False
+
+        claimed = CampaignLead.objects.filter(**filter_kwargs).update(next_execution_time=None)
         if not claimed:
             logger.info(
                 f"Skipping duplicate send for {clead.lead.email} on step {step.step_order}"
             )
             return
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/campaigns/tasks.py` around lines 577 - 588, Update the atomic claim
logic around CampaignLead.objects.filter in the task to distinguish broker
redeliveries from fresh deliveries and bypass the
next_execution_time__isnull=False condition for redelivered tasks. Preserve the
existing guard for fresh tasks so concurrent sends remain deduplicated, while
allowing an acks_late redelivery to reclaim a lead whose next_execution_time is
already null.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.gitignore:
- Line 15: Replace the NUL-padded entry in .gitignore with a plain `.venv/`
pattern, preserving the intended virtualenv ignore rule.

In `@backend/campaigns/tasks.py`:
- Around line 626-637: Update the retry handling around self.retry and
MaxRetriesExceededError to use exponential countdowns of 1, 2, and 4 minutes
based on the current retry attempt instead of the fixed 15-minute
next_execution_time. When MaxRetriesExceededError occurs, place clead in the
established terminal failure status and clear or disable next_execution_time so
polling cannot enqueue it again. Record the permanent failure in EmailLog with
the relevant lead, error, and failure context.

In `@backend/campaigns/tests.py`:
- Around line 677-678: Update the test around send_email_step.run to wrap the
direct task invocation in assertRaises for celery.exceptions.Retry, while
preserving the existing send_gmail patch and subsequent database-state
assertions.

---

Outside diff comments:
In `@backend/campaigns/tasks.py`:
- Around line 577-588: Update the atomic claim logic around
CampaignLead.objects.filter in the task to distinguish broker redeliveries from
fresh deliveries and bypass the next_execution_time__isnull=False condition for
redelivered tasks. Preserve the existing guard for fresh tasks so concurrent
sends remain deduplicated, while allowing an acks_late redelivery to reclaim a
lead whose next_execution_time is already null.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 07d924d5-d2ac-4a45-899f-677438297ad0

📥 Commits

Reviewing files that changed from the base of the PR and between 4a33158 and 1b39d9c.

📒 Files selected for processing (3)
  • .gitignore
  • backend/campaigns/tasks.py
  • backend/campaigns/tests.py
🛑 Comments failed to post (3)
.gitignore (1)

15-15: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

python - <<'PY'
from pathlib import Path

line = Path(".gitignore").read_bytes().splitlines()[14]
print(line)
assert line == b".venv/", f"Unexpected bytes: {line!r}"
PY

Repository: Kuldeeep18/LeadOrbit

Length of output: 317


Replace the NUL-padded .gitignore entry with plain .venv/. .gitignore:15 contains embedded NUL bytes (.v\0e\0n\0v\0/), so the ignore rule won’t match the virtualenv directory.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.gitignore at line 15, Replace the NUL-padded entry in .gitignore with a
plain `.venv/` pattern, preserving the intended virtualenv ignore rule.
backend/campaigns/tasks.py (1)

626-637: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Implement exponential backoff, prevent infinite retry loops, and record permanent failures.

The current error handling has two major flaws:

  1. Infinite Loop via Polling: When MaxRetriesExceededError is caught, the method returns while leaving next_execution_time in the future (set on line 626). The background database polling loop will pick up this lead 15 minutes later and enqueue a new Celery task, bypassing the max_retries limit entirely and causing an infinite loop.
  2. Missing PR Objectives: The code uses a static 15-minute delay instead of the requested "exponential retry delays of 1, 2, and 4 minutes", and it fails to record permanent failures in EmailLog as specified in Issue #461.

Ensure the lead is placed into a terminal status on max retries, calculate the correct countdown, and implement the EmailLog requirement.

💡 Proposed fix
-                clead.next_execution_time = timezone.now() + timedelta(minutes=15)
-                clead.save(update_fields=["next_execution_time"])
-
-                try:
-                    self.retry(exc=send_err)
-
-                except MaxRetriesExceededError:
-                    logger.error(
-                        f"Max retries exceeded for {clead.lead.email}"
-                    )
+                # Exponential backoff: 1, 2, 4 minutes
+                delay_minutes = 2 ** self.request.retries
+                
+                # Fallback guard: ensures the database polling loop won't race the Celery retry execution
+                clead.next_execution_time = timezone.now() + timedelta(minutes=delay_minutes + 5)
+                clead.save(update_fields=["next_execution_time"])
+
+                try:
+                    self.retry(exc=send_err, countdown=delay_minutes * 60)
+                except MaxRetriesExceededError:
+                    logger.error(f"Max retries exceeded for {clead.lead.email}")
+                    # Permanently fail the lead to prevent the polling loop from blindly repeating it
+                    clead.status = 'FAILED'  # Or the appropriate error status
+                    clead.next_execution_time = None
+                    clead.save(update_fields=['status', 'next_execution_time'])
+                    # TODO: Implement EmailLog creation for permanently failed sends as per `#461`
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/campaigns/tasks.py` around lines 626 - 637, Update the retry handling
around self.retry and MaxRetriesExceededError to use exponential countdowns of
1, 2, and 4 minutes based on the current retry attempt instead of the fixed
15-minute next_execution_time. When MaxRetriesExceededError occurs, place clead
in the established terminal failure status and clear or disable
next_execution_time so polling cannot enqueue it again. Record the permanent
failure in EmailLog with the relevant lead, error, and failure context.
backend/campaigns/tests.py (1)

677-678: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Catch the Retry exception to prevent test crashes.

Because send_email_step.run() is called directly without a Celery worker, self.retry() will raise a celery.exceptions.Retry exception into the test scope. This unhandled exception will cause the test to crash before any assertions are executed.

Wrap the execution in an assertRaises block so the test can safely proceed to verify the database state.

💡 Proposed fix
+        from celery.exceptions import Retry
+
         with patch('campaigns.tasks.send_gmail', side_effect=Exception('gmail disabled')):
-            send_email_step.run(campaign_lead.id, email_step.id)
+            with self.assertRaises(Retry):
+                send_email_step.run(campaign_lead.id, email_step.id)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

        from celery.exceptions import Retry

        with patch('campaigns.tasks.send_gmail', side_effect=Exception('gmail disabled')):
            with self.assertRaises(Retry):
                send_email_step.run(campaign_lead.id, email_step.id)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/campaigns/tests.py` around lines 677 - 678, Update the test around
send_email_step.run to wrap the direct task invocation in assertRaises for
celery.exceptions.Retry, while preserving the existing send_gmail patch and
subsequent database-state assertions.

@tanishka-kuwar

Copy link
Copy Markdown
Author

Hi @Kuldeeep18, could you please take a look at my code when you get a chance? I’ve added retry support for the Celery email tasks and updated the unit tests. Let me know if you have any feedback or suggestions!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Performance] Celery email tasks have no max_retries or dead-letter handling - failed sends vanish silently

1 participant