@@ -57,6 +57,26 @@ class NullRunError(BreakerError):
5757 subclass populates at least ``error_code``; ``user_action`` is
5858 empty only when there is genuinely nothing to suggest (e.g. an
5959 internal sanity check).
60+
61+ Two intermediate marker subclasses split the public hierarchy by
62+ category so host code can ``except`` on the category without
63+ enumerating individual codes:
64+
65+ * :class:`NullRunDecision` — expected policy outcomes (budget
66+ cap, tool block, rate limit, loop detection, workflow pause).
67+ The enforcement layer is doing its job; the UX is "what
68+ happened" + (where applicable) "how to proceed".
69+ * :class:`NullRunInfrastructureError` — system failures (network,
70+ backend 5xx, auth rejection, config error). The SDK could not
71+ reach or query the policy engine; the UX is a generic
72+ "service unavailable" with operator triage info.
73+
74+ Both inherit from :class:`NullRunError`, so existing
75+ ``except NullRunError:`` clauses keep matching — the split is a
76+ strict refinement, not a breaking change. ``WorkflowKilledInterrupt``
77+ is **not** in either category: it remains a ``BaseException``
78+ subclass so kill signals bypass any ``except Exception:`` that
79+ might otherwise swallow them.
6080 """
6181
6282 #: Default error code when a subclass does not override it.
@@ -120,6 +140,56 @@ def __init__(
120140 super ().__init__ (message )
121141
122142
143+ # ---------------------------------------------------------------------------
144+ # Category marker classes
145+ # ---------------------------------------------------------------------------
146+ # These two classes split the NullRunError hierarchy by what kind of
147+ # event the exception represents. They are pure markers — no new fields,
148+ # no constructor changes. Host code can use them as the catch-all for
149+ # a category without enumerating individual codes:
150+ #
151+ # try:
152+ # ...
153+ # except NullRunDecision as d:
154+ # # Budget, tool block, rate limit, loop, pause — expected
155+ # return d.user_action_or_message()
156+ # except NullRunInfrastructureError as e:
157+ # # Network, 5xx, auth, config — system failure
158+ # sentry.capture_exception(e)
159+ # return "service unavailable"
160+ #
161+ # Both inherit from NullRunError so ``except NullRunError:`` keeps
162+ # matching existing handlers — the split is additive.
163+ class NullRunDecision (NullRunError ):
164+ """Marker for expected policy outcomes.
165+
166+ Includes budget caps, tool blocks, rate limits, loop detection,
167+ workflow pause, and the generic block fallback. These are NOT
168+ system failures — the enforcement layer reached a deliberate
169+ decision. UX should explain the decision and (where applicable)
170+ offer an upgrade or alternative action.
171+
172+ End-user messaging for these exceptions is stable per ``error_code``
173+ (see :mod:`nullrun.messages`) and rarely needs to mention the
174+ decision mechanism.
175+ """
176+
177+
178+ class NullRunInfrastructureError (NullRunError ):
179+ """Marker for system failures (operator-facing).
180+
181+ Includes network errors reaching the policy engine, gateway 5xx,
182+ authentication rejections, and configuration errors. End users see
183+ a generic "service unavailable" message; operators see the
184+ structured fields for triage (``error_code``, ``retryable``, and
185+ for transport errors, ``source`` / ``endpoint``).
186+
187+ Host integrations (FastAPI middleware, Slack handler, etc.)
188+ typically map these to HTTP 503 / 502 / 500 — NOT to 4xx, because
189+ the failure is on our side, not the user's.
190+ """
191+
192+
123193# ---------------------------------------------------------------------------
124194# Transport / network failures
125195# ---------------------------------------------------------------------------
@@ -142,7 +212,7 @@ class TransportErrorSource(str, Enum):
142212 AUTH_ERROR = "AUTH_ERROR" # 401 / 403 from the gateway
143213
144214
145- class NullRunTransportError (NullRunError ):
215+ class NullRunTransportError (NullRunInfrastructureError ):
146216 """Raised by transport layer when the policy engine is unreachable.
147217
148218 The exception carries a `source` (TransportErrorSource) and the
@@ -337,7 +407,7 @@ class InsecureTransportError(BreakerTransportError):
337407# ---------------------------------------------------------------------------
338408# Configuration / authentication
339409# ---------------------------------------------------------------------------
340- class NullRunConfigError (NullRunError ):
410+ class NullRunConfigError (NullRunInfrastructureError ):
341411 """Raised when the SDK is misconfigured: missing api_key, bad
342412 key format, workflow not registered, etc.
343413
@@ -354,7 +424,7 @@ class NullRunConfigError(NullRunError):
354424 retryable = False
355425
356426
357- class NullRunAuthenticationError (NullRunError ):
427+ class NullRunAuthenticationError (NullRunInfrastructureError ):
358428 """
359429 Raised when authentication fails and safe mode is required.
360430
@@ -402,7 +472,7 @@ class NullRunAuthError(NullRunAuthenticationError):
402472# ---------------------------------------------------------------------------
403473# Block decisions (budget, loop, rate, tool-block)
404474# ---------------------------------------------------------------------------
405- class NullRunBlockedException (NullRunError ):
475+ class NullRunBlockedException (NullRunDecision ):
406476 """
407477 Raised when NullRun circuit breaker trips.
408478
@@ -525,7 +595,7 @@ class NullRunToolBlockedError(NullRunBlockedException):
525595# - RateLimitExceededException
526596
527597
528- class WorkflowPausedException (NullRunError ):
598+ class WorkflowPausedException (NullRunDecision ):
529599 """
530600 Raised when workflow is paused by NullRun.
531601
0 commit comments