Governed error taxonomy
LiveThe proxy doesn’t just pass model calls through — it enforces policy. When it rejects a call, DDK turns the response into a typed exception so you branch on the governance outcome instead of parsing bodies.
The rejection shapes classify() types
classify() types eight rejection shapes. Neither the status code nor the
shape of the error value alone is a sufficient discriminator — a 403 can be
PII, a regex-guard block, a content-safety block (all policy blocks) or auth,
and the same nested-object envelope is emitted by both the upstream provider and
a gateway policy. The authoritative discriminator is the error type plus
specific headers.
| Rejection | HTTP | Discriminator | Maps to |
|---|---|---|---|
| Client-ID enforcement (auth) | 401 | flat {"error":"…"} + www-authenticate: Client-ID-Enforcement | AuthError |
| PII detected | 403 | nested {"error":{type:"pii_detected"}}, no www-authenticate | PIIDetected (parses entities) |
| Injection protection | 400 | header x-injection-protection: blocked (not the status) | PromptInjectionBlocked |
| Regex prompt guard | 403 | top-level matched_patterns list (flat error) | PromptInjectionBlocked (policy="regex-prompt-guard") |
| Content safety / guardrails | 403 | header x-llm-proxy-<vendor>-…-action: reject (Azure Content Safety / Bedrock Guardrails) | ContentSafetyBlocked (parses categories) |
| Token rate limit | 429 | empty body; x-token-limit/-remaining/-reset headers (ms) | TokenBudgetExceeded (retry_after derived) |
| Content moderation (undiscriminated) | 4xx | falls through — no nested error, no injection/guard/safety discriminator | generic PolicyViolation |
| Upstream provider 4xx | 4xx | nested error object with code/type/param — in an OpenAI-style object envelope {"error":{…}} or a Gemini-style list envelope [{"error":{…}}] (status→error_type) | UpstreamRequestError |
| Upstream 5xx | 5xx | status range (no competing discriminator) | UpstreamModelError (retryable) |
PIIDetected, the regex-prompt-guard check, and the content-safety check are all
evaluated before the generic 401/403→auth rule, because each is a 403 (or
4xx) that is not an auth failure. Likewise the injection check gates on the
x-injection-protection header, so an ordinary malformed 400 stays an
ordinary refusal.
Client-ID enforcement (401) is a consumer-auth case, not one of the eight
policy-rejection rows.
The Injection Protection header is typed from the documented response shape;
classify() keys on the header discriminator alone for it.
The exception tree
All importable from donkey_kit:
DonkeyError # base of the whole tree
├─ ConfigError # misconfiguration (raised locally, pre-flight)
├─ AuthError # rejected data-plane credentials or control-plane auth
├─ PolicyViolation # base for every governance rejection
│ ├─ PIIDetected # 403, type=pii_detected; .entities
│ ├─ TokenBudgetExceeded # 429; .retry_after (seconds)
│ ├─ PromptInjectionBlocked # x-injection-protection: blocked, or regex matched_patterns
│ └─ ContentSafetyBlocked # Azure Content Safety / Bedrock Guardrails vendor reject header; .categories
├─ GatewayUnavailable # transport failure — gateway unreachable, NO response; .base_url/.cause (ungoverned)
├─ UpstreamRequestError # upstream 4xx; .code/.error_type/.param
├─ UpstreamModelError # upstream 5xx — provider error, retryable
├─ BudgetReserveReached # client-side, from budget.pace(); .fraction_used/.reserve/.reset_at
├─ ModelSubstituted # client-side, opt-in; .requested_model/.served_model/.served_provider
└─ ToolInvocationError, RegistryError, PublicationDrift
# tool access, registry and publishing (Roadmap surfaces)GatewayUnavailable is deliberately not under PolicyViolation: it is the one
ungoverned failure in the tree (see below). Everything under PolicyViolation is
something the gateway told the SDK; GatewayUnavailable is the gateway not being
there to tell it anything.
Cookbook: every exception — discriminator, retryable, next step
One row per exception you can catch, with the three facts you need to write a
handler: what tells it apart (the discriminator), whether retrying it can
ever succeed, and the next step its .remediation names. “Retryable” here
means by you — the transport already retries the only class that is safe to
(UpstreamModelError), and treats every governance refusal as terminal so it can
never burn an exhausted budget or replay a blocked prompt.
| Exception | Discriminator | Retryable? | Next step (.remediation) |
|---|---|---|---|
AuthError | Data plane: 401, or 403 + www-authenticate. Control plane: Anypoint auth-provider or connected-app token acquisition fails. | No — terminal. The async data-plane client refreshes its token and retries once on a 401, then surfaces it; control-plane token acquisition surfaces immediately. | Data plane: check the consumer client_id / client_secret pair and API Manager authorization. Control plane: check the configured auth provider; for a connected app, verify ANYPOINT_CLIENT_ID / ANYPOINT_CLIENT_SECRET and the required scopes. |
PIIDetected | 403, nested type: "pii_detected", no www-authenticate | No — a PolicyViolation, never retried. | Remove or redact the flagged values (.entities), or relax the policy’s entity list in API Manager. |
TokenBudgetExceeded | 429, empty body, x-token-* headers | Not immediately — never auto-retried; only worth retrying after the window resets. | Wait for .retry_after (seconds) / the reset, then retry — or request an increase in API Manager. |
PromptInjectionBlocked | header x-injection-protection: blocked, or a top-level matched_patterns list (regex prompt guard) | No — a PolicyViolation, never retried. | Review and sanitise the untrusted input, or adjust the policy’s sensitivity / deny-list in API Manager. |
ContentSafetyBlocked | 403 + x-llm-proxy-<vendor>-…-action: reject (Azure Content Safety / Bedrock Guardrails) | No — a PolicyViolation, never retried. | Revise the flagged content (.categories), or adjust the policy’s categories / severity thresholds in API Manager. |
PolicyViolation (generic) | a 4xx matching no known rejection shape | No — terminal. | Inspect .response; file an issue with the status/headers/body so the shape can be typed. |
UpstreamRequestError | non-429 4xx, nested error with code/type/param (object or Gemini list envelope) | No — a client-side request mistake passed through the gateway, terminal. | Fix the flagged model or parameter (.code / .param); if model_not_found, request the model in API Manager. |
UpstreamModelError | 5xx | Yes — the transport already retries 502 / 503 / 504; a persistent 5xx is safe for you to retry too. | Transient provider failure — retry, then escalate if it persists. |
GatewayUnavailable | transport failure — DNS, refused connection, TLS, timeout — with no HTTP response | Not automatically — terminal here; you may retry or fall back. | Check host reachability, .base_url, and network egress; run donkey doctor. |
Two more DonkeyErrors are client-side signals, not gateway refusals, so
they sit outside the retry question. BudgetReserveReached is raised before a
call by donkey.budget.pace() and is meant to be recovered from
(await donkey.budget.wait_for_reset(), then continue) when its .reset_at is
known. If .reset_at is None, propagate or handle it instead — waiting returns
immediately and an unconditional retry would spin. Its .remediation carries
that branch as an inspectable next step, so you don’t have to parse the
exception message. ModelSubstituted reports that a call succeeded against a
different model than requested (opt-in via on_model_substitution="raise").
ConfigError is raised locally, pre-flight, and reports every missing field at
once — fix the config and re-run.
AuthError.remediation follows the plane that failed. Errors classified from
an LLM-proxy response use the canonical consumer-credential guidance that
donkey doctor also prints. Control-plane token failures override that
default with guidance for the provider that failed: connected-app errors point
to the Anypoint credentials and scopes, while an exhausted ChainedAuth points
to each configured provider’s credential or token source.
When the gateway can’t be reached at all
Every rejection above describes something the gateway told the SDK.
GatewayUnavailable is the opposite: a transport-level failure — DNS, refused
connection, TLS error or timeout — with no HTTP response behind it. It is the
one ungoverned failure the taxonomy names, so a long-running agent can tell
“lost the gateway” apart from any other network fault and react — checkpoint,
queue, shed load, or fall back to a non-AI path — instead of pattern-matching a
raw httpx exception.
DonkeyAsyncClient and its blocking twin both raise it, so the async and sync
surfaces behave identically. It is terminal and not retried. It carries:
.base_url— the origin that failed, on the exception, not only in the message..cause— the underlyinghttpxexception (also chained viaraise … from)..request_id— alwaysNone; there was no response to read the upstream provider’s id from..correlation_id/.call_id— the run and per-call ids the client sent, carried even though no response came back, so the failure joins your logs like any other.
Its .remediation names the three real causes — an unreachable host, a wrong
base URL, or blocked network egress — and points at donkey doctor for
connectivity diagnosis.
Every refusal names a next step
Every PolicyViolation carries a non-empty, human-readable remediation — the
constructor raises if you try to build one without it — and each concrete
subclass ships a canonical default:
PIIDetected→ remove or redact the flagged values, or relax the policy’s entity list in API Manager.TokenBudgetExceeded→ wait for the window to reset (seeretry_after) or request an increase.PromptInjectionBlocked→ review and sanitise the untrusted input, or adjust the policy’s sensitivity.ContentSafetyBlocked→ revise the flagged content, or adjust the policy’s categories / severity thresholds.
The text names the action you can take, not the policy that fired. Because
each default lives on the exception class, it is a single source of wording that
donkey doctor reuses for its own failure output, so the CLI and the
exception never disagree.
The ids every DonkeyError carries
Every exception in the tree carries three ids so you can join a failure to your logs and to the gateway’s own record:
| Attribute | What it is | Provenance |
|---|---|---|
.correlation_id | The run id, shared by every call in a donkey.run() block | The X-Correlation-Id request header the client sent — always equals what went on the wire. |
.call_id | The per-call id, unique per logical request and stable across that request’s retries | The X-Donkey-Request-Id request header the client sent. Present even when the request fails before any response (a transport error). |
.request_id | The upstream provider’s own id, passed through by the gateway | Read back from a response header whose name varies by provider (x-request-id for OpenAI, x-amzn-requestid for Bedrock, apim-request-id for Azure). Quote it to the provider’s support team. Absent on a transport error, or on a route where the provider forwarded none. |
classify(response) fills .correlation_id and .call_id from the response’s
own request, so bridging an openai error (below) needs no extra wiring — the
correlation id on the exception equals the header that was actually sent. (If you
overrode the header names in config, pass the ids to classify() explicitly.)
Bridging from the raw client
donkey.llm.client() is the OpenAI SDK, so on an HTTP failure it raises
openai.APIStatusError, not a DonkeyError. Bridge into the taxonomy by
applying classify() to the error’s .response.
import openai
from donkey_kit import PIIDetected, TokenBudgetExceeded, AuthError
from donkey_kit.core.errors import classify
try:
resp = await client.chat.completions.create(model="gpt-4o", messages=msgs)
except openai.APIStatusError as e:
governed = classify(e.response) # -> a DonkeyError subclass
if isinstance(governed, PIIDetected):
print("blocked, entities:", governed.entities)
elif isinstance(governed, TokenBudgetExceeded):
print("slow down; retry after", governed.retry_after, "s")
elif isinstance(governed, AuthError):
print("bad credentials:", governed)
else:
print(f"{type(governed).__name__}: {governed}")
except openai.APIConnectionError as e:
print("could not reach the proxy:", e)The blocking client from donkey.llm.client(sync=True) behaves identically here
— drop the await. It is the same OpenAI SDK raising the same
openai.APIStatusError, and classify() reads the response the same way.
Retry behaviour
Both clients retry only transient upstream/gateway failures (502/503/504) and
treat every 4xx as terminal — including a 429: on this proxy a 429 is a
token-budget refusal (TokenBudgetExceeded), so retrying it would only burn the
same already-exhausted window. retry_after is still surfaced for you to pace
against, but the transport never silently retries it.
The async client additionally refreshes its token and retries once on a 401,
because it may carry an Anypoint control-plane credential. The blocking client
holds no such credential, so a 401 there is terminal and surfaces immediately as
AuthError.
Unrecognised shapes
Any content-moderation or federated-guardrail response that matches none of the
discriminators above falls through to a generic PolicyViolation rather than an
invented type. DDK only types a refusal by a discriminator it can identify
reliably; everything else stays inspectable via .response.