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_afterA 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.
When to use it
Section titled “When to use it”- You need to branch on the HTTP status a provider returned —
429to back off harder than the built-in retry policy,401/403to prompt for a new key, anything else to surface as a hard failure.statusis the typed field; nothing here requires parsing a message string. - You want the
Retry-Aftersignal without digging through headers —retry_aftercarries 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 — seeToolnexus.Client.create). - You want to log or display the body safely —
bodyis 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.
Why this and not the alternative
Section titled “Why this and not the alternative”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.
Examples
Section titled “Examples”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}")See also
Section titled “See also”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 aProviderErrorever reaches the caller.