Skip to content

Commit ad87f20

Browse files
fix: move email related vars to scheduled_tasks/email_retry
1 parent 7e43792 commit ad87f20

File tree

3 files changed

+78
-65
lines changed

3 files changed

+78
-65
lines changed

scheduled_tasks/email_retry.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import random
2+
from datetime import datetime, timedelta, timezone
3+
4+
from botocore.exceptions import BotoCoreError, ClientError
5+
6+
from db.models import EmailNotification
7+
8+
EMAIL_QUEUE_BATCH_SIZE = 25
9+
EMAIL_MAX_ATTEMPTS = 2
10+
EMAIL_RETRY_DELAY_MIN_SECONDS = 15 * 60
11+
EMAIL_RETRY_DELAY_MAX_SECONDS = 30 * 60
12+
EMAIL_RETRY_WINDOW_SECONDS = 60 * 60
13+
EMAIL_JOB_ID_PREFIX = "email_notification_"
14+
15+
TRANSIENT_CLIENT_ERROR_CODES = {
16+
"Throttling",
17+
"ThrottlingException",
18+
"TooManyRequestsException",
19+
"ServiceUnavailable",
20+
"InternalFailure",
21+
"InternalError",
22+
"RequestThrottled",
23+
}
24+
25+
26+
def _ensure_aware(dt: datetime | None) -> datetime | None:
27+
if dt is None:
28+
return None
29+
if dt.tzinfo is None:
30+
return dt.replace(tzinfo=timezone.utc)
31+
return dt
32+
33+
34+
def retry_deadline(notification: EmailNotification) -> datetime | None:
35+
first_attempt = _ensure_aware(notification.last_attempt_at)
36+
if first_attempt is None:
37+
return None
38+
return first_attempt + timedelta(seconds=EMAIL_RETRY_WINDOW_SECONDS)
39+
40+
41+
def can_schedule_notification(notification: EmailNotification, now: datetime) -> bool:
42+
if notification.attempts >= EMAIL_MAX_ATTEMPTS:
43+
return False
44+
deadline = retry_deadline(notification)
45+
return deadline is None or now < deadline
46+
47+
48+
def is_retryable_email_error(exc: Exception) -> bool:
49+
if isinstance(exc, ClientError):
50+
code = exc.response.get("Error", {}).get("Code")
51+
return code in TRANSIENT_CLIENT_ERROR_CODES
52+
if isinstance(exc, BotoCoreError):
53+
return True
54+
if isinstance(exc, OSError):
55+
return True
56+
return False
57+
58+
59+
def next_retry_delay_seconds() -> int:
60+
return random.randint(EMAIL_RETRY_DELAY_MIN_SECONDS, EMAIL_RETRY_DELAY_MAX_SECONDS)

scheduled_tasks/tasks.py

Lines changed: 14 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import random
21
import re
32
from datetime import datetime, timedelta, timezone
43
from uuid import UUID
54

6-
from botocore.exceptions import BotoCoreError, ClientError
75
from loguru import logger
86
from pydantic import BaseModel
97
from sqlalchemy import and_, or_
@@ -25,6 +23,16 @@
2523
)
2624
from db.setup import get_db_session
2725
from db.types import GROUP_NAMES, ApprovalStatusEnum, EmailStatusEnum, GroupEnum
26+
from scheduled_tasks.email_retry import (
27+
EMAIL_JOB_ID_PREFIX,
28+
EMAIL_MAX_ATTEMPTS,
29+
EMAIL_QUEUE_BATCH_SIZE,
30+
EMAIL_RETRY_WINDOW_SECONDS,
31+
can_schedule_notification,
32+
is_retryable_email_error,
33+
next_retry_delay_seconds,
34+
retry_deadline,
35+
)
2836
from scheduled_tasks.scheduler import SCHEDULER
2937
from schemas.auth0 import (
3038
GROUP_ROLE_PATTERN,
@@ -33,61 +41,6 @@
3341
)
3442
from schemas.biocommons import Auth0UserData
3543

36-
EMAIL_QUEUE_BATCH_SIZE = 25
37-
EMAIL_MAX_ATTEMPTS = 2
38-
EMAIL_RETRY_DELAY_MIN_SECONDS = 15 * 60
39-
EMAIL_RETRY_DELAY_MAX_SECONDS = 30 * 60
40-
EMAIL_RETRY_WINDOW_SECONDS = 60 * 60
41-
EMAIL_JOB_ID_PREFIX = "email_notification_"
42-
43-
TRANSIENT_CLIENT_ERROR_CODES = {
44-
"Throttling",
45-
"ThrottlingException",
46-
"TooManyRequestsException",
47-
"ServiceUnavailable",
48-
"InternalFailure",
49-
"InternalError",
50-
"RequestThrottled",
51-
}
52-
53-
54-
def _ensure_aware(dt: datetime | None) -> datetime | None:
55-
if dt is None:
56-
return None
57-
if dt.tzinfo is None:
58-
return dt.replace(tzinfo=timezone.utc)
59-
return dt
60-
61-
62-
def _retry_deadline(notification: EmailNotification) -> datetime | None:
63-
first_attempt = _ensure_aware(notification.last_attempt_at)
64-
if first_attempt is None:
65-
return None
66-
return first_attempt + timedelta(seconds=EMAIL_RETRY_WINDOW_SECONDS)
67-
68-
69-
def _can_schedule_now(notification: EmailNotification, now: datetime) -> bool:
70-
if notification.attempts >= EMAIL_MAX_ATTEMPTS:
71-
return False
72-
deadline = _retry_deadline(notification)
73-
return deadline is None or now < deadline
74-
75-
76-
def _is_retryable_email_error(exc: Exception) -> bool:
77-
if isinstance(exc, ClientError):
78-
code = exc.response.get("Error", {}).get("Code")
79-
return code in TRANSIENT_CLIENT_ERROR_CODES
80-
if isinstance(exc, BotoCoreError):
81-
return True
82-
if isinstance(exc, OSError):
83-
return True
84-
return False
85-
86-
87-
def _next_retry_delay_seconds() -> int:
88-
return random.randint(EMAIL_RETRY_DELAY_MIN_SECONDS, EMAIL_RETRY_DELAY_MAX_SECONDS)
89-
90-
9144
def _ensure_user_from_auth0(session: Session, user_data: Auth0UserData) -> tuple[BiocommonsUser, bool, bool]:
9245
"""
9346
Ensure the Auth0 user exists in the database, creating or restoring if required.
@@ -156,7 +109,7 @@ async def process_email_queue(
156109
return 0
157110
scheduled = 0
158111
for notification in notifications:
159-
if not _can_schedule_now(notification, now):
112+
if not can_schedule_notification(notification, now):
160113
logger.info(
161114
"Skipping email %s: retry window exhausted or max attempts reached",
162115
notification.id,
@@ -205,19 +158,17 @@ async def send_email_notification(
205158
except Exception as exc: # noqa: BLE001
206159
logger.warning("Failed to send email %s: %s", notification.id, exc)
207160
now = datetime.now(timezone.utc)
208-
should_retry = _is_retryable_email_error(exc)
209-
deadline = _retry_deadline(notification)
161+
should_retry = is_retryable_email_error(exc)
162+
deadline = retry_deadline(notification)
210163
if deadline is None:
211164
deadline = now + timedelta(seconds=EMAIL_RETRY_WINDOW_SECONDS)
212-
else:
213-
deadline = _ensure_aware(deadline)
214165
attempts_remaining = notification.attempts < EMAIL_MAX_ATTEMPTS
215166
if (
216167
should_retry
217168
and attempts_remaining
218169
and now < deadline
219170
):
220-
delay_seconds = _next_retry_delay_seconds()
171+
delay_seconds = next_retry_delay_seconds()
221172
retry_time = now + timedelta(seconds=delay_seconds)
222173
if retry_time <= deadline:
223174
notification.schedule_retry(str(exc), retry_time)

tests/scheduled_tasks/test_tasks.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@
1818
PlatformMembershipHistory,
1919
)
2020
from db.types import ApprovalStatusEnum, EmailStatusEnum
21-
from scheduled_tasks.tasks import (
21+
from scheduled_tasks.email_retry import (
2222
EMAIL_MAX_ATTEMPTS,
2323
EMAIL_RETRY_WINDOW_SECONDS,
24+
)
25+
from scheduled_tasks.tasks import (
2426
_ensure_user_from_auth0,
2527
_get_group_membership_including_deleted,
2628
populate_db_groups,
@@ -598,7 +600,7 @@ async def test_send_email_notification_retries_transient_errors(test_db_session,
598600
return_value=iter(lambda: test_db_session, None),
599601
)
600602
mock_scheduler = mocker.patch("scheduled_tasks.tasks.SCHEDULER.add_job")
601-
mocker.patch("scheduled_tasks.tasks._next_retry_delay_seconds", return_value=900)
603+
mocker.patch("scheduled_tasks.tasks.next_retry_delay_seconds", return_value=900)
602604

603605
scheduled = await process_email_queue()
604606

0 commit comments

Comments
 (0)