Task · SFL-0048

cli: run/backfill dump a traceback (exit 1) for non-ConfigError wiring failures - overlap and label-guard errors bypass the documented exit-2 contract

Status
To Do
Labels
followup, phase-5
Milestone
Correctness & data-integrity hardening
Updated
2026-08-14

Description

What

The run path (no subcommand) guards App.build with a single-type handler:

# src/sf2loki/cli.py:261-268
try:
    cfg = load(args.config)
    app = App.build(cfg)
except ConfigError as exc:
    print(f"sf2loki: {exc}", file=sys.stderr)
    return _CONFIG_ERROR_EXIT_CODE

App.build raises two wiring errors that are not ConfigError subclasses, so both escape uncaught:

Nothing upstream prevents either state from a plain config file: sink.loki.labels is an unvalidated dict[str, str] (src/sf2loki/config.py:959-964), so a disallowed or reserved label key passes Pydantic validation and only fails at sink construction; the either/or overlap guard runs only inside App.build.

--check catches bare Exception (src/sf2loki/cli.py:252-257) and therefore reports both cleanly with exit 2. The run path does not. Reproduced in-process against two configs:

config sf2loki --check --config c.yaml sf2loki --config c.yaml
pubsub.topics: ["/event/LoginEventStream"] + eventlog_objects.objects: [{name: LoginEvent}] config check FAILED: ... / rc 2 uncaught sf2loki.sources.overlap.OverlapError
sink.loki.labels: {user_id: abc} config check FAILED: Disallowed label keys: user_id / rc 2 uncaught sf2loki.sinks.loki.labels.LabelGuardError

Because the console script is sf2loki = "sf2loki.cli:main" (pyproject.toml:34) and python -m sf2loki is sys.exit(main()) (src/sf2loki/__main__.py:10), an escaping exception prints a Python traceback and exits 1.

This contradicts three places that state the opposite:

backfill is exposed the same way for the label guard: run_backfill constructs LokiSink at src/sf2loki/backfill.py:740, which is outside its own try (opens at backfill.py:757) and outside cli.py’s config try (the except (ConfigError, ValueError) block ends at src/sf2loki/cli.py:221, while uvloop.run(run_backfill(...)) is at src/sf2loki/cli.py:229-241). That handler would have caught a ValueError, which is precisely why the construction must move inside a guarded region or the call site must be wrapped.

Existing coverage does not catch this: tests/test_cli.py:110-125 pins “no traceback, exit 2” for the run path but only for a missing-secret ConfigError; tests/test_cli.py:55-80 pins the overlap case for --check only.

Related but distinct from #71 item 4, which unified the value of the config-error exit code (--check 1 -> 2). That change did not broaden the run path’s exception type.

Why it matters

An operator who misconfigures overlapping sources (the either/or-per-category rule is easy to trip when adding a second source for one category) or sets a disallowed sink.loki.labels key gets a traceback and exit 1 on service start, instead of the documented one-line message and exit 2. Supervisors and deployment scripts that branch on the documented contract — treat 2 as “invalid config, stop and alert a human”, anything else as “crashed, restart” — misclassify a permanent config error as a transient crash and restart-loop the container. It also makes the --check/run pair inconsistent for byte-identical config, which is the exact property cli.py:35-43 promises.

Proposed approach

Preferred: give the wiring guards a common ancestor so every current and future call site is covered by one handler, rather than maintaining an exception tuple that drifts as guards are added.

  1. src/sf2loki/sources/overlap.py:41 -> class OverlapError(ConfigError) (import ConfigError from sf2loki.config; config.py imports nothing from sources/, so no cycle).
  2. src/sf2loki/sinks/loki/labels.py:17 -> class LabelGuardError(ConfigError, ValueError). Keeping ValueError in the bases preserves every existing pytest.raises(LabelGuardError) assertion and any caller relying on ValueError (notably src/sf2loki/cli.py:219). config.py does not import sinks/, so no cycle.
  3. Guard the backfill call: wrap uvloop.run(run_backfill(...)) (src/sf2loki/cli.py:229-241) in except ConfigError returning _CONFIG_ERROR_EXIT_CODE, or move the LokiSink construction at src/sf2loki/backfill.py:740 inside a region that returns a config-error code. The CLI-side wrapper is simpler and keeps the exit-code constant in one module.
  4. Leave --check’s bare except Exception as-is; do not widen the run path to bare Exception — a genuine programming error must still surface as a traceback rather than be reported as invalid config.

Fallback if the base-class change is rejected: except (ConfigError, OverlapError, LabelGuardError) on both the run path and the backfill call, with a comment that any new startup guard must be added to the tuple.


Imported from GitHub issue #132 on 2026-08-14, when this repo migrated from GitHub Issues to Backlog.md. The original issue has been deleted; its verbatim body, labels and comments are preserved in archive/issues-dump.json (jq '.[] | select(.number == 132)' archive/issues-dump.json).

Filed from the 2026-07-30 full-repo audit (11 finder lanes + adversarial verification per finding).

Acceptance Criteria

Definition of Done

References

View the source file on GitHub