Skip to content

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, organization
public 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.

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

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 { }
}
}
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.
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.
  • LlmClient.ErrorInfo — Classify an LLM error into retry/fail — the decision that runs before a ProviderException ever reaches your catch block.
  • 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.