Skip to Content
ExamplesGeneralTyped refusals

Typed refusals

Governance outcomes should be something you branch on, not something you parse. These examples run the gateway’s rejection shapes through classify() and show the exception hierarchy you write except clauses against. The discriminator is deliberately not the status code: a PII block is a 403 but is not an auth failure, and an injection block is identified by a header. They also show the one failure classify() cannot produce — GatewayUnavailable, raised when there is no HTTP response at all.

ExampleShowsNeeds
Narrative demo 02Nine captured shapes through classify(), what the hierarchy buys, the handler you write, and a dead origin raising GatewayUnavailableNothing — no gateway, no simulator
OpenAI script 04Live UpstreamRequestError, PIIDetected, TokenBudgetExceeded and AuthError on a blocking client, no asyncProxy credentials, plus policies for the PII and budget cases
OpenAI script 11A dead origin surfacing GatewayUnavailable as __cause__Nothing

Run it

make demo N=02
Expected output
════════════════════════════════════════════════════════════════════════════════════════ Demo 02 — typed refusals The gateway's rejection shapes, mapped to exceptions you can branch on. ════════════════════════════════════════════════════════════════════════════════════════ Run context ─────────── target offline — no gateway, no simulator, no credentials output masking on Nine captured responses through classify() ────────────────────────────────────────── from donkey_kit.core.errors import classify governed = classify(response) # -> a typed DonkeyError subclass client-id-missing consumer auth — a genuinely missing/wrong client id HTTP 401 classified as AuthError pii-detected PII policy — a 403 that is NOT an auth failure HTTP 403 classified as PIIDetected .policy pii-detection .entities ['Email'] token-rate-limit token budget — a 429 with an EMPTY body; state is header-only HTTP 429 classified as TokenBudgetExceeded .policy token-rate-limit .retry_after 41.728 injection-protection prompt injection — identified by a header, not a status HTTP 400 classified as PromptInjectionBlocked .policy prompt-injection-protection regex-prompt-guard regex prompt guard — 403 keyed on matched_patterns, not auth HTTP 403 classified as PromptInjectionBlocked .policy regex-prompt-guard content-safety content safety / guardrails — 403 keyed on a vendor reject header HTTP 403 classified as ContentSafetyBlocked .policy content-safety .categories ['severity_hate', 'severity_violence'] content-moderation undiscriminated moderation — no live capture, left unnamed HTTP 400 classified as PolicyViolation .policy unknown model-not-found upstream passthrough — the provider's own error, not a policy HTTP 400 classified as UpstreamRequestError .code model_not_found .error_type invalid_request_error .param model upstream-5xx provider failure — retryable, unlike every refusal above HTTP 503 classified as UpstreamModelError Why the hierarchy is shaped this way ──────────────────────────────────── PASS a PII block is a policy refusal, not an auth error PASS a token-budget 429 is also a policy refusal — so one `except PolicyViolation` catches both PASS content-safety is ContentSafetyBlocked, still a PolicyViolation PASS regex-prompt-guard is PromptInjectionBlocked with its own policy name PASS an upstream 400 is NOT a policy refusal — it is your request that is wrong, not the gateway saying no PASS the budget refusal carries retry_after, parsed from x-token-reset (milliseconds, not an epoch) PASS GatewayUnavailable is not a PolicyViolation — nothing was refused, the request never arrived PASS a transport failure has no request_id — there was no response to read the gateway's id from What that looks like in your agent ────────────────────────────────── def ask(client: Any, prompt: str) -> str: try: try: response = client.responses.create(model="gpt-4o", input=prompt) except openai.APIStatusError as exc: # the gateway answered: type it raise classify(exc.response) from exc except openai.APIConnectionError as exc: # raised on the transport, wrapped cause = exc.__cause__ # GatewayUnavailable, ModelSubstituted if isinstance(cause, DonkeyError): raise cause from cause.__cause__ # keep its own cause chain raise return response.output_text except PIIDetected as e: # 403, and e.entities says what tripped return f"redact {e.entities} and retry" except ContentSafetyBlocked as e: # 403, e.categories is the moderation analog return f"revise for {e.categories}" except TokenBudgetExceeded as e: # 429, terminal — never retry it return f"wait {e.retry_after}s for the budget to reset" except PolicyViolation as e: # any other gateway refusal return f"escalate the {e.policy} refusal" except GatewayUnavailable as e: # NO response — not a refusal return f"checkpoint and shed: {e.base_url} is unreachable" except ModelSubstituted as e: # NOT classify() — you opted in (demo 10) return f"pin or accept {e.served_model}" except UpstreamRequestError as e: # your request was wrong (e.code) return f"fix the request: {e.code}" except UpstreamModelError: # provider 5xx — this one IS retryable return "retry with backoff" The bridge is an INNER try. An exception raised inside one except clause is never handed to a sibling clause of the same try, so the typed handlers have to sit one level out. The bridge has two arms because the OpenAI client reports two ways: a refusal is an APIStatusError carrying the gateway's response, and an error the transport raises itself is an APIConnectionError with the typed DonkeyError on __cause__. PIIDetected redact ['Email'] and retry ContentSafetyBlocked revise for ['severity_hate', 'severity_violence'] TokenBudgetExceeded wait 41.728s for the budget to reset PromptInjectionBlocked escalate the prompt-injection-protection refusal UpstreamRequestError fix the request: model_not_found UpstreamModelError retry with backoff GatewayUnavailable checkpoint and shed: http://127.0.0.1:9 is unreachable PASS the flat version — typed handlers as siblings of the classify() clause — lets PIIDetected escape: its handler never runs What is typed from docs, and what is still unnamed ────────────────────────────────────────────────── Four of these shapes are live-verified against a real proxy: consumer auth, PII, token rate limit, and upstream passthrough. Injection, regex prompt guard, and content- safety are typed from the documented wire shapes — classify() produces PromptInjectionBlocked / ContentSafetyBlocked — and are pending a live sandbox capture. That is the same posture as header-based injection: named because the shape is specified, not because a capture has landed yet. content-moderation PolicyViolation remediation This refusal matched no documented rejection shape, so its contract is unconfirmed (#184, #253). It is terminal and was NOT retried. Please file an issue on the donkey-development-kit repo with the response status, headers and body (all carried on this exception's .response) so the shape can be typed. An undiscriminated content-moderation 4xx still falls through to a generic PolicyViolation. That leftover shape has never been captured from a live gateway, so it is left unnamed rather than given a class that would imply more certainty than exists. ModelSubstituted is not in the table above because it is not a gateway refusal and classify() never produces it. It is raised by the transport when you opt into on_model_substitution='raise' and the gateway serves a different model than you asked for. Demo 10. GatewayUnavailable is the other type classify() never produces: there is no HTTP response to classify. DNS, connection refused, TLS, timeout — the transport wraps those as a typed DonkeyError so a long-running agent can tell 'lost the gateway' from a policy refusal without matching raw httpx exceptions. It is not retried. Act 5 actually raises it. A refused connection, typed — not a raw httpx error ─────────────────────────────────────────────────── donkey = Donkey(DonkeyConfig(llm_proxy_url="http://127.0.0.1:9/", ...)) client.responses.create(...) # nothing is listening # -> GatewayUnavailable, not ConnectError PASS GatewayUnavailable — the request never left the building base_url http://127.0.0.1:9 cause ConnectError request_id None call_id c46d490e7aef47b18a391a2764087952 The gateway could not be reached and no HTTP response came back. The three usual causes: (1) the host is unreachable — DNS failure or the gateway is down; (2) the configured base URL is wrong; or (3) network egress to the gateway is blocked — a firewall or air-gapped environment. Run `donkey doctor` to diagnose connectivity, and check `base_url` on this error against your gateway's address. PASS not a PolicyViolation — nothing was refused, because nothing arrived ────────────────────────────────────────────────────────────────────────────────────────
python "demos/human-made/openai/04 - typed-refusals-live.py" # needs proxy credentials python "demos/human-made/openai/11 - gateway-unavailable.py" # no gateway

Narrative demo 02 loads its fixtures from the installed SDK (donkey_kit.simulator.fixtures) — the same bytes classify() is tested against and donkey mock serves. OpenAI script 04 provokes the upstream and auth cases with nothing extra; the PII case needs the PII detection policy with Email and action Reject, and the budget case needs the token rate limit policy with a small maximumTokens.

Key code

The handler shape the hierarchy is designed for (narrative demo 02, act 3 — the demo runs this exact function against a simulated refusal of each type):

def ask(client: Any, prompt: str) -> str: try: try: response = client.responses.create(model="gpt-4o", input=prompt) except openai.APIStatusError as exc: # the gateway answered: type it raise classify(exc.response) from exc except openai.APIConnectionError as exc: # raised on the transport, wrapped cause = exc.__cause__ # GatewayUnavailable, ModelSubstituted if isinstance(cause, DonkeyError): raise cause from cause.__cause__ # keep its own cause chain raise return response.output_text except PIIDetected as e: # 403, and e.entities says what tripped return f"redact {e.entities} and retry" except ContentSafetyBlocked as e: # 403, e.categories is the moderation analog return f"revise for {e.categories}" except TokenBudgetExceeded as e: # 429, terminal — never retry it return f"wait {e.retry_after}s for the budget to reset" except PolicyViolation as e: # any other gateway refusal return f"escalate the {e.policy} refusal" except GatewayUnavailable as e: # NO response — not a refusal return f"checkpoint and shed: {e.base_url} is unreachable" except ModelSubstituted as e: # NOT classify() — you opted in (demo 10) return f"pin or accept {e.served_model}" except UpstreamRequestError as e: # your request was wrong (e.code) return f"fix the request: {e.code}" except UpstreamModelError: # provider 5xx — this one IS retryable return "retry with backoff"

The bridge is an inner try. An exception raised inside one except clause is never handed to a sibling clause of the same try, so typed handlers written next to the classify() clause would never run. The bridge has two arms because the OpenAI client reports failures two ways: a gateway refusal is an openai.APIStatusError carrying the response, and an error the transport raises itself (GatewayUnavailable, ModelSubstituted) arrives as an openai.APIConnectionError with the typed error on __cause__.

A live refusal on a blocking client (OpenAI script 04):

cfg = DonkeyConfig.from_env() donkey = Donkey(cfg) client = donkey.openai(sync=True) with donkey.run(id="live-refusals-PIIDetected"): try: raw = client.responses.with_raw_response.create(model=MODEL, input=PII_PROMPT) except openai.APIStatusError as err: error = classify(err.response) print(f" REFUSED {type(error).__name__} (HTTP {err.response.status_code})") print(f" entities {getattr(error, 'entities', None)}") print(f" remediation {getattr(error, 'remediation', None)}") print(f" correlation_id {getattr(error, 'correlation_id', None)}")

When nothing is listening, the OpenAI client wraps the transport error and the typed GatewayUnavailable sits on __cause__ (OpenAI script 11):

donkey = Donkey( DonkeyConfig( llm_proxy_url="http://127.0.0.1:9/", llm_proxy_client_id="demo-client-id-not-a-real-credential", llm_proxy_client_secret="demo-client-secret-not-a-real-credential", timeout_s=2.0, max_retries=0, ) ) client = donkey.openai(sync=True) try: client.responses.create(model="gpt-4o", input="hello") except Exception as err: hit = err if isinstance(err, GatewayUnavailable) else err.__cause__ if isinstance(hit, GatewayUnavailable): print("base_url ", hit.base_url) print(hit.remediation)

A token-budget 429 and a PII 403 are both PolicyViolations, so one except PolicyViolation catches either. An upstream 400 is not — your request was wrong, the gateway did not say no. GatewayUnavailable is not a PolicyViolation either: nothing was refused, because nothing arrived. An undiscriminated content-moderation 4xx falls through to a generic PolicyViolation.

Learn more: Typed refusals

Source: narrative demo 02  · script 04  · script 11 

Last updated on