Skip to content

Toolnexus.ProviderError

Elixir · package toolnexus · SPEC §8

defexception [:status, :body, :retry_after, :message]
@type t :: %Toolnexus.ProviderError{
status: non_neg_integer() | nil,
body: term(),
retry_after: String.t() | nil,
message: String.t()
}
# raised as: raise Toolnexus.ProviderError, status: status, body: body, retry_after: retry_after

A non-2xx response from the model endpoint raises a typed provider error carrying the status code, a redacted+capped body, and the raw Retry-After header — never a bare unstructured exception.

Toolnexus.ProviderError is a defexception (elixir/lib/toolnexus/errors.ex:20-58), so a caller rescues it like any other Elixir exception. Its four fields are set through the standard exception/1 callback (errors.ex:58), which is what every raise site in elixir/lib/toolnexus/client.ex (e.g. client.ex:1273) actually calls — construction is never done with %ProviderError{...} struct literals directly, because the callback is where redaction happens.

  • You need to branch on the HTTP status a provider returned429 to back off harder than the built-in retry policy, 401/403 to prompt for a new key, anything else to surface as a hard failure. status is the typed field; nothing here requires parsing a message string.
  • You want the Retry-After signal without digging through headersretry_after carries the header verbatim, exactly as in every other port (see the note below; the client’s own retry loop parses it separately and consumes it before a caller ever sees the exception — see Toolnexus.Client.create).
  • You want to log or display the body safelybody is already redacted (never raw), so a caller can log it, show it in an error banner, or ship it to an error tracker without a second redaction pass.

Redaction (ADR 0027, docs/adr/0027-*.md) runs before the cap, never after: @account_keys (user_id, account_id, org_id, organization, errors.ex:47) are replaced with the constant @redacted token «redacted» (errors.ex:46) inside redact/1, and only then does cause/2 truncate the rendered message to 200 bytes with an ellipsis. A 401/403 body is never echoed at all — a gateway in front of the real provider often reflects the Authorization header back verbatim, so those two statuses render no cause text regardless of what the body contains.

1. The smallest useful call — rescue and read the typed fields

Section titled “1. The smallest useful call — rescue and read the typed fields”
alias Toolnexus.{Client, ProviderError}
plug = fn conn ->
{:ok, _raw, conn} = Plug.Conn.read_body(conn)
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(400, ~s({"error":"bad request","user_id":"u_123"}))
end
client =
Client.create(
base_url: "http://localhost",
style: "openai",
model: "gpt-x",
api_key: "test-key",
retries: 0,
http_options: [plug: plug]
)
result =
try do
Client.run(client, "hi", [])
:not_raised
rescue
e in ProviderError -> {:raised, e.status, e.body}
end
{:raised, 400, body} = result
# The response was auto-decoded to a map — redaction walks it by key, replacing the
# VALUE at the known account-identifier key, not scanning every string for the pattern.
"«redacted»" = body["user_id"]
false = inspect(body) =~ "u_123"
IO.puts("ok: ProviderError status=400 body=#{inspect(body)}")

2. retry_after — the raw header, verbatim

Section titled “2. retry_after — the raw header, verbatim”
alias Toolnexus.{Client, ProviderError}
plug = fn conn ->
{:ok, _raw, conn} = Plug.Conn.read_body(conn)
conn
|> Plug.Conn.put_resp_header("retry-after", "30")
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(429, ~s({"error":"rate limited"}))
end
client =
Client.create(
base_url: "http://localhost",
style: "openai",
model: "gpt-x",
api_key: "test-key",
retries: 0,
http_options: [plug: plug]
)
{:raised, "30"} =
try do
Client.run(client, "hi", [])
:not_raised
rescue
e in ProviderError -> {:raised, e.retry_after}
end
IO.puts("ok: ProviderError retry_after=\"30\"")

3. 401/403 never echo the body, no matter what it contains

Section titled “3. 401/403 never echo the body, no matter what it contains”
alias Toolnexus.{Client, ProviderError}
plug = fn conn ->
{:ok, _raw, conn} = Plug.Conn.read_body(conn)
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(401, ~s({"error":"Authorization: Bearer sk-super-secret-key"}))
end
client =
Client.create(
base_url: "http://localhost",
style: "openai",
model: "gpt-x",
api_key: "test-key",
retries: 0,
http_options: [plug: plug]
)
message =
try do
Client.run(client, "hi", [])
"not raised"
rescue
e in ProviderError -> e.message
end
true = message == "LLM 401"
false = message =~ "sk-super-secret-key"
IO.puts("ok: #{message}")
  • Toolnexus.Client.create — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.
  • Toolnexus.Client.run — Send a prompt, let the loop call tools until the model stops, get a RunResult.
  • Toolnexus.Client.stream — The streaming loop: text deltas, tool-call events, and suspension events as they happen.
  • Toolnexus.Client.create — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite.
  • Toolnexus.Client.create — resilience — Classify an LLM error into retry / fail, bound a run with a deadline; the retry policy that runs before a ProviderError ever reaches the caller.