ProviderError
Python · package toolnexus · SPEC §8 · python/src/toolnexus/client.py
class ProviderError(Exception): status: int # the HTTP status the provider answered with body: str # the FULL provider body, redacted, never capped text: str # deprecated alias of `body` — kept for back-compat retry_after: str | None # the RAW Retry-After header, verbatim; None if absent
# raised in place of a bare exception on any non-2xx LLM responsedef __init__(self, status: int, text: str, retry_after: str | None) -> None: ...
# back-compat alias — the type was private before it was worth catching_HttpError = ProviderErrorA non-2xx response from the model endpoint raises a typed ProviderError carrying the status
code, a redacted+capped body, and the raw Retry-After header — never a bare unstructured exception.
str(error) is the capped, presentational message ("LLM {status}: {shown}"); error.body
(and its deprecated alias error.text) carries the whole redacted body, uncapped, for a host
that caught the typed error and asked for all of it.
Two independent things happen to the body before it ever reaches your except clause:
redaction — account-identifying fields (user_id, account_id, org_id, organization)
are replaced with «redacted», values only, keys intact — and, for the message form only, a
200-byte cap. A 401/403 body is dropped entirely rather than redacted, because a gateway
will happily reflect back the credential or Authorization header it was just sent. See
ADR 0027
for why this became part of the contract rather than “whatever str(exc) happens to contain”:
a provider error was previously carrying a live user_id into whatever a host logged or
rendered, and a cap alone would not have caught it — the leak that motivated this was 96 bytes,
well under any reasonable cap.
When to use it
Section titled “When to use it”- Branching on
statuswithout string-matching an error message — retry a429yourself outside the built-in resilience layer, surface a402as “add billing”, treat a5xxas transient. - Logging or rendering the failure to a user-facing surface (an event log, a support UI, a
webhook) —
error.bodyis already safe to persist and display; it does not need its own redaction pass before it leaves the process. - Reading
retry_afterto decide how long to back off yourself, when you have disabled the built-in retry (retries=0) and are driving resilience from the outside. It is the header verbatim — see the note below.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call — catch it and branch on status
Section titled “1. The smallest useful call — catch it and branch on status”import asyncioimport jsonimport threadingfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from toolnexus import create_client, create_toolkitfrom toolnexus.client import ProviderError
class StubServer: def __init__(self, handler): outer = self
class H(BaseHTTPRequestHandler): def log_message(self, *a): pass
def do_POST(self): # noqa: N802 length = int(self.headers.get("Content-Length", 0)) self.rfile.read(length) outer._handler(self)
self._handler = handler self._server = ThreadingHTTPServer(("127.0.0.1", 0), H) self.port = self._server.server_address[1] self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
@property def base_url(self) -> str: return f"http://127.0.0.1:{self.port}/v1"
def __enter__(self): self._thread.start() return self
def __exit__(self, *exc): self._server.shutdown() self._server.server_close()
def _send(req, status, payload, headers=None): body = json.dumps(payload).encode("utf-8") req.send_response(status) req.send_header("Content-Type", "application/json") req.send_header("Content-Length", str(len(body))) for k, v in (headers or {}).items(): req.send_header(k, v) req.end_headers() req.wfile.write(body)
async def main(): tk = await create_toolkit()
def handler(req): _send(req, 402, {"error": {"message": "add billing to continue"}})
try: with StubServer(handler) as srv: client = create_client( base_url=srv.base_url, style="openai", model="test-model", api_key="test-key", retries=0, ) try: await client.run("hi", tk) raised = None except ProviderError as e: raised = e
assert raised is not None assert raised.status == 402 assert "add billing" in raised.body
print("ok: status", raised.status, "|", raised.body) finally: await tk.close()
asyncio.run(main())2. The realistic case — account identifiers are redacted, not merely capped
Section titled “2. The realistic case — account identifiers are redacted, not merely capped”import asyncioimport jsonimport threadingfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from toolnexus import create_client, create_toolkitfrom toolnexus.client import ProviderError
class StubServer: def __init__(self, handler): outer = self
class H(BaseHTTPRequestHandler): def log_message(self, *a): pass
def do_POST(self): # noqa: N802 length = int(self.headers.get("Content-Length", 0)) self.rfile.read(length) outer._handler(self)
self._handler = handler self._server = ThreadingHTTPServer(("127.0.0.1", 0), H) self.port = self._server.server_address[1] self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
@property def base_url(self) -> str: return f"http://127.0.0.1:{self.port}/v1"
def __enter__(self): self._thread.start() return self
def __exit__(self, *exc): self._server.shutdown() self._server.server_close()
def _send(req, status, payload): body = json.dumps(payload).encode("utf-8") req.send_response(status) req.send_header("Content-Type", "application/json") req.send_header("Content-Length", str(len(body))) req.end_headers() req.wfile.write(body)
async def main(): tk = await create_toolkit()
def handler(req): _send(req, 400, { "error": {"message": "no credit", "user_id": "user_2abc", "org_id": "org_9"} })
try: with StubServer(handler) as srv: client = create_client( base_url=srv.base_url, style="openai", model="test-model", api_key="test-key", retries=0, ) try: await client.run("hi", tk) raised = None except ProviderError as e: raised = e
assert raised is not None assert "user_2abc" not in raised.body and "org_9" not in raised.body assert raised.body.count("«redacted»") == 2 assert raised.text == raised.body # `text` is a deprecated alias, same value
print("ok:", raised.body) finally: await tk.close()
asyncio.run(main())3. The full surface — retry_after, the 200-byte message cap, and a 401 dropped entirely
Section titled “3. The full surface — retry_after, the 200-byte message cap, and a 401 dropped entirely”import asyncioimport jsonimport threadingfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from toolnexus import create_client, create_toolkitfrom toolnexus.client import ProviderError
class StubServer: def __init__(self, handler): outer = self
class H(BaseHTTPRequestHandler): def log_message(self, *a): pass
def do_POST(self): # noqa: N802 length = int(self.headers.get("Content-Length", 0)) self.rfile.read(length) outer._handler(self)
self._handler = handler self._server = ThreadingHTTPServer(("127.0.0.1", 0), H) self.port = self._server.server_address[1] self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
@property def base_url(self) -> str: return f"http://127.0.0.1:{self.port}/v1"
def __enter__(self): self._thread.start() return self
def __exit__(self, *exc): self._server.shutdown() self._server.server_close()
def _send(req, status, payload, headers=None): body = json.dumps(payload).encode("utf-8") req.send_response(status) req.send_header("Content-Type", "application/json") req.send_header("Content-Length", str(len(body))) for k, v in (headers or {}).items(): req.send_header(k, v) req.end_headers() req.wfile.write(body)
async def main(): tk = await create_toolkit()
# --- retry_after: the RAW header, verbatim --- def rate_limited(req): _send(req, 429, {"error": "slow down"}, headers={"Retry-After": "2"})
try: with StubServer(rate_limited) as srv: client = create_client( base_url=srv.base_url, style="openai", model="test-model", api_key="test-key", retries=0, ) try: await client.run("hi", tk) raised = None except ProviderError as e: raised = e assert raised.retry_after == "2"
# --- the 200-byte cap applies to the MESSAGE only, never to `.body` --- long_body = json.dumps({"error": "x" * 600})
def long_error(req): _send(req, 500, {"error": "x" * 600})
with StubServer(long_error) as srv2: client2 = create_client( base_url=srv2.base_url, style="openai", model="test-model", api_key="test-key", retries=0, ) try: await client2.run("hi", tk) raised2 = None except ProviderError as e: raised2 = e assert len(raised2.body) > 200 # the typed field carries all of it assert "…" in str(raised2) # the printed message is capped + ellipsis
# --- a 401 body never surfaces at all, even redacted --- def unauthorized(req): _send(req, 401, {"error": "Bearer sk-live-xyz rejected"})
with StubServer(unauthorized) as srv3: client3 = create_client( base_url=srv3.base_url, style="openai", model="test-model", api_key="test-key", retries=0, ) try: await client3.run("hi", tk) raised3 = None except ProviderError as e: raised3 = e assert raised3.body == "" assert "sk-live" not in str(raised3)
print("ok: retry_after", raised.retry_after, "| capped:", str(raised2)[-20:]) finally: await tk.close()
asyncio.run(main())ProviderError fields
Section titled “ProviderError fields”| Field | Type | What it is |
|---|---|---|
status |
int |
The HTTP status the provider answered with. |
body |
str |
The FULL provider body, account identifiers redacted to «redacted», never capped. Empty for 401/403. |
text |
str |
Deprecated alias of body — identical value, kept for back-compat. |
retry_after |
str | None |
The raw Retry-After header, verbatim. None only when the response sent no such header — an un-honourable value like an HTTP-date still arrives intact. Identical in all seven ports. |
str(error) is "LLM {status}: {shown}" where shown is body capped at 200 characters
(MAX_ERROR_BODY) with a trailing … — the presentational form. Reach into .body (or the
deprecated .text) for the whole redacted body.
See also
Section titled “See also”ErrorClassifier— the resilience layer this error type feeds:on_errorreceives anErrorInfowith the samestatus/retryablefacts and decides retry-vs-fail per attempt.create_client— The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.Client.run— Send a prompt, let the loop call tools until the model stops, get a RunResult.Hooks— Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite.