OpenProgram Docs

Error Handling — Discipline and Gate#

What may be caught, what must be said about it, and which paths a linter enforces it on.

1. The rule#

Catch what you can handle, and leave a record of what you swallowed.

A caught exception that produces no log, no comment and no fallback is indistinguishable from a bug that has not been found yet. Most of the cost of a silent handler is paid later, by whoever is trying to work out why a write never landed.

Three concrete obligations:

  1. Catch specifically. File IO raises OSError; JSON raises ValueError (and json.JSONDecodeError); ContextVar.reset raises ValueError for a foreign token and RuntimeError for a spent one. Name everything the operation actually raises.
  2. Never swallow a programmer error. AttributeError, TypeError, NameError and friends are defects in our own code, not conditions to tolerate. A handler broad enough to hide one must either be narrowed until it cannot, or log so the defect is visible.
  3. Say why. Every remaining swallow carries either a log line with context, or a comment stating why ignoring it is correct. Preferably both when the reasoning is not obvious.

2. Boundaries#

A broad except Exception is legitimate at a boundary — a place where letting the exception propagate would take down something larger than the failed operation:

  • WebSocket handlers and HTTP routes, where an exception would drop the connection rather than return an error.
  • Thread and worker entry points, where an exception dies unobserved.
  • Callback invocation, where caller-supplied code runs inside our loop.
  • Optional subsystems (memory, project git, usage metering), where absence degrades a feature rather than failing the turn.
  • Best-effort bookkeeping that the next turn re-derives anyway.

A boundary catch still logs with enough context to identify the session or the operation. "Boundary" is a reason to keep running, never a reason to stay quiet.

3. Choosing the log level#

Level When
warning Something was lost that the user could notice: a write that did not land, a status left stale, a record that will be missing.
debug An optional path degraded as designed: an absent subsystem, a best-effort cache, a fallback that worked.

Use exc_info=True so the traceback survives, and include the session id or equivalent identifier — a log line that cannot be tied to a session is close to useless when several agents run at once.

4. What good handlers look like#

Silent loss of a durable write, made visible:

except OSError:
    _log.warning(
        "index.json not saved for session %s", session_id, exc_info=True)

A narrow catch where a broad one was hiding defects:

except (ValueError, RuntimeError):
    # ContextVar.reset raises ValueError for a token minted in another
    # context and RuntimeError for one already spent. Narrowing to only
    # the first lets a cancelled turn's spent token escape and replace
    # the CancelledError.
    _log.debug("context var token already spent or foreign", exc_info=True)

Narrowing is only correct once you know the full set. Check what the call actually raises — ContextVar.reset above raises two unrelated types, and a handler that names one of them turns a swallowed failure into an escaping one.

An optional subsystem, with the reason stated:

except Exception:
    # Memory is optional: an unavailable provider degrades to no memory
    # block, never a failed turn.
    _log.debug("memory system prompt block unavailable", exc_info=True)

Handlers that guard an operation which already swallows its own errors, or that catch something the code cannot raise, are deleted rather than annotated.

5. The gate#

ruff enforces two rules, configured in pyproject.toml:

Rule What it catches
E722 A bare except:, which also swallows KeyboardInterrupt and SystemExit.
S110 try / except / pass — an exception discarded with no log, no comment and no fallback.

Run it with:

.venv/bin/ruff check .

Scope#

The gate is enforced on three core paths, which are clean:

  • openprogram/store/ — the durable record
  • openprogram/context/ — what the model is shown
  • openprogram/agent/dispatcher/ — the turn itself

These carry the state whose silent corruption is hardest to diagnose later. Everywhere else is muted through per-file-ignores, because several hundred pre-existing sites would make the gate unpassable and therefore ignored. The mute list is the backlog: clean a directory, delete its line, and the gate covers it.

A per-file-ignores pattern matches the whole path and its * crosses /, so openprogram/*.py mutes every module in the package rather than the top-level ones — which is why the top-level modules are named individually. Check any new pattern against a nested file before trusting it: a pattern that mutes too much turns the gate off without failing anything.

BLE001 (blind except Exception) is deliberately not enabled. A boundary handler that logs and degrades is correct design, and flagging every one of them would produce noise rather than signal. Section 2 governs those by review instead.

Ruff is scoped to this gate alone. Formatting and import order are left alone on purpose — this is a correctness rule, not a style regime.

Appendix: Implementation Status#

Implemented. The three core paths pass E722 and S110; ruff is in the dev extra and configured under [tool.ruff] in pyproject.toml.

Not yet done: the muted directories in per-file-ignores. openprogram/webui/ and openprogram/agent/ outside the dispatcher hold the largest remaining concentrations.

Last updated · 2026-08-13