ADR 0141 — A provider refusal is classified, not phrased
Context
Three independently maintained consuming applications carry the same 69-line
file for turning a provider failure into something a person can read. Two copies
are byte-identical but for one catch; the oldest carries
LIBRARY FILE: no project-specific imports in its header — the author knew it
was a library and had nowhere to put it.
The runtime reported provider_failure and kept the provider’s envelope, and
said nothing about which failure. Running out of credits, being rate limited
and naming a model that does not exist are three situations with three different
next moves — top up, wait, choose another model — and all three arrived as one
word. So each application derived the taxonomy again from its own traffic, and
they disagree on details that only production reveals.
This is not a domain model (→ ADR 0002): “the provider answered 402” is a fact about the provider, not about anyone’s business. The core is where that answer arrives, and it is the only place that sees it.
Decision
The core classifies; the application phrases.
classifyProviderFailure returns the reason, the status when the provider gave
one, whether the same request could work unchanged, and — the part that makes
the rest usable — the evidence the answer rests on. A status code is the
provider stating its own answer. A message match is us reading its prose, which
changes without notice and differs between providers. none is an honest
refusal to guess. A caller deciding whether to retry automatically needs to know
which of the three it got, and a classifier that hides that difference invites
exactly the automation that should not be built on prose.
The sentence stays with the application. Its tone, its language and its decision about what to admit to a user are product choices; a core that wrote them would be answering a question nobody asked it, and every consumer would immediately need to override the wording anyway.
retryable separates waiting from rewriting: a rate limit can pass, and an
oversized prompt repeated unchanged fails identically forever. An unrecognised
failure is not retryable — a retry loop built on a guess is how one broken
request becomes a bill.
isToolResultFailure covers the other half of the copied file: a tool can
answer 200 with { error: … }, and a loop reading only the transport counts
that as work done.
Consequences
- Both are plain functions with no runtime dependency, which is the point: the
application that hand-rolls its model loop is exactly the one that copied this
file, and a primitive it cannot reach without adopting the whole runtime would
not have helped it. → the same reasoning exported
normalizeOpenRouterUsage. - The message table is a fallback and is documented as fragile. It runs only when no status survived, and it reports itself as prose-derived.
- What did not come along: extracting an ORM’s error text out of a tool failure. That is one consumer’s persistence layer showing through, and it belongs to that consumer.