ProviderException
C# · package Toolnexus · SPEC §8
public sealed class ProviderException : InvalidOperationException{ public int Status { get; } // the HTTP status the provider returned public string Body { get; } // REDACTED but UNCAPPED — the raw shape, minus account ids public string? RetryAfter { get; } // the RAW Retry-After header, verbatim
public ProviderException(int status, string body, string? retryAfter, string message);}
// Shared redaction policy (ADR 0027), also usable directly:public static readonly IReadOnlyList<string> RedactedBodyKeys; // user_id, account_id, org_id, organizationpublic const string RedactionToken = "«redacted»";public const int ErrorBodyCap = 200;A non-2xx response from the model endpoint raises a typed ProviderException carrying the status
code, a redacted+capped body, and the raw Retry-After header — never a bare unstructured exception.
Before this type existed the only interface to a failed call was InvalidOperationException.Message,
so a host that wanted to log the status or honor a retry delay had to parse prose. ProviderException
still derives from InvalidOperationException, so existing catch blocks and message matching
keep working unchanged.
When to use it
Section titled “When to use it”Catch it wherever a run against a live provider can fail with a non-2xx response — a 429 rate
limit, a 402 out of credit, a 500 upstream outage — and you need the status or the raw body as
data rather than a formatted sentence. Message is the redacted, capped rendering that is safe to
log by default; Body and Status are there for a host that genuinely wants the whole thing (an
alerting rule keyed on status, a support tool that needs the provider’s real error payload).
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”using System.Net;using System.Text;using Toolnexus;
using var stub = new Stub(ctx => Stub.Json(ctx, 429, """{"error":{"message":"rate limited"}}"""));
var client = LlmClient.Create(new LlmClient.Options{ BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", Retries = 0,});
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options());LlmClient.ProviderException? e = null;try { await client.RunAsync("go", tk); }catch (LlmClient.ProviderException ex) { e = ex; }if (e is null) throw new Exception("expected a ProviderException");
if (e.Status != 429) throw new Exception($"status: {e.Status}");Console.WriteLine($"ok: {e.Status} — {e.Message}");
sealed class Stub : IDisposable{ readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}";
public Stub(Action<HttpListenerContext> handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); }
public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); }
public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } }}2. The realistic case — the raw Retry-After, and a body redacted but not capped
Section titled “2. The realistic case — the raw Retry-After, and a body redacted but not capped”using System.Net;using System.Text;using Toolnexus;
// A leaking body: 96 bytes, well inside the 200-char message cap, carrying account identifiers.const string leaky = "{\"error\":{\"message\":\"no credit\",\"user_id\":\"user_2abcdefghijkl\"," + "\"org_id\":\"org_9\",\"account_id\":\"acct_7\",\"organization\":\"acme\"}}";
using var stub = new Stub(ctx =>{ ctx.Response.Headers.Add("Retry-After", "3"); // a 3-SECOND header value Stub.Json(ctx, 402, leaky);});
var client = LlmClient.Create(new LlmClient.Options{ BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", Retries = 0,});
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options());LlmClient.ProviderException? caught = null;try { await client.RunAsync("p", tk); }catch (LlmClient.ProviderException e) { caught = e; }if (caught is null) throw new Exception("expected a ProviderException");
// The header said "3" — the field hands back that string verbatim, in every port.if (caught.RetryAfter != "3") throw new Exception($"RetryAfter: {caught.RetryAfter}");
// The typed Body field is REDACTED (account keys replaced) but UNCAPPED — the full shape survives.if (caught.Body.Contains("user_2abcdefghijkl")) throw new Exception("account id leaked into Body");if (!caught.Body.Contains(LlmClient.RedactionToken)) throw new Exception("Body must show the redaction token");if (!caught.Body.Contains("no credit")) throw new Exception("the real cause must survive redaction");
// Message is the redacted AND capped rendering — the one that reaches logs by default.if (caught.Message.Contains("user_2abcdefghijkl")) throw new Exception("account id leaked into Message");
Console.WriteLine($"ok: status={caught.Status} retryAfter={caught.RetryAfter} body-len={caught.Body.Length}");
sealed class Stub : IDisposable{ readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}";
public Stub(Action<HttpListenerContext> handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); }
public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); }
public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } }}3. Full surface — the 401/403 rule, and the ADR 0027 account-field redaction list
Section titled “3. Full surface — the 401/403 rule, and the ADR 0027 account-field redaction list”using System.Net;using System.Text;using Toolnexus;
// (a) A 401/403 body is NEVER echoed — it routinely reflects the credential that was sent,// so it never reaches Message OR Body at all.using (var stub401 = new Stub(ctx => Stub.Json(ctx, 401, "{\"error\":\"invalid key sk-live-DEADBEEF\"}"))){ var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub401.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", Retries = 0, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); LlmClient.ProviderException? e = null; try { await client.RunAsync("p", tk); } catch (LlmClient.ProviderException ex) { e = ex; } if (e is null) throw new Exception("expected a ProviderException"); if (e.Message != "LLM 401") throw new Exception(e.Message); if (e.Body.Length != 0) throw new Exception("401 body must never be echoed");}
// (b) The redacted key list (ADR 0027) is pinned and public — a host can check against it.foreach (var key in new[] { "user_id", "account_id", "org_id", "organization" }) if (!LlmClient.RedactedBodyKeys.Contains(key)) throw new Exception($"missing key: {key}");if (LlmClient.RedactionToken != "«redacted»") throw new Exception(LlmClient.RedactionToken);if (LlmClient.ErrorBodyCap != 200) throw new Exception(LlmClient.ErrorBodyCap.ToString());
Console.WriteLine("ok: 401 never echoed; redaction constants pinned");
sealed class Stub : IDisposable{ readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}";
public Stub(Action<HttpListenerContext> handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); }
public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); }
public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } }}Fields
Section titled “Fields”| Field | Type | What it is |
|---|---|---|
Status |
int |
The HTTP status the provider returned. |
Body |
string |
The response body, redacted but uncapped (ADR 0027 A5). Empty on 401/403, where the body may reflect the credential that was sent. |
RetryAfter |
string? |
The raw Retry-After header, verbatim, or null when the response sent none. Not pre-parsed — see the note above. Identical in all seven ports. |
Message (inherited) |
string |
The redacted and capped rendering — the one that reaches logs by default. |
Redaction policy (ADR 0027)
Section titled “Redaction policy (ADR 0027)”| Member | What it is |
|---|---|
RedactedBodyKeys |
user_id, account_id, org_id, organization — replaced with RedactionToken, never dropped, so the body’s shape survives. Identical across all seven ports. |
RedactionToken |
"«redacted»" — byte-identical across all seven ports. |
ErrorBodyCap |
200 — applied to Message only, never to Body. A cap is not redaction: the leaking body that motivated ADR 0027 was 96 bytes and would have sailed through a 200-char cap untouched had redaction not run first. |
See also
Section titled “See also”LlmClient.ErrorInfo— Classify an LLM error into retry/fail — the decision that runs before aProviderExceptionever reaches yourcatchblock.LlmClient.Create— The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.LlmClient.RunAsync— Send a prompt, let the loop call tools until the model stops, get a RunResult.LlmClient.Hooks— Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite.