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
) {
Copied!

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.