Skip to content

Commit db4583c

Browse files
rcmerlombanting
andauthored
feat: Update scheduled retry mechanism for EHR (M2-9394) (#1884)
* feat: Update scheduled retry mechanism for EHR (M2-9394) Adds a scheduled retry mechanism to the task to improve reliability. This change introduces a function to check if a task should be retried based on a retry time stored in the task context. This prevents tasks from being retried immediately after a failure and allows for delayed retries. The retry time is calculated using an exponential backoff strategy, and the task is requeued if the current time is before the retry time. * fix: uncomment & simplify test_task_retries_on_connection_error test * chore: fix formatting * chore: add back user_id --------- Co-authored-by: Marty <2614025+mbanting@users.noreply.github.com>
1 parent bc8dda7 commit db4583c

2 files changed

Lines changed: 119 additions & 64 deletions

File tree

src/apps/integrations/oneup_health/service/task.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import random
22
import uuid
33
from contextlib import asynccontextmanager
4-
from datetime import datetime
4+
from datetime import datetime, timedelta, timezone
5+
from typing import Annotated
56

67
import httpx
8+
from taskiq import Context, TaskiqDepends
79

810
from apps.answers.crud.answers import AnswersEHRCRUD
911
from apps.answers.deps.preprocess_arbitrary import get_answer_session, preprocess_arbitrary_url
@@ -136,10 +138,11 @@ async def _schedule_retry(
136138
delay = _exponential_backoff(retry_count)
137139
if delay > 0:
138140
retry_count += 1
139-
logger.info(f"Scheduling retry #{retry_count} in {delay} seconds")
141+
retry_time = datetime.now(tz=timezone.utc) + timedelta(seconds=delay)
142+
logger.info(f"Scheduling retry #{retry_count} at {retry_time}")
140143
await (
141144
task_ingest_user_data.kicker()
142-
.with_labels(delay=delay)
145+
.with_labels(delay=60, retry_time=retry_time.isoformat())
143146
.kiq(
144147
user_id=user_id,
145148
target_subject_id=target_subject_id,
@@ -155,6 +158,24 @@ async def _schedule_retry(
155158
return delay > 0
156159

157160

161+
async def _check_retry_time(context: Annotated[Context, TaskiqDepends()]) -> bool:
162+
"""
163+
Determine if the task should be retried based on the context.
164+
165+
Args:
166+
context (Annotated[Context, TaskiqDepends()]): The context provided by Taskiq
167+
168+
Returns:
169+
bool: True if the task should be retried, False otherwise
170+
"""
171+
retry_time = context.message.labels.get("retry_time")
172+
if retry_time and datetime.fromisoformat(retry_time) > datetime.now(tz=timezone.utc):
173+
await context.requeue()
174+
return False
175+
176+
return True
177+
178+
158179
@broker.task
159180
async def task_ingest_user_data(
160181
user_id: uuid.UUID,
@@ -165,6 +186,7 @@ async def task_ingest_user_data(
165186
start_date: datetime | None = None,
166187
retry_count: int = 0,
167188
failed_attempts: int = 0,
189+
check_retry_time: Annotated[bool, TaskiqDepends(_check_retry_time)] = True, # ignore:unused argument
168190
) -> str | None:
169191
"""
170192
Asynchronous task to ingest user health data from OneUp Health.
@@ -181,6 +203,7 @@ async def task_ingest_user_data(
181203
start_date (datetime, optional): The start date of the transfer process
182204
retry_count (int): The current retry attempt count
183205
failed_attempts (int): The current error retry attempt count
206+
check_retry_time (bool): Flag to check if the task should be retried
184207
185208
Returns:
186209
list | None: List of retrieved resources if successful, None otherwise

src/apps/integrations/oneup_health/tests/test_task.py

Lines changed: 93 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import re
22
import uuid
3+
from datetime import datetime, timedelta, timezone
34
from unittest.mock import AsyncMock, MagicMock, patch
45

56
import pytest
@@ -9,7 +10,6 @@
910
from apps.answers.domain import EHRIngestionStatus
1011
from apps.applets.domain.applet_full import AppletFull
1112
from apps.integrations.oneup_health.service.task import task_ingest_user_data
12-
from broker import broker
1313

1414

1515
class TestTaskIngestUserData:
@@ -394,43 +394,50 @@ async def test_schedule_retry(self, applet_one: AppletFull):
394394
target_subject_id = uuid.uuid4()
395395
user_id = uuid.uuid4()
396396

397-
with patch("apps.integrations.oneup_health.service.task.task_ingest_user_data") as mock_task:
398-
kicker = MagicMock()
399-
with_labels = MagicMock()
400-
kiq = AsyncMock()
401-
402-
mock_task.kicker.return_value = kicker
403-
kicker.with_labels.return_value = with_labels
404-
with_labels.kiq = kiq
405-
406-
with patch(
407-
"apps.integrations.oneup_health.service.task._exponential_backoff",
408-
return_value=10,
409-
) as mock_backoff:
410-
await _schedule_retry(
411-
user_id=user_id,
412-
target_subject_id=target_subject_id,
413-
applet_id=applet_one.id,
414-
submit_id=submit_id,
415-
activity_id=applet_one.activities[0].id,
416-
start_date=start_date,
417-
retry_count=retry_count,
418-
failed_attempts=failed_attempts,
419-
)
420-
421-
mock_backoff.assert_called_once_with(retry_count)
422-
mock_task.kicker.assert_called_once()
423-
kicker.with_labels.assert_called_once_with(delay=10)
424-
kiq.assert_awaited_once_with(
425-
user_id=user_id,
426-
target_subject_id=target_subject_id,
427-
applet_id=applet_one.id,
428-
submit_id=submit_id,
429-
activity_id=applet_one.activities[0].id,
430-
start_date=start_date,
431-
retry_count=retry_count + 1,
432-
failed_attempts=failed_attempts,
433-
)
397+
with patch("apps.integrations.oneup_health.service.task.datetime") as mock_datetime:
398+
mock_retry_time = datetime(2025, 6, 16, 12, 0, 10) # Fixed time for testing
399+
mock_datetime.now.return_value = mock_retry_time
400+
401+
retry_time = mock_retry_time + timedelta(seconds=10)
402+
403+
with patch("apps.integrations.oneup_health.service.task.task_ingest_user_data") as mock_task:
404+
kicker = MagicMock()
405+
with_labels = MagicMock()
406+
kiq = AsyncMock()
407+
408+
mock_task.kicker.return_value = kicker
409+
kicker.with_labels.return_value = with_labels
410+
with_labels.kiq = kiq
411+
412+
with patch(
413+
"apps.integrations.oneup_health.service.task._exponential_backoff",
414+
return_value=10,
415+
) as mock_backoff:
416+
await _schedule_retry(
417+
user_id=user_id,
418+
target_subject_id=target_subject_id,
419+
applet_id=applet_one.id,
420+
submit_id=submit_id,
421+
activity_id=applet_one.activities[0].id,
422+
start_date=start_date,
423+
retry_count=retry_count,
424+
failed_attempts=failed_attempts,
425+
)
426+
427+
mock_backoff.assert_called_once_with(retry_count)
428+
mock_task.kicker.assert_called_once()
429+
430+
kicker.with_labels.assert_called_once_with(delay=60, retry_time=retry_time.isoformat())
431+
kiq.assert_awaited_once_with(
432+
user_id=user_id,
433+
target_subject_id=target_subject_id,
434+
applet_id=applet_one.id,
435+
submit_id=submit_id,
436+
activity_id=applet_one.activities[0].id,
437+
start_date=start_date,
438+
retry_count=retry_count + 1,
439+
failed_attempts=failed_attempts,
440+
)
434441

435442
@pytest.mark.asyncio
436443
async def test_task_retries_on_connection_error(self, applet_one: AppletFull):
@@ -447,26 +454,51 @@ async def test_task_retries_on_connection_error(self, applet_one: AppletFull):
447454
target_subject_id = uuid.uuid4()
448455
user_id = uuid.uuid4()
449456

450-
with patch(
451-
"apps.answers.crud.answers.AnswersEHRCRUD.upsert",
452-
new=AsyncMock(side_effect=httpx.RequestError("Connection error")),
453-
):
454-
submit_id = uuid.uuid4()
455-
with patch.object(task_module, "_schedule_retry", wraps=task_module._schedule_retry) as mock_retry:
456-
task = await task_ingest_user_data.kicker().kiq(
457-
user_id=user_id,
458-
target_subject_id=target_subject_id,
459-
applet_id=applet_one.id,
460-
submit_id=submit_id,
461-
activity_id=applet_one.activities[0].id,
462-
)
463-
result = await task.wait_result()
464-
# The result should be None due to the connection error
465-
assert result.return_value is None
466-
467-
# Wait for all scheduled retries to be invoked
468-
# This is necessary because the retry function is asynchronous, and we need to give it time to execute.
469-
await broker.wait_all() # type: ignore
470-
471-
# The function should have been retried a total of 4 times, so the retry function is called 5 times.
472-
assert mock_retry.call_count == 5
457+
# Mock the exponential backoff to simplify and return a constant value
458+
# so that retries are scheduled consistently
459+
with patch("apps.integrations.oneup_health.service.task._exponential_backoff", return_value=1):
460+
with patch("apps.integrations.oneup_health.service.task.datetime") as mock_datetime:
461+
mock_now = datetime(2025, 6, 16, 12, 0, 0, tzinfo=timezone.utc)
462+
mock_datetime.now.return_value = mock_now
463+
464+
with patch("apps.integrations.oneup_health.service.task.task_ingest_user_data.kicker") as mock_kicker:
465+
kicker = MagicMock()
466+
with_labels = MagicMock()
467+
kiq = AsyncMock()
468+
469+
mock_kicker.return_value = kicker
470+
kicker.with_labels.return_value = with_labels
471+
with_labels.kiq = kiq
472+
473+
with patch.object(task_module, "_check_retry_time", return_value=True):
474+
with patch(
475+
"apps.answers.crud.answers.AnswersEHRCRUD.upsert",
476+
new=AsyncMock(side_effect=httpx.RequestError("Connection error")),
477+
):
478+
submit_id = uuid.uuid4()
479+
with patch.object(
480+
task_module, "_schedule_retry", wraps=task_module._schedule_retry
481+
) as mock_retry:
482+
# Trigger the initial task
483+
task = await task_ingest_user_data(
484+
user_id=user_id,
485+
target_subject_id=target_subject_id,
486+
applet_id=applet_one.id,
487+
submit_id=submit_id,
488+
activity_id=applet_one.activities[0].id,
489+
)
490+
491+
# The result should be None due to the connection error
492+
assert task is None
493+
494+
# Verify that _schedule_retry was called once for the initial error
495+
assert mock_retry.call_count == 1
496+
497+
# Verify that the task was scheduled with the correct parameters
498+
kicker.with_labels.assert_called_with(
499+
delay=60, retry_time=(mock_now + timedelta(seconds=1)).isoformat()
500+
)
501+
502+
# For each retry, we would need to manually simulate the retry process
503+
# In a real scenario, we would have 5 calls total (1 initial + 4 retries)
504+
# But in this test, we're just verifying the initial scheduling works correctly

0 commit comments

Comments
 (0)