ADR 053 · Accepted
One Marker Interface for All Thrown Exceptions
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' fromArray() normalisation threw PHP's global \InvalidArgumentException, a different class from nr_llm's own Exception\InvalidArgumentException — the first entry in the list above exists only because of that mismatch (nr_ai_search's NrLlmChatClient documents exactly this trap).
Decision
Netresearch\NrLlm\Exception\NrLlmExceptionInterface (extending \Throwable) marks every exception this extension throws on its public API surface. The five core exceptions and ProviderException (which its five subtypes inherit from) implement it.
ChatMessage / ToolSpec / ToolCall normalisation errors now throw Exception\InvalidArgumentException instead of PHP's global class. Backwards compatible: the nr_llm class extends \InvalidArgumentException, so existing catches keep matching.
A reflection test sweeps both exception directories so a future exception class cannot ship without the marker.
Consumers can now write catch (NrLlmExceptionInterface $e) — one arm, future-proof.
Consequences
The remaining classes that imported the global InvalidArgumentException for their own validation errors (response parsers, task readers, backend response DTOs, value objects) throw Exception\InvalidArgumentException now — the compatible follow-up named here is done, guarded by the same reflection test. One deliberate exception: Service\Task\TaskInputResolver keeps the global import because it only catches the exception around RecordTableReader::fetchAll() — narrowing that catch to the nr_llm subclass would miss a plain \InvalidArgumentException raised by third-party code inside the read path.
Catch-all remains opt-in: consumers that want to handle budget exhaustion differently from provider outages keep catching the concrete classes.