Skip to content

Error taxonomy + parse warnings: typed ParseError subtypes, lossy-repair observability, strict mode #100

Description

@kurok

Problem

Error reporting is binary: ParseError with a message string, or success. Real mail is messier than that — #24 (fixed) showed decode failures being silently swallowed, and the fix chose between "error" and "best effort" globally. Pipeline consumers need the middle ground: parse what you can, tell me what was lossy. Today a message with one undecodable body part either fails entirely or succeeds with silently-mangled content, and the caller can't distinguish pristine output from patched-up output — which matters enormously when the consumer is a spam classifier or forensic tool making decisions on the content.

Proposal

1. Error taxonomy (additive; ParseError stays the base)

ParseError                    # base (unchanged — existing except clauses keep working)
├── HeaderParseError          # unrecoverable header-section failure
├── MimeStructureError        # malformed multipart structure / recursion cap (#21) exceeded
└── DecodeError               # body/attachment transfer-decode failure in strict mode
  • Each carries structured context: part_path (e.g. "1.2" tree coordinates, aligning with the part-tree issue), charset/encoding where relevant — machine-usable, not just prose.

2. Parse warnings — the lossy-success channel

mail = parse_email(payload)
mail.warnings     # list[ParseWarning]: kind, part_path, detail
# kinds: "charset-fallback", "transfer-decode-lossy", "invalid-header-skipped",
#        "address-unparseable", "date-unparseable", ...
  • Best-effort behavior (post-Body decode errors silently swallowed via unwrap_or_default() #24 semantics) becomes observable: every lossy repair the parser performs is recorded. Empty list = pristine parse — a guarantee consumers can finally act on (route warnings != [] mail to quarantine/manual review).
  • parse_email(payload, strict=True): any condition that would emit a warning raises the corresponding typed error instead (validation-pipeline mode).

Acceptance criteria

  • Exception hierarchy exported with stubs; except ParseError catches all subtypes (compat test); each subtype raised from a dedicated malformed fixture with part_path/context populated (table-driven, extending test_decode_errors.py / test_dos_limits.py).
  • Warning kinds emitted for each documented lossy path: charset fallback fixture, broken quoted-printable, bad address header, bad date — each yields exactly the expected (kind, part_path) and correct best-effort content alongside (both asserted).
  • Clean corpus regression: every currently-passing fixture parses with warnings == [] — no warning inflation on good mail (full-corpus assertion; any fixture that legitimately warns gets documented as such).
  • strict=True converts each warning scenario into its typed exception (parametrized over the same fixtures); default remains best-effort.
  • parse_many propagates per-item typed errors and per-mail warnings (integration with the batch issue if merged; sequencing noted).
  • Warning collection overhead ≤ 2% on the clean-corpus benchmark (numbers in PR; check_benchmark.py still green).
  • README error-handling section rewritten around the taxonomy + warnings contract; stubs/mypy --strict/ruff/clippy/fmt clean; CHANGELOG entry.

Out of scope

  • Python warnings module integration (list attribute is explicit and pipeline-friendly; stdlib warnings are process-global and wrong for per-message data).
  • Auto-repair beyond what the parser already does (observability of repairs, not new repairs).

Part of the roadmap (see tracking issue). Builds on #24's resolution; part_path vocabulary shared with the part-tree issue.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions