ADR-080: Typed provider HTTP exceptions (authentication 401, rate-limit 429)
- Status
-
Accepted
- Date
-
2026-07-18
- Authors
-
Netresearch DTT GmbH
Context
A provider's 4xx responses were all flattened into one class. The standard
adapter path (Abstract and
assert) mapped every 4xx — including 401
(authentication) and 429 (rate limit) — to a generic
Provider carrying the numeric http. A consumer that
wanted to react differently to "the API key is wrong" versus "we are being
throttled" (a controller turning either into user-facing copy, a retry policy)
had to inspect get/$http or, worse, re-parse the message string
— the exact fragile string-matching ADR-053's marker interface
was meant to end.
The mapping was also inconsistent across adapters:
Open routed 401 to
Provider and 429 to Provider, so the
same HTTP status produced a different class on OpenRouter than on the other six
providers.
Decision
Introduce two status-specific subclasses of `ProviderResponseException`:
Provider (401) and Provider (429).
They are empty final subclasses — they inherit the constructor and the typed
http / response / endpoint fields unchanged. Provider
drops its own final so it can be extended; it stays otherwise identical.
A private Abstract factory picks the class from
the status (401 → authentication, 429 → rate-limit, every other 4xx → the base
Provider) and is used by both the buffered and the streaming
4xx branches, so the two paths never drift. Open is realigned to
the same 401/429 classes (402 stays Provider, 503 stays
Provider).
Backward compatibility is preserved by inheritance:
catch (ProviderResponseException)still catches a 401 or 429 — the new classes areProvider.Response Exception catch (ProviderException)andcatch (\Netresearch\NrLlm\Exception\NrLlmExceptionInterface)are unaffected.getCode()still returns the HTTP status, so the retry/fallback (Fallback) and circuit-breaker (Middleware Circuit) checks that key offBreaker Middleware getCode() === 429keep firing for the rate-limit class.
Consequences
- Consumers branch on the exception class (
catch (ProviderRateLimitException)) instead of the status code or the message text. - Behaviour change on OpenRouter only: a 401 is now
Provider(wasAuthentication Exception Provider) and a 429 is nowConfiguration Exception Provider(wasRate Limit Exception Provider). Both remainConnection Exception Provider/Response Exception Provider, and 429 keepsException getCode() === 429, so retry/circuit-breaker semantics are unchanged; only a consumer catching those two specific OpenRouter classes sees the more correct type. The realignment is a separate commit in the introducing PR. ProviderResponseExceptionis no longerfinal; the two shipped subclasses are the only intended extensions.- The exceptions carry no
Retry-value — none is parsed today; adding it is a separate change.After