- Add _ClassificationError sentinel raised by _run() for classification failures - Add _mark_classification_failed() async helper for final status writeback - Change decorator to bind=True, max_retries=3 - Outer sync task catches _ClassificationError and calls self.retry(countdown=[30,90,270]) - MaxRetriesExceededError caught in nested try/except to avoid re-raise from exc block - Promote test_retry_backoff and test_exhaustion_sets_failed_status from xfail to passing - Tests use push_request(retries=N) + patch.object(task, "retry") to verify countdown values - test_exhaustion_sets_failed_status uses assert_called_once_with (asyncio.run calling convention)
95 lines
3.9 KiB
Python
95 lines
3.9 KiB
Python
"""
|
|
Phase 7 Plan 04 — Celery retry harness tests.
|
|
|
|
Covers:
|
|
- D-09: Celery retry with 30s/90s/270s exponential backoff
|
|
- D-10: After 3 retries doc.status = "classification_failed"
|
|
|
|
Test contract note (asyncio.run + AsyncMock):
|
|
Production code uses asyncio.run(_mark_classification_failed(document_id)).
|
|
asyncio.run() invokes the coroutine object returned by calling the factory:
|
|
_mark_classification_failed(document_id). This records the invocation as a
|
|
__call__ on the mock, NOT as an __await__. Therefore tests must assert via
|
|
assert_called_once_with(), NOT assert_awaited_once_with().
|
|
|
|
Celery bind=True calling convention:
|
|
For bind=True tasks, task.run() is the raw underlying function with self=task.
|
|
We push a fake request context via push_request(retries=N) to simulate retries,
|
|
then patch task.retry to capture the countdown argument rather than letting
|
|
Celery's called_directly path re-raise the original exception.
|
|
"""
|
|
import pytest
|
|
from unittest.mock import patch, AsyncMock
|
|
from celery.exceptions import Retry, MaxRetriesExceededError
|
|
|
|
|
|
def test_retry_backoff():
|
|
"""extract_and_classify raises Retry with countdown 30/90/270 on retries 0/1/2.
|
|
|
|
D-09: exponential backoff harness. The outer sync task must catch
|
|
_ClassificationError and call self.retry(exc=..., countdown=N) where N
|
|
comes from [30, 90, 270][min(self.request.retries, 2)].
|
|
"""
|
|
from tasks.document_tasks import extract_and_classify, _ClassificationError
|
|
|
|
doc_id = "11111111-1111-1111-1111-111111111111"
|
|
expected_countdowns = [30, 90, 270]
|
|
captured_countdowns = []
|
|
|
|
async def fake_run_raises(document_id):
|
|
raise _ClassificationError("fake classification error")
|
|
|
|
def capture_retry(exc=None, countdown=None, **kwargs):
|
|
"""Capture the countdown argument and raise Retry to simulate Celery behaviour."""
|
|
captured_countdowns.append(countdown)
|
|
raise Retry(str(exc), exc)
|
|
|
|
for retry_num in range(3):
|
|
extract_and_classify.push_request(retries=retry_num)
|
|
try:
|
|
with patch("tasks.document_tasks._run", side_effect=fake_run_raises), \
|
|
patch.object(extract_and_classify, "retry", side_effect=capture_retry):
|
|
with pytest.raises(Retry):
|
|
extract_and_classify.run(doc_id)
|
|
finally:
|
|
extract_and_classify.pop_request()
|
|
|
|
assert captured_countdowns == expected_countdowns, (
|
|
f"Expected countdowns {expected_countdowns}, got {captured_countdowns}"
|
|
)
|
|
|
|
|
|
def test_exhaustion_sets_failed_status():
|
|
"""When MaxRetriesExceededError is raised, _mark_classification_failed is called
|
|
and the task returns {'document_id': ..., 'status': 'classification_failed'}.
|
|
|
|
D-10: final state writeback after exhausting all retries.
|
|
|
|
Test contract: assert_called_once_with (not assert_awaited_once_with) because
|
|
asyncio.run(_mark_classification_failed(doc_id)) invokes the factory — that is
|
|
the __call__ path, not the __await__ path, on the AsyncMock.
|
|
"""
|
|
from tasks.document_tasks import extract_and_classify, _ClassificationError
|
|
|
|
doc_id = "22222222-2222-2222-2222-222222222222"
|
|
mock_mark_failed = AsyncMock()
|
|
|
|
async def fake_run_raises(document_id):
|
|
raise _ClassificationError("fake classification error")
|
|
|
|
extract_and_classify.push_request(retries=3)
|
|
try:
|
|
with patch("tasks.document_tasks._run", side_effect=fake_run_raises), \
|
|
patch("tasks.document_tasks._mark_classification_failed", mock_mark_failed), \
|
|
patch.object(extract_and_classify, "retry", side_effect=MaxRetriesExceededError()):
|
|
result = extract_and_classify.run(doc_id)
|
|
finally:
|
|
extract_and_classify.pop_request()
|
|
|
|
# _mark_classification_failed(document_id) was called (factory call, not awaited)
|
|
mock_mark_failed.assert_called_once_with(doc_id)
|
|
|
|
# Return value reports final failure state
|
|
assert result["status"] == "classification_failed"
|
|
assert result["document_id"] == doc_id
|