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. Core, provider, agent, specialized, skill and tool exception hierarchies implement it. The compatible Exception\RuntimeException and Exception\LogicException subclasses cover internally generated errors without a more specific failure type; the existing Exception\InvalidArgumentException covers validation, including delegated credentials and editor actions.
  • 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 the complete Classes/ tree, independently of exception names and directories, so a future exception class cannot ship without the marker. A parser-based check also rejects directly constructed native validation, runtime and logic errors in this tree. Consumer-boundary tests verify common catches and native compatibility.

Consumers can write catch (NrLlmExceptionInterface $e) for nr_llm-defined exceptions. Propagated dependency exceptions still need their own catch arm when the consumer wants to handle them.

The marker covers errors generated by nr_llm. It does not convert exceptions originating in third-party callbacks or dependencies, nor PHP engine errors. Callers of the tool loop handle its approval/input suspension types before the common marker catch to retain their resumable state.

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.

Scope clarification (2026-10-10) 

The marker covers exception classes defined by nr_llm. Dependency exceptions that propagate through a public call retain their original class. The bundled streaming adapters, for example, propagate PSR-18 network failures directly. Seven executable adapter contract cases preserve that behavior; they do not claim that the marker catches every dependency failure or every PHP error. See Streaming support for the pre-first-chunk fallback boundary.