Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions backend/app/routers/letters.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Letter upload + retrieval endpoints (auth-required, /api/letters/*)."""

import logging
from uuid import UUID

from fastapi import APIRouter, Depends, File, Query, UploadFile
Expand Down Expand Up @@ -27,6 +28,8 @@
from app.services.persistence import persist_extraction
from app.services.storage import detect_magic_mime, save_letter_file

logger = logging.getLogger("klar.letters")

router = APIRouter(prefix="/api/letters", tags=["letters"])

ACCEPTED_MIMES = {
Expand Down Expand Up @@ -221,24 +224,31 @@ async def extract_letter(
letter.original_file, mime, lang=letter.language
)
except PdfRenderError as exc:
# PDF couldn't be rendered (corrupt / poppler missing) — distinct,
# actionable message ("try uploading it as an image instead").
logger.info("PDF render failed for letter %s: %s", letter_id, exc)
letter.status = LetterStatus.ERROR
db.add(letter)
db.commit()
# PDF couldn't be rendered (corrupt / poppler missing) — distinct,
# actionable message ("try uploading it as an image instead").
raise KlarHTTPException(502, ErrorCode.PDF_RENDER_FAILED, message=str(exc))
except ExtractionError as exc:
# Scanned image-only PDF (no text layer) or malformed model output —
# surface the typed, user-friendly message instead of a raw 500.
logger.info("Extraction produced no text for letter %s: %s", letter_id, exc)
letter.status = LetterStatus.ERROR
db.add(letter)
db.commit()
# Scanned image-only PDF (no text layer) or malformed model output —
# surface the typed, user-friendly message instead of a raw 500.
raise KlarHTTPException(502, ErrorCode.EXTRACTION_FAILED, message=str(exc))
except Exception:
except Exception as exc:
# Log the real error to the server console so we can diagnose.
# The 502 response stays generic on the wire to avoid leaking provider
# implementation details to the client.
logger.exception(
"Qwen extraction failed for letter %s: %s", letter_id, exc,
)
letter.status = LetterStatus.ERROR
db.add(letter)
db.commit()
# Don't leak the raw exception. Logged by unhandled_exception_handler.
raise KlarHTTPException(502, ErrorCode.EXTRACTION_FAILED)

actions = persist_extraction(db, letter, extracted)
Expand Down
77 changes: 77 additions & 0 deletions backend/tests/test_scanned_pdf_graceful.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,3 +354,80 @@ async def _render_fail(path):

assert "event: error" in blob
assert ErrorCode.PDF_RENDER_FAILED.value in blob


# --------------------------------------------------------------------------
# 7. POST /api/letters/{id}/extract (letters.py) — sync extraction endpoint
# must return typed 502 errors with logging, NOT silently swallow them.
# --------------------------------------------------------------------------


async def _call_extract_letter(monkeypatch, *, raise_exc):
"""Drive letters.extract_letter with extraction stubbed to raise `raise_exc`.

Returns the KlarHTTPException the handler raises (or None if it didn't).
"""
from app.routers import letters as letters_router

async def _boom(*a, **k):
raise raise_exc

monkeypatch.setattr(letters_router, "extract_from_letter_file", _boom)

with Session(engine) as db:
user = _make_user(db)
letter = Letter(
user_id=user.id,
language="en",
status=LetterStatus.UPLOADED,
original_file="/tmp/scanned-no-text.pdf",
)
db.add(letter)
db.commit()
db.refresh(letter)
try:
await letters_router.extract_letter(
letter_id=letter.id, db=db, user=user,
)
except Exception as exc: # noqa: BLE001 — we assert on the typed error
return exc
return None


async def test_extract_letter_scanned_pdf_returns_extraction_failed(monkeypatch):
from app.errors import KlarHTTPException

exc = await _call_extract_letter(
monkeypatch,
raise_exc=ExtractionError("scanned image without readable content"),
)
assert isinstance(exc, KlarHTTPException)
assert exc.status_code == 502
assert exc.code == ErrorCode.EXTRACTION_FAILED
assert "readable content" in exc.message


async def test_extract_letter_corrupt_pdf_returns_pdf_render_failed(monkeypatch):
from app.errors import KlarHTTPException

exc = await _call_extract_letter(
monkeypatch,
raise_exc=PdfRenderError("Could not render this PDF. It may be corrupt."),
)
assert isinstance(exc, KlarHTTPException)
assert exc.status_code == 502
assert exc.code == ErrorCode.PDF_RENDER_FAILED
assert "corrupt" in exc.message


async def test_extract_letter_unexpected_error_stays_generic(monkeypatch):
from app.errors import KlarHTTPException

exc = await _call_extract_letter(
monkeypatch, raise_exc=RuntimeError("some provider 500 with secrets")
)
assert isinstance(exc, KlarHTTPException)
assert exc.status_code == 502
assert exc.code == ErrorCode.EXTRACTION_FAILED
# Raw provider error must NOT leak into the user-facing message.
assert "secrets" not in exc.message