5 waves: system_settings DB + HKDF encryption (01), ProviderConfig + GenericOpenAIProvider + singleton client fix (02), Anthropic output_config + classifier wiring (03), Celery retry 30/90/270s + re-queue endpoint (04), admin AI panel + DocumentCard badge + human checkpoint (05). All 18 decisions D-01..D-18 covered. Plan checker passed after 1 revision round (4 blockers fixed: D-02/D-14 coverage, D-11 Vitest tests, Plan 05 files_modified). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
18 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07-redo-and-optimize-llm-integration | 04 | execute | 4 |
|
|
true |
|
|
Purpose: Deliver D-09 (30s/90s/270s exponential backoff with max_retries=3), D-10 (final state remains classification_failed), and D-11 backend half (re-classify endpoint re-queues Celery instead of running synchronously). Apply Pitfall 3 from RESEARCH.md — self.retry() must escape the sync task layer, not the inner asyncio.run() — by introducing a _ClassificationError sentinel.
Output: Refactored extract_and_classify task with bind=True, _ClassificationError sentinel, _mark_classification_failed helper, retry-with-countdown logic, and a refactored POST /classify endpoint that calls .delay(); matching tests promoted from xfail.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/phases/07-redo-and-optimize-llm-integration/07-CONTEXT.md @.planning/phases/07-redo-and-optimize-llm-integration/07-RESEARCH.md @.planning/phases/07-redo-and-optimize-llm-integration/07-PATTERNS.md @.planning/phases/07-redo-and-optimize-llm-integration/07-03-SUMMARY.md @backend/tasks/document_tasks.py @backend/api/documents.py @backend/services/classifier.py @backend/tests/test_documents.py @backend/tests/test_document_tasks.pyFrom backend/tasks/document_tasks.py (current):
@celery_app.task(name="tasks.document_tasks.extract_and_classify") def extract_and_classify(document_id: str) -> dict:callsreturn asyncio.run(_run(document_id))async def _run(document_id: str) -> dictperforms UUID parse, DB load, MinIO fetch, extract text, then await classify_document(...)- Current behaviour: on classification exception, sets
doc.status = "classification_failed"and returns{"document_id": ..., "status": "classification_failed"}directly (no retry) - A second task
cleanup_abandoned_uploadsalready follows the asyncio.run bridge pattern
From backend/api/documents.py (current POST /{doc_id}/classify):
- Authenticated route via Depends(get_regular_user); ownership check returns 404 on mismatch (per STATE.md "Cross-user doc access returns 404 not 403")
- Currently
await classifier.classify_document(doc_id=..., session=..., ai_provider=..., ai_model=...)synchronously and returns a dict containing assigned topics - Receives only doc_id and current_user from auth context
Celery retry API (RESEARCH.md Pattern 4):
@celery_app.task(bind=True, max_retries=3, name=...)signature requiresselfas first positional argself.request.retriesreports the current retry attempt (0 on first try)raise self.retry(exc=exc, countdown=N)schedules a retry; raises Retry exceptionself.MaxRetriesExceededErroris the exception type raised by Celery when retries are exhausted
Important — asyncio.run + AsyncMock interaction (test contract):
- Production code calls
asyncio.run(_mark_classification_failed(document_id)). asyncio.run() invokes the coroutine object returned by calling _mark_classification_failed(...); this is a__call__()on the patched function, NOT a__await__(). - An AsyncMock substitute for _mark_classification_failed therefore records the invocation via
mock.call_args/mock.call_count, NOT viaawait_count. Tests must assert viaassert_called_once_with(document_id), notassert_awaited_once_with(...).
Promote backend/tests/test_document_tasks.py::test_retry_backoff: use unittest.mock.patch on `tasks.document_tasks._run` so it raises `_ClassificationError("fake")`; construct a MagicMock `self` with `request.retries` settable; assert that calling `extract_and_classify.run(self, "<uuid>")` raises celery.exceptions.Retry; iterate retries=0,1,2 and assert the Retry's `countdown` attribute equals 30, 90, 270 respectively. (Use celery's `extract_and_classify.run` to bypass the Celery messaging layer and invoke the wrapped function directly.)
Promote test_exhaustion_sets_failed_status: patch `tasks.document_tasks._run` to raise `_ClassificationError`; patch `tasks.document_tasks._mark_classification_failed` with an AsyncMock; configure `self.retry` to raise `MaxRetriesExceededError`; call extract_and_classify.run(self, "<uuid>"); assert `mock_mark_failed.assert_called_once_with(document_id)` — NOT `assert_awaited_once_with`. Rationale: production code is `asyncio.run(_mark_classification_failed(document_id))`. The coroutine factory call (`_mark_classification_failed(document_id)`) is recorded as `call`, not `await`, on the AsyncMock; asyncio.run() then runs the resulting coroutine to completion (which on an AsyncMock returns immediately). Therefore the assertion must inspect `call_args`/`call_count`, not `await_count`. Also assert the return dict has status="classification_failed". Remove the xfail decorators from both tests.
Promote backend/tests/test_documents.py::test_reclassify_requeues_celery: as an authenticated regular user, POST to /api/documents/{doc_id}/classify with monkeypatch on `tasks.document_tasks.extract_and_classify.delay` (a MagicMock). Confirm: response status is 200; response JSON contains status="processing" and document_id matching the doc; mock_delay was called exactly once with str(doc.id); after the request the Document row in DB has status="processing". Use the existing auth_user/admin_user fixtures from conftest.py per STATE.md "Celery mock required in /confirm tests". Remove the xfail decorator.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Authenticated user → POST /classify | doc_id crosses untrusted boundary; ownership re-checked via _get_owned_doc → 404 on mismatch |
| Celery worker process → DB | retry exponential backoff bounded (max_retries=3) — prevents runaway loops |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-07-09 | Denial of Service | Re-classify endpoint enqueue without rate limit | accept | per-user rate-limit infrastructure lives on auth endpoints (Phase 2) and is being expanded by Phase 6; reclassify endpoint is gated by ownership check and inherits Phase 6 per-account limiter once that ships; documenting accepted risk for v1 |
| T-07-10 | Tampering | Cross-user reclassify | mitigate | _get_owned_doc returns 404 on cross-user (matches STATE.md cross-user 404 policy); existing IDOR test in test_documents.py validates this |
| T-07-11 | Information Disclosure | Retry exception payload | accept | _ClassificationError carries only the str(exc); no document content; Celery serializes exc message into the broker but RabbitMQ/Redis is internal-only |
| T-07-12 | Tampering | self.retry mid-asyncio.run | mitigate | Pitfall 3 — retry raised from the outer sync layer, never inside asyncio.run; _ClassificationError sentinel enforces this boundary |
| T-07-SC | Tampering | No new packages | accept | celery already pinned; pip audit re-runs at phase gate |
| </threat_model> |
<success_criteria>
- Celery retry harness operational with 30/90/270 backoff and final classification_failed status.
- POST /{id}/classify is a re-queue endpoint; clients receive status=processing.
- 3 previously-xfailed tests now pass (test_retry_backoff, test_exhaustion_sets_failed_status, test_reclassify_requeues_celery).
- No regressions in existing test_documents.py IDOR/auth coverage. </success_criteria>