ADR-053: One marker interface for all thrown exceptions
- Status
-
Accepted
- Date
-
2026-07-12
- Authors
-
Netresearch DTT GmbH
Context
A consumer that wraps an nr_llm call and wants to convert failures into its own domain exception has to enumerate concrete classes today:
} catch (
InvalidArgumentException // PHP's own, from ChatMessage/ToolSpec::fromArray()
| NrLlmInvalidArgumentException // options validation
| ProviderException // covers the 5 provider subtypes
| BudgetExceededException
| AccessDeniedException
| ConfigurationNotFoundException $e
) {
Two problems. The list goes stale silently: when a future version adds
or rethrows a new exception type, existing catch lists let it escape as
an uncaught 500 instead of the consumer's clean error path. And the
chat/tool value objects' from normalisation threw PHP's
global \Invalid, a different class from nr_llm's
own Exception\ — the first entry in the
list above exists only because of that mismatch (nr_'s
Nr documents exactly this trap).
Decision
Netresearch\(extendingNr Llm\ Exception\ Nr Llm Exception Interface \Throwable) marks every exception this extension throws on its public API surface. The five core exceptions andProvider(which its five subtypes inherit from) implement it.Exception Chat/Message Tool/Spec Toolnormalisation errors now throwCall Exception\instead of PHP's global class. Backwards compatible: the nr_llm class extendsInvalid Argument Exception \Invalid, so existing catches keep matching.Argument Exception - A reflection test sweeps both exception directories so a future exception class cannot ship without the marker.
Consumers can now write catch — one
arm, future-proof.
Consequences
- The remaining classes that imported the global
Invalidfor their own validation errors (response parsers, task readers, backend response DTOs, value objects) throwArgument Exception Exception\now — the compatible follow-up named here is done, guarded by the same reflection test. One deliberate exception:Invalid Argument Exception Service\keeps the global import because it only catches the exception aroundTask\ Task Input Resolver Record— narrowing that catch to the nr_llm subclass would miss a plainTable Reader:: fetch All () \Invalidraised by third-party code inside the read path.Argument Exception - Catch-all remains opt-in: consumers that want to handle budget exhaustion differently from provider outages keep catching the concrete classes.