Skip to content

fix(parser): single corrupt page causes full document rejection with no per-page fallback or partial-result signal #275

Closed

Description

@piyushmakhija28

Summary

parser.py builds all pages via tuple(generator) inside a single try block. If PyMuPDF raises an exception on page N (malformed xref, corrupted content stream), the exception propagates out of tuple() and the entire document is rejected — even if pages 0..N-1 were successfully parsed. There is no per-page exception handling, partial result, or indication of which page failed.

Root Cause

ingestion/parser.py around line 135:

def parse(self, data: bytes) -> tuple[ParsedPage, ...]:
    try:
        doc = fitz.open(stream=data, filetype='pdf')
        return tuple(self._parse_pages(doc))   # ← generator consumed here
    except Exception as exc:
        raise ParseError(f"Failed to parse PDF: {exc}") from exc

If _parse_pages raises on page 47 of a 200-page document, tuple() unwinds, the remaining pages are never parsed, and ParseError is raised — treating one bad page the same as a completely unparseable file.

Failure Scenario

  1. Tenant uploads a 200-page PDF with one corrupt page (page 47 has a malformed image stream).
  2. tuple(self._parse_pages(doc)) raises RuntimeError: 'fitz: page 47 corrupted' when processing page 47.
  3. ParseError is raised. Ingest fails with 422.
  4. Pages 0–46 and 48–199 are perfectly valid and searchable, but the entire document is rejected.
  5. Tenant has no option to get partial results — they must either fix the PDF or skip the document entirely.

Proposed Fix

Add per-page exception handling with graceful skip:

def _parse_pages_safe(self, doc) -> Iterator[ParsedPage]:
    for page_num in range(doc.page_count):
        try:
            yield self._parse_page(doc[page_num])
        except Exception as exc:
            logger.warning(
                "Skipping corrupt page %d: %s",
                page_num, exc,
                extra={"total_pages": doc.page_count},
            )
            # Yield an empty page so the caller knows this page was skipped:
            yield ParsedPage(page_num=page_num, blocks=[], text='', skipped=True)

This preserves the overall ingest while logging corrupt pages, and downstream chunker/TOC code can handle skipped=True pages gracefully.

Activity

  1. piyushmakhija28 commented on Jun 21, 2026

    @piyushmakhija28
    ContributorAuthor

    Fixed in PR #280 (Sprint R3/R4: fix(sprint-r3): root-cause fixes for all 39 bugs from code review round 3 + Sprint R4 round 4)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcode-reviewFound via automated code reviewpriority: mediumFunctional error with workaround

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions