Skip to content

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):

  1. a 401/403 body is blanked outright — a gateway routinely reflects the very Authorization header it just rejected back into its own error text;
  2. account identifiers (user_id, account_id, org_id, organization) are replaced with the constant LlmClient.REDACTED («redacted», :279) — the key stays so a reader can see which identifier was withheld, only the value goes;
  3. the exception’s getMessage() is additionally capped at LlmClient.ERROR_BODY_CAP (200 characters, :287) — but ProviderException.body itself 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.

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.

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);
}
}
}
  • 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 a ProviderException is thrown.
  • LlmClient.Hooks — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite.