docs(07-04): add plan summary — Celery retry harness + POST /classify re-queue complete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
63cd707d52
commit
0c1ae5284f
@@ -0,0 +1,136 @@
|
||||
---
|
||||
phase: 07-redo-and-optimize-llm-integration
|
||||
plan: 04
|
||||
subsystem: backend/tasks + backend/api
|
||||
tags: [celery, retry, classification, async, tdd]
|
||||
wave: 4
|
||||
|
||||
dependency_graph:
|
||||
requires:
|
||||
- 07-03 # load_provider_config + provider singletons
|
||||
provides:
|
||||
- D-09 # Celery exponential backoff (30/90/270s)
|
||||
- D-10 # Final classification_failed status writeback
|
||||
- D-11 # backend half: POST /classify re-queues Celery
|
||||
affects:
|
||||
- backend/tasks/document_tasks.py
|
||||
- backend/api/documents.py
|
||||
- backend/tests/test_document_tasks.py
|
||||
- backend/tests/test_documents.py
|
||||
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "_ClassificationError sentinel escapes asyncio.run() to trigger Celery retry in sync layer (Pitfall 3)"
|
||||
- "MaxRetriesExceededError caught in nested try/except inside except _ClassificationError block"
|
||||
- "push_request(retries=N) + patch.object(task, retry) pattern for testing bound Celery tasks"
|
||||
- "assert_called_once_with (not assert_awaited_once_with) for asyncio.run(AsyncMock(...)) calling convention"
|
||||
|
||||
key_files:
|
||||
modified:
|
||||
- backend/tasks/document_tasks.py
|
||||
- backend/api/documents.py
|
||||
- backend/tests/test_document_tasks.py
|
||||
- backend/tests/test_documents.py
|
||||
|
||||
decisions:
|
||||
- "MaxRetriesExceededError caught in nested try/except inside except _ClassificationError — not as sibling except — because raise self.retry() raises MaxRetriesExceededError during exception handling, which would propagate past a sibling except block"
|
||||
- "module-level extract_and_classify import retained in documents.py (already existed, already used in confirm and upload handlers) — no deferred import needed"
|
||||
- "test_reclassify_cross_user_returns_404 added as new IDOR test for classify endpoint (plan said 'regression of existing IDOR test' but none existed for classify; added new test per security mandate)"
|
||||
|
||||
metrics:
|
||||
completed_date: "2026-06-04"
|
||||
tasks_completed: 2
|
||||
tasks_total: 2
|
||||
files_modified: 4
|
||||
---
|
||||
|
||||
# Phase 07 Plan 04: Celery Retry Harness + POST /classify Re-queue Summary
|
||||
|
||||
## One-liner
|
||||
|
||||
Celery exponential-backoff retry harness (30s/90s/270s) via `_ClassificationError` sentinel + MaxRetriesExceededError nested catch, and `POST /classify` converted from synchronous classification to Celery re-queue.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 | Celery retry harness + _ClassificationError + classification_failed writeback | e9ee5d4 | document_tasks.py, test_document_tasks.py |
|
||||
| 2 | POST /api/documents/{id}/classify → re-queue Celery | 63cd707 | documents.py, test_documents.py |
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Task 1: Celery retry harness (D-09 / D-10)
|
||||
|
||||
`backend/tasks/document_tasks.py` was refactored to implement the self-healing retry loop:
|
||||
|
||||
- `class _ClassificationError(Exception)` — sentinel raised by `_run()` when `classifier.classify_document()` fails; escapes `asyncio.run()` and is caught by the outer sync task
|
||||
- `async def _mark_classification_failed(document_id)` — writes `doc.status = "classification_failed"` after all retries exhausted; called via `asyncio.run(...)` from the `MaxRetriesExceededError` handler
|
||||
- Task decorator changed to `@celery_app.task(name=..., bind=True, max_retries=3)`
|
||||
- Retry loop: `except _ClassificationError as exc` → `countdowns = [30, 90, 270]` → `raise self.retry(exc=exc, countdown=countdown)`
|
||||
- `MaxRetriesExceededError` caught in a **nested** `try/except` inside the `_ClassificationError` handler (not as a sibling `except`) because `raise self.retry()` raises `MaxRetriesExceededError` during exception handling context
|
||||
|
||||
**Critical architectural decision:** The nested catch was required. If `MaxRetriesExceededError` were a sibling `except` block after `except _ClassificationError`, Celery's `self.retry()` call raising `MaxRetriesExceededError` inside the `_ClassificationError` handler would propagate past it without being caught.
|
||||
|
||||
### Task 2: POST /classify re-queue (D-11)
|
||||
|
||||
`backend/api/documents.py` classify endpoint was refactored:
|
||||
|
||||
- Removed: `await classifier.classify_document(session, doc_id, topic_names)` and the topics response
|
||||
- Added: `doc.status = "processing"`, `await session.commit()`, `extract_and_classify.delay(str(doc.id))`
|
||||
- Returns: `{"document_id": str(doc.id), "status": "processing"}` with HTTP 200
|
||||
- Ownership check retained inline (no `_get_owned_doc` helper existed; plan said to replicate if absent)
|
||||
- `body: dict = {}` parameter removed (no longer needed since topics are written by the Celery task)
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Promoted from xfail:
|
||||
- `test_document_tasks.py::test_retry_backoff` — verifies countdowns [30, 90, 270] for retries 0/1/2
|
||||
- `test_document_tasks.py::test_exhaustion_sets_failed_status` — verifies `_mark_classification_failed` called and return dict correct
|
||||
- `test_documents.py::test_reclassify_requeues_celery` — verifies Celery delay called, doc.status=processing, response correct
|
||||
|
||||
### New tests added:
|
||||
- `test_documents.py::test_reclassify_cross_user_returns_404` — IDOR test for classify endpoint (T-07-10)
|
||||
|
||||
### Test suite result:
|
||||
- Before Plan 04: 366 passed, 12 xfailed, 1 pre-existing failure
|
||||
- After Plan 04: 370 passed, 9 xfailed, 1 pre-existing failure (test_extract_docx missing module — unrelated)
|
||||
- Net: +4 promoted tests, +1 new IDOR test
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] MaxRetriesExceededError requires nested try/except, not sibling**
|
||||
- **Found during:** Task 1 implementation — tests confirmed the structure
|
||||
- **Issue:** The plan described `except MaxRetriesExceededError` as a sibling handler after `except _ClassificationError`. However, `raise self.retry(exc=exc, countdown=countdown)` is called inside the `except _ClassificationError` block. When `self.retry()` raises `MaxRetriesExceededError`, Python is already unwinding the `_ClassificationError` handler — a sibling `except MaxRetriesExceededError` would not catch it.
|
||||
- **Fix:** Used nested `try/except MaxRetriesExceededError` inside the `except _ClassificationError` block
|
||||
- **Files modified:** `backend/tasks/document_tasks.py`
|
||||
- **Commit:** e9ee5d4
|
||||
|
||||
**2. [Rule 2 - Missing Critical Functionality] Added IDOR test for classify endpoint**
|
||||
- **Found during:** Task 2 — plan referenced "regression of existing IDOR test" but no such test existed for POST /classify
|
||||
- **Issue:** T-07-10 (cross-user reclassify → 404) had no test coverage
|
||||
- **Fix:** Added `test_reclassify_cross_user_returns_404`
|
||||
- **Files modified:** `backend/tests/test_documents.py`
|
||||
- **Commit:** 63cd707
|
||||
|
||||
### No architectural changes required.
|
||||
|
||||
## Threat Surface Scan
|
||||
|
||||
No new network endpoints or trust boundaries introduced. The `POST /classify` endpoint already existed; this plan changed its implementation from synchronous to async-queued. The IDOR invariant (T-07-10) is enforced by the ownership check `doc.user_id != current_user.id → 404` — same pattern as all other document endpoints.
|
||||
|
||||
No new entries needed in threat model.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None — all classification path changes write real state to the DB via the Celery task.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `backend/tasks/document_tasks.py` contains `class _ClassificationError`, `bind=True`, `max_retries=3`, `async def _mark_classification_failed`, `countdowns = [30, 90, 270]`, `raise self.retry(exc=`
|
||||
- `backend/api/documents.py` POST classify contains `extract_and_classify.delay(` and `doc.status = "processing"`, does NOT contain `await classifier.classify_document(`
|
||||
- Commits e9ee5d4 and 63cd707 exist in git log
|
||||
- `pytest tests/test_document_tasks.py tests/test_documents.py::test_reclassify_requeues_celery` exits 0
|
||||
- Full suite: 370 passed, 1 pre-existing failure (test_extract_docx — unrelated)
|
||||
Reference in New Issue
Block a user