LlmClient.ProviderException
Java · package io.github.muthuishere:toolnexus · SPEC §8 · LlmClient.java
public static final class ProviderException extends RuntimeException { public final int status; // the HTTP status the provider answered with public final String body; // redacted, NOT capped — the whole thing (A5) public final String retryAfter; // the raw Retry-After header value, or null}A non-2xx response from the model endpoint raises a typed provider error carrying the status
code, a redacted+capped body, and the retry-after signal — never a bare unstructured exception.
ProviderException is nested inside LlmClient (LlmClient.java:332) and built by the private
providerError(status, rawBody, retryAfter) factory (:350) from every non-2xx response the
client sees.
Before the exception is ever constructed, the body goes through the ONE error-body policy shared
by the §8 client path and the §8B classifier path (redactErrorBody(), :300):
- a
401/403body is blanked outright — a gateway routinely reflects the veryAuthorizationheader it just rejected back into its own error text; - account identifiers (
user_id,account_id,org_id,organization) are replaced with the constantLlmClient.REDACTED(«redacted»,:279) — the key stays so a reader can see which identifier was withheld, only the value goes; - the exception’s
getMessage()is additionally capped atLlmClient.ERROR_BODY_CAP(200characters,:287) — butProviderException.bodyitself is never truncated (ADR 0027). A cap is a message-display policy; redaction is a leak policy. They are separate for a reason: a host that opted into the typed field asked for the whole (redacted) body, not a preview of it.
When to use it
Section titled “When to use it”Catch LlmClient.ProviderException whenever a host needs to branch on why the model provider
rejected a call rather than regex-parsing getMessage() — telling a 402 (top up) from a 429
(back off) from a 500 (retry), reading the raw Retry-After header to schedule a resume, or
surfacing the provider’s own error text to an end user without leaking the account identifier or
API key it echoed back. It is thrown after retries are exhausted (or immediately, for a
non-retryable status) — the same 200-char-capped, redacted body reaches both the exception’s
getMessage() and its body field, just capped differently.
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 read the typed fields
Section titled “1. The smallest useful call — catch it and read the typed fields”import com.sun.net.httpserver.HttpServer;import io.github.muthuishere.toolnexus.*;import java.io.OutputStream;import java.net.InetSocketAddress;import java.nio.charset.StandardCharsets;
public class Example { public static void main(String[] args) throws Exception { HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); server.createContext("/", ex -> { byte[] b = "{\"error\":\"slow down\"}".getBytes(StandardCharsets.UTF_8); ex.getResponseHeaders().add("Content-Type", "application/json"); ex.getResponseHeaders().add("Retry-After", "1"); ex.sendResponseHeaders(429, b.length); try (OutputStream os = ex.getResponseBody()) { os.write(b); } }); server.start();
try (Toolkit tk = Toolkit.create(new Toolkit.Options())) { LlmClient client = LlmClient.create(new LlmClient.Options() .baseUrl("http://127.0.0.1:" + server.getAddress().getPort()) .style("openai").model("test-model").apiKey("test-key") .retries(0));
try { client.run("hi", tk); throw new AssertionError("expected a ProviderException"); } catch (LlmClient.ProviderException e) { if (e.status != 429) throw new AssertionError(e.status); if (!e.body.contains("slow down")) throw new AssertionError(e.body); if (!"1".equals(e.retryAfter)) throw new AssertionError(e.retryAfter); System.out.println("ok: status=" + e.status + " retryAfter=" + e.retryAfter); } } finally { server.stop(0); } }}2. The realistic case — account identifiers never leave the process
Section titled “2. The realistic case — account identifiers never leave the process”The provider’s own 4xx body routinely echoes back an account or org id. ProviderException.body
carries the redacted body, with the key preserved so the caller still knows which field was
withheld — the value itself never reaches a log line.
import com.sun.net.httpserver.HttpServer;import io.github.muthuishere.toolnexus.*;import java.io.OutputStream;import java.net.InetSocketAddress;import java.nio.charset.StandardCharsets;
public class Example { public static void main(String[] args) throws Exception { String leaky = "{\"error\":{\"message\":\"not a valid model ID\",\"code\":400}," + "\"user_id\":\"user_2FAKEFAKEFAKEFAKEFAKE\"}";
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); server.createContext("/", ex -> { byte[] b = leaky.getBytes(StandardCharsets.UTF_8); ex.getResponseHeaders().add("Content-Type", "application/json"); ex.sendResponseHeaders(400, b.length); try (OutputStream os = ex.getResponseBody()) { os.write(b); } }); server.start();
try (Toolkit tk = Toolkit.create(new Toolkit.Options())) { LlmClient client = LlmClient.create(new LlmClient.Options() .baseUrl("http://127.0.0.1:" + server.getAddress().getPort()) .style("openai").model("test-model").apiKey("test-key"));
try { client.run("hi", tk); throw new AssertionError("expected a ProviderException"); } catch (LlmClient.ProviderException e) { if (e.body.contains("user_2FAKE")) throw new AssertionError("leaked: " + e.body); if (!e.body.contains(LlmClient.REDACTED)) throw new AssertionError(e.body); // the cause survives — only the identifier goes if (!e.body.contains("not a valid model ID")) throw new AssertionError(e.body); System.out.println("ok: redacted body=" + e.body); } } finally { server.stop(0); } }}3. The full surface — the message is capped, the typed body is not
Section titled “3. The full surface — the message is capped, the typed body is not”import com.sun.net.httpserver.HttpServer;import io.github.muthuishere.toolnexus.*;import java.io.OutputStream;import java.net.InetSocketAddress;import java.nio.charset.StandardCharsets;
public class Example { public static void main(String[] args) throws Exception { String longBody = "{\"error\":\"" + "x".repeat(500) + "\"}";
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); server.createContext("/", ex -> { byte[] b = longBody.getBytes(StandardCharsets.UTF_8); ex.getResponseHeaders().add("Content-Type", "application/json"); ex.sendResponseHeaders(400, b.length); try (OutputStream os = ex.getResponseBody()) { os.write(b); } }); server.start();
try (Toolkit tk = Toolkit.create(new Toolkit.Options())) { LlmClient client = LlmClient.create(new LlmClient.Options() .baseUrl("http://127.0.0.1:" + server.getAddress().getPort()) .style("openai").model("test-model").apiKey("test-key"));
try { client.run("hi", tk); throw new AssertionError("expected a ProviderException"); } catch (LlmClient.ProviderException e) { // The MESSAGE is capped at 200 chars (+ "LLM 400: " + an ellipsis). The cap // itself (ERROR_BODY_CAP) is package-private — it's LlmClient's own policy, // not part of the public surface — so a caller checks against the literal. if (e.getMessage().length() >= 200 + 40) { throw new AssertionError("message should be capped: " + e.getMessage().length()); } // the TYPED FIELD is the whole redacted body — A5, never truncated if (e.body.length() <= 200) { throw new AssertionError("typed body must not be capped: " + e.body.length()); } System.out.println("ok: message=" + e.getMessage().length() + " chars, body=" + e.body.length() + " chars"); } } finally { server.stop(0); } }}See also
Section titled “See also”LlmClient.create— The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.LlmClient.run— Send a prompt, let the loop call tools until the model stops, get a RunResult.LlmClient.stream— The streaming loop: text deltas, tool-call events, and suspension events as they happen.ErrorInfo/ resilience — The retry-classification seam:onError,retries,retryableStatuses,timeoutMs,CancelToken— runs before aProviderExceptionis thrown.LlmClient.Hooks— Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite.