DEV Community

Avery Lin
Avery Lin

Posted on

Map Exception Classes to an Error Catalog; Humans Sign Recovery and Retry

Customer-facing error documentation fails when generated class names leak into signed recovery copy without a review gate. A safer workflow extracts exception types and HTTP mappings into a catalog, then allows a model to draft only technical summaries from docstrings. Humans must sign severity, retry class, and recovery wording before those rows can merge into the public error reference. Unsigned rows may exist in a generated index for engineers, but they must not appear in user guides, status pages, or support macros.

Why mixed ownership produces stale recovery copy

Most services already encode a machine-readable error surface through exception classes, error codes, and HTTP status attributes. Documentation authors then rewrite those facts in Markdown, and the two representations drift after the next handler refactor. Models worsen that drift when they invent recovery advice from a class name without a signed source for retry semantics. The resulting page looks complete, yet it can promise refunds, retries, or SLAs that engineering never approved.

The failure is visible in review comments long before any analytics dashboard shows a documentation defect rate. Reviewers repeatedly flag user-visible sentences that over-promise compensation, retry behavior, or incident communication windows. A catalog with explicit ownership columns turns that qualitative pain into a merge gate that CI can enforce. Missing signatures fail the build, and generated summaries are forbidden from overwriting fields a human already signed.

Ownership split: extracted columns versus signed columns

Treat every error row as a record that keeps extracted fields and obligation fields in separate namespaces. The extracted namespace may be regenerated on every commit because the abstract syntax tree is the source of truth. The obligation namespace may receive a model proposal, but it stays unpublished until a human sets signed_by. If a column can be derived from source, regeneration should win; if it changes customer obligations, regeneration must lose.

Column Source of truth Model may draft Human must own Public merge without signature
error_code class constant no uniqueness engineer index only
exc_class AST class name no n/a allowed
http_status class attr or handler map no public contract engineer index only
technical_summary docstring yes, compress only correct the docstring engineer index only
user_message not in source propose only yes blocked
retryable not in source unless attr propose only yes blocked
severity not in source unless attr propose only yes blocked
recovery_steps not in source propose only yes blocked
signed_by review record no yes blocked

The table is the catalog contract rather than an editorial preference, and CI should encode it as a schema check. Extracted columns rebuild from the AST on each commit, while obligation columns remain frozen until a named reviewer updates them. That split also gives models a narrow job: draft technical_summary from a docstring, never mint retry policy from a class name.

Proposed extractor: exception classes to catalog JSON

The following Python is a proposed, unexecuted example that walks a package and emits catalog rows for exception subclasses. It copies previously signed columns forward so regeneration cannot blank user_message, severity, retryable, or recovery_steps. Run it only after you point --root at a real package and inspect the JSON diff in an ordinary pull request.

#!/usr/bin/env python3
"""Proposed extractor: exception classes -> error catalog JSON. Unexecuted example."""
from __future__ import annotations

import argparse
import ast
import json
from pathlib import Path
from typing import Any

SIGNED_FIELDS = (
    "user_message",
    "retryable",
    "severity",
    "recovery_steps",
    "signed_by",
)


class ExceptionVisitor(ast.NodeVisitor):
    def __init__(self, module: str) -> None:
        self.module = module
        self.rows: list[dict[str, Any]] = []

    def visit_ClassDef(self, node: ast.ClassDef) -> None:
        bases = [self._name(base) for base in node.bases]
        if not self._looks_like_error(node.name, bases):
            self.generic_visit(node)
            return
        docstring = ast.get_docstring(node) or ""
        error_code, http_status = self._class_constants(node)
        self.rows.append(
            {
                "error_code": error_code or node.name,
                "exc_class": f"{self.module}.{node.name}",
                "bases": bases,
                "http_status": http_status,
                "technical_summary": " ".join(docstring.split())[:240],
                "docstring_present": bool(docstring.strip()),
                "user_message": "",
                "retryable": None,
                "severity": "",
                "recovery_steps": "",
                "signed_by": "",
            }
        )
        self.generic_visit(node)

    @staticmethod
    def _name(expr: ast.expr) -> str:
        if isinstance(expr, ast.Name):
            return expr.id
        if isinstance(expr, ast.Attribute):
            return expr.attr
        return type(expr).__name__

    @staticmethod
    def _looks_like_error(name: str, bases: list[str]) -> bool:
        tokens = set(bases) | {name}
        return any(
            token.endswith(("Error", "Exception", "Fault"))
            or token in {"Exception", "BaseException"}
            for token in tokens
        )

    @staticmethod
    def _class_constants(node: ast.ClassDef) -> tuple[str | None, int | None]:
        code = None
        status = None
        for stmt in node.body:
            if not isinstance(stmt, ast.Assign) or len(stmt.targets) != 1:
                continue
            target = stmt.targets[0]
            if not isinstance(target, ast.Name):
                continue
            value = stmt.value
            if target.id in {"code", "error_code"} and isinstance(value, ast.Constant):
                if isinstance(value.value, str):
                    code = value.value
            if target.id in {"http_status", "status_code"} and isinstance(value, ast.Constant):
                if isinstance(value.value, int):
                    status = value.value
        return code, status


def merge_signed(old_rows: list[dict[str, Any]], new_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    previous = {row["exc_class"]: row for row in old_rows}
    merged: list[dict[str, Any]] = []
    for row in new_rows:
        prior = previous.get(row["exc_class"], {})
        for field in SIGNED_FIELDS:
            if field in prior:
                row[field] = prior[field]
        merged.append(row)
    return merged


def parse_module(path: Path, module: str) -> list[dict[str, Any]]:
    tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
    visitor = ExceptionVisitor(module)
    visitor.visit(tree)
    return visitor.rows


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--root", type=Path, required=True)
    parser.add_argument("--manifest", type=Path, required=True)
    parser.add_argument("--out", type=Path, required=True)
    args = parser.parse_args()
    prefixes = [
        line.strip()
        for line in args.manifest.read_text(encoding="utf-8").splitlines()
        if line.strip() and not line.startswith("#")
    ]
    extracted: list[dict[str, Any]] = []
    for path in args.root.rglob("*.py"):
        rel = path.relative_to(args.root).with_suffix("")
        module = ".".join(rel.parts)
        if not any(module == pfx or module.startswith(pfx + ".") for pfx in prefixes):
            continue
        extracted.extend(parse_module(path, module))
    old_rows: list[dict[str, Any]] = []
    if args.out.exists():
        old_rows = json.loads(args.out.read_text(encoding="utf-8")).get("rows", [])
    payload = {"version": 1, "rows": merge_signed(old_rows, extracted)}
    args.out.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Persist the catalog as docs/errors/catalog.json beside signed overlay fields rather than inside generated Markdown. Markdown renderers should read the JSON, print extracted columns freely, and omit any row that lacks signed_by. That rendering rule is the public-docs equivalent of a database view with a filter on signature state.

python tools/extract_error_catalog.py \
  --root src/payments \
  --manifest docs/errors/modules.txt \
  --out docs/errors/catalog.json
Enter fullscreen mode Exit fullscreen mode

A manifest keeps internal helpers out of the public catalog. The file is a proposed example, not evidence from a particular production repository.

# docs/errors/modules.txt
payments.errors
payments.api.http_errors
Enter fullscreen mode Exit fullscreen mode

Numbered workflow for draft, sign, and merge

The sequence below is a documentation pipeline, not a prompt collection, and each step has a checkable output.

1. Freeze the public exception surface

Name the modules that constitute the public error surface, and keep internal helper exceptions out of the catalog root. Private exceptions used only in tests should not receive customer recovery copy, because that copy would freeze an unstable type. Record the allowed module prefixes in a manifest file so the extractor cannot silently index a new internal package.

2. Extract identity fields from the AST

Run the extractor against those prefixes and write docs/errors/catalog.json as the extracted output for the commit. Diff the JSON in the pull request and treat added classes as unsigned rows, not as ready documentation. Deleted classes should remain in an archive list so public codes are not silently removed from historical references.

3. Draft technical summaries only from docstrings

When a docstring exists, a model may compress it into technical_summary at a maximum of two hundred forty characters. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Teams can draft those summaries with MonkeyCode's free model access and run the extractor on the free server option. Signed recovery columns still live in the reviewed catalog file, not in model output or server scratch space. If the docstring is empty, leave technical_summary blank and fail a warning rather than asking a model to invent meaning from the class name.

4. Humans sign obligation columns

A reviewer fills user_message, retryable, severity, and recovery_steps, then sets signed_by to a real reviewer identity. Retryable must be a boolean with an explicit source, such as an idempotency guarantee or a documented client replay rule. Severity must match the paging policy already used in operations, not a dramatic reading of the exception class name. Recovery steps must describe an action the caller can take, and they must not invent credits, SLAs, or legal remedies.

The fixture below is a labeled example for tests and renderers. It is not a claim about a live payments system.

{
  "version": 1,
  "rows": [
    {
      "error_code": "PAY_CONFLICT",
      "exc_class": "payments.errors.PaymentConflictError",
      "http_status": 409,
      "technical_summary": "Raised when two captured intents share the same idempotency key.",
      "user_message": "This payment was already processed. Refresh the order before retrying.",
      "retryable": false,
      "severity": "warn",
      "recovery_steps": "GET the order; if status is captured, stop retries; otherwise open a support ticket with the intent id.",
      "signed_by": "docs-oncall"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

5. Render two views from one catalog

Generate an engineer index that includes unsigned rows so missing signatures remain visible during ordinary code review. Generate the public error reference only from rows where signed_by is present and obligation columns are complete. Do not let a docs site template print class names as user-facing titles when a signed user_message exists.

# tools/render_error_docs.py
# Proposed, unexecuted example.
from __future__ import annotations

import argparse
import json
from pathlib import Path


def render_public(rows: list[dict]) -> str:
    lines = [
        "<!-- generated from docs/errors/catalog.json; do not sign this file -->",
        "# Error reference",
        "",
        "Only signed rows are published. Retry and recovery wording is human-owned.",
        "",
    ]
    published = [row for row in rows if row.get("signed_by")]
    for row in sorted(published, key=lambda item: item["error_code"]):
        retry = "yes" if row["retryable"] else "no"
        lines.extend(
            [
                f"## {row['error_code']}",
                "",
                row["user_message"],
                "",
                f"- HTTP status: {row.get('http_status') or 'unspecified'}",
                f"- Retryable: {retry}",
                f"- Severity: {row['severity']}",
                "",
                row["recovery_steps"],
                "",
            ]
        )
    return "\n".join(lines) + "\n"


def render_internal(rows: list[dict]) -> str:
    lines = ["# Engineering error index", "", "Unsigned rows are visible here only.", ""]
    for row in sorted(rows, key=lambda item: item["exc_class"]):
        flag = "signed" if row.get("signed_by") else "UNSIGNED"
        lines.append(f"- `{row['exc_class']}` ({row['error_code']}) — {flag}")
    return "\n".join(lines) + "\n"


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--catalog", type=Path, required=True)
    parser.add_argument("--public", type=Path, required=True)
    parser.add_argument("--internal", type=Path, required=True)
    args = parser.parse_args()
    rows = json.loads(args.catalog.read_text(encoding="utf-8"))["rows"]
    args.public.write_text(render_public(rows), encoding="utf-8")
    args.internal.write_text(render_internal(rows), encoding="utf-8")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode
python tools/render_error_docs.py \
  --catalog docs/errors/catalog.json \
  --public docs/errors/public.md \
  --internal docs/errors/engineering.md
Enter fullscreen mode Exit fullscreen mode

6. Enforce the gate in CI

Fail the build when public Markdown contains an exception class that is unsigned in catalog.json for this commit. Fail the build when a signed row changes extracted identity fields without a human note, because the contract may have shifted. Allow unsigned rows to merge only into the engineer index, never into the path that publishes customer documentation.

Gate tests that keep unsigned recovery out of public docs

The tests below are proposed examples. They encode the ownership split as assertions instead of reviewer memory.

# tests/test_error_catalog_gate.py
# Proposed, unexecuted example.
import json
from pathlib import Path

CATALOG = Path("docs/errors/catalog.json")
PUBLIC_MD = Path("docs/errors/public.md")
OBLIGATION = ("user_message", "severity", "recovery_steps")


def load_rows():
    payload = json.loads(CATALOG.read_text(encoding="utf-8"))
    return payload["rows"]


def test_signed_rows_have_obligation_fields():
    for row in load_rows():
        if not row.get("signed_by"):
            continue
        for field in OBLIGATION:
            assert row.get(field), f"{row['exc_class']} missing {field}"
        assert row["retryable"] in (True, False), f"{row['exc_class']} retryable unset"


def test_public_markdown_omits_unsigned_classes():
    public = PUBLIC_MD.read_text(encoding="utf-8")
    for row in load_rows():
        if row.get("signed_by"):
            assert row["error_code"] in public
            continue
        assert row["exc_class"] not in public
        assert row["error_code"] not in public


def test_extracted_identity_fields_are_present():
    for row in load_rows():
        assert row.get("exc_class")
        assert row.get("error_code")


def test_empty_docstring_does_not_invent_summary():
    for row in load_rows():
        if row.get("docstring_present") is False:
            assert row.get("technical_summary") in ("", None)
Enter fullscreen mode Exit fullscreen mode
pytest tests/test_error_catalog_gate.py
Enter fullscreen mode Exit fullscreen mode

Wire the tests into the same job that renders documentation, so a green docs build cannot hide an unsigned public row. A renderer that prints class names for unsigned rows belongs only on the engineering index, where missing signatures are the point of the page.

Limitations and who should skip this workflow

This workflow assumes exception classes are a stable public contract, which is false for many rapidly prototyped internal tools. Docstrings are often stale or humorous, and a model will faithfully compress jokes into technical_summary if you allow it. HTTP status attributes on classes can disagree with the status the handler actually returns, so mapping still needs a human check. The catalog does not replace an incident runbook, a status-page taxonomy, or a legally reviewed terms-of-service clause.

Class renames appear as a deletion plus an insertion, which drops signed columns unless reviewers pass an explicit rename map. AST extraction also misses errors declared only as string codes in middleware, protobuf enums, or OpenAPI components. If those formats are your contract, reuse the extracted-versus-signed column split, and replace the walker rather than forcing Python class names into the catalog.

Regulated products, payment user notices, and health-related messaging should not take recovery_steps from a draft even after a casual thumbs-up. Teams without a named documentation owner will accumulate unsigned rows and then disable the CI gate under release pressure. Skip the extractor if you do not yet have a public error module and are still renaming exceptions every week.

Skip model drafts if your docstrings contain secrets, customer identifiers, or unreleased product names that should not leave the repository. Skip public rendering if legal or support must approve every user_message through a ticket, because the catalog would duplicate that queue without authority. In those organizations the catalog can still feed an engineering index, while customer copy stays on the existing approval path.

Keep the rule small: extracted columns come from source, obligation columns come from signed humans, and models may only compress existing docstrings. When those three jobs stay separate, the error reference can regenerate after refactors without rewriting promises the company did not make.

Top comments (0)