""" 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