odysseus-dev/odysseus · error · HTTPException

AI tidy failed: {e}

Error message

AI tidy failed: {e}

What it means

The catch-all 500 handler for POST /api/documents/ai-tidy. Non-HTTPException errors during candidate selection, LLM calls, verdict parsing, or the delete/commit phase are rolled back, logged as 'AI tidy failed: {e}', and surfaced as HTTPException(500, f"AI tidy failed: {e}"). Note HTTPExceptions (like the invalid-response error) are re-raised unchanged; this handler covers everything else, including network failures to the LLM endpoint.

Source

Thrown at routes/document/document_routes.py:1059

                    db.delete(doc)
                    deleted += 1
                else:
                    doc.tidy_verdict = "keep"
                reviewed += 1

            db.commit()
            return {
                "deleted": deleted,
                "reviewed": reviewed,
                "remaining": len(to_review) - len(batch),
                "message": f"Reviewed {reviewed}, removed {deleted} junk document{'s' if deleted != 1 else ''}",
            }
        except HTTPException:
            raise
        except Exception as e:
            db.rollback()
            logger.error(f"AI tidy failed: {e}")
            raise HTTPException(500, f"AI tidy failed: {e}")
        finally:
            db.close()

    # ---- POST /api/document/{doc_id}/export-pdf/preview ----
    @router.post("/api/document/{doc_id}/export-pdf/preview")
    async def export_pdf_preview(doc_id: str, request: Request) -> Dict[str, Any]:
        """Return the field-value mapping that would be written to the PDF.

        Frontend shows this in a confirmation modal so the user can spot/fix
        any wrong values before triggering the actual download.
        """
        from src.pdf_form_doc import find_source_upload_id, parse_markdown_to_values, load_field_sidecar

        user = get_current_user(request)
        db = SessionLocal()
        try:
            doc = db.query(Document).filter(Document.id == doc_id).first()
            if not doc:

View on GitHub (pinned to f9235ebbf1)

Solutions

  1. Read the '{e}' detail — timeout errors point at the provider, JSONDecodeError at the model output, lock errors at the DB.
  2. Verify the endpoint URL and headers returned by resolve_task_endpoint actually work (curl the provider).
  3. Shrink the batch to keep the LLM call under the 30s timeout.
  4. Retry later; verdicts are cached, so completed batches are skipped on the next run.

Example fix

# before
resp = requests.post(f"{base}/api/documents/ai-tidy", timeout=120)
resp.raise_for_status()

# after
for attempt in range(3):
    resp = requests.post(f"{base}/api/documents/ai-tidy", timeout=120)
    if resp.status_code != 500: break
    time.sleep(2 ** attempt)  # transient LLM/DB failures; cached verdicts make retry cheap
Defensive patterns

Strategy: retry

Try / catch

try:
    r = requests.post(f"{base}/api/documents/ai-tidy", timeout=120)
except requests.HTTPError as e:
    detail = e.response.json().get("detail", "")
    if e.response.status_code == 500 and ("timeout" in detail or "connection" in detail):
        time.sleep(5); r = retry_ai_tidy()  # cached verdicts make retries cheap
    else:
        raise

Prevention

When it happens

Trigger: llm_call_async raising on connection timeout (30s limit) or auth failure; JSON parse errors if the extracted bracket text is not valid JSON; DB lock during the commit that deletes junk docs.

Common situations: LLM provider unreachable from the server (egress blocked, wrong URL); expired API key headers; slow provider exceeding the 30-second timeout on a large batch.

Related errors


AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14). Data as JSON: /api/errors/6f5b149f722d1074. Report an issue: GitHub.