Skip to content

Retries and errors

Retry policy

Retrying is safe for this API: POST /v1/systemone is a stateless evaluation call, and a response that was never read is not billed to the caller. So the SDK retries by default, with the same defaults as the official TypeSafe Python and JavaScript SDKs, so behaviour is identical across languages.

RetryPolicy.Default is:

Knob Default Meaning
MaxRetries 2 Retries after the initial attempt, so at most three attempts in total. 0 disables retrying.
BackoffInitial 500 ms The delay before the first retry. Doubles for each subsequent retry.
BackoffMax 5 s The ceiling applied to the computed delay.
BackoffJitter 0.25 The fraction of each delay removed at random, from 0 to 1. Jitter stops many clients from retrying in lockstep after a shared outage.
HttpStatuses 408, 429, 500599 The status codes that are retried.
RespectRetryAfter true Whether a Retry-After or retry-after-ms header overrides the computed delay.
MaxRetryAfter 60 s The longest server-requested delay the SDK will honour. A longer one is ignored and the computed backoff is used instead.
RetryConnectionErrors true Whether a TypeSafeConnectionException is retried.
RetryTimeoutErrors true Whether a TypeSafeTimeoutException is retried.
TotalBudget 30 s Prevents a retry when its delay would reach the elapsed-time budget. It does not cancel an in-progress attempt. null removes the limit.
ShouldRetry null An extra predicate, consulted for every failure.
OnRetry null A callback invoked just before each retry.

All of the properties are init-only, so a policy is configured with an object initializer and is immutable once built:

using TypeSafeAI;

var options = new TypeSafeClientOptions
{
    Retry = new RetryPolicy
    {
        MaxRetries = 4,
        BackoffInitial = TimeSpan.FromMilliseconds(250),
        BackoffMax = TimeSpan.FromSeconds(8),
        BackoffJitter = 0.5,
        MaxRetryAfter = TimeSpan.FromSeconds(30),
        TotalBudget = TimeSpan.FromSeconds(20),
        OnRetry = attempt => Console.WriteLine(
            $"attempt {attempt.Attempt} failed with {attempt.Exception.GetType().Name}; " +
            $"retrying in {attempt.Delay.TotalMilliseconds:F0} ms"),
    },
};

To turn retrying off entirely, use RetryPolicy.None, which is the same thing as new RetryPolicy { MaxRetries = 0 }:

using TypeSafeAI;

var options = new TypeSafeClientOptions { Retry = RetryPolicy.None };

Or for a single call, through TypeSafeRequestOptions:

using TypeSafeAI;

var result = await client.SystemOneAsync(new SystemOneRequest
{
    State = "My card was charged twice.",
    Questions = [new NoulQuestion("is_urgent", "Does this convey urgency?")],
    Options = new TypeSafeRequestOptions
    {
        Retry = RetryPolicy.None,
        Timeout = TimeSpan.FromSeconds(2),
    },
});

Retrying on your own condition

ShouldRetry is consulted for every failure and can only add retries, never remove them. It cannot suppress a retry the built-in rules already allow:

using System.Net;
using TypeSafeAI;

var policy = new RetryPolicy
{
    ShouldRetry = exception => exception is TypeSafeApiException { StatusCode: HttpStatusCode.Conflict },
};

Retry-After handling

Both the delta-seconds and the HTTP-date forms of Retry-After are understood, and retry-after-ms is checked first when present, matching the sibling SDKs. A server-requested delay longer than MaxRetryAfter is ignored in favour of the computed backoff, and a retry whose delay would reach or exceed the remaining TotalBudget is not attempted at all — the last error is rethrown instead. This prevents a long server-requested delay from starting another attempt.

Per-attempt timeout versus total budget

These are two different limits, and both are needed.

Limit Default Bounds
TypeSafeClientOptions.Timeout (or TypeSafeRequestOptions.Timeout) 10 s One HTTP attempt. Applied with a linked CancellationTokenSource, so each retry gets a fresh full timeout.
RetryPolicy.TotalBudget 30 s Starting another retry. Before waiting, the SDK checks elapsed time plus the next delay against this budget.

TotalBudget is a retry-admission budget, not a hard deadline. An HTTP attempt that has already started is allowed to run until its per-attempt timeout, so the complete call can finish after the budget value. Use the caller's CancellationToken when the operation needs a strict end-to-end deadline.

An HttpClient created by the SDK uses Timeout.InfiniteTimeSpan, and the SDK applies the per-attempt limit itself. A caller-supplied HttpClient is not modified; if it has a shorter timeout, that timeout can end an attempt before the SDK's configured limit. In either case the SDK surfaces a TypeSafeTimeoutException rather than a bare TaskCanceledException.

The caller's CancellationToken cancels the entire call, including any pending retry. A cancelled token surfaces as OperationCanceledException, deliberately distinct from TypeSafeTimeoutException so that "I cancelled this" and "the server was too slow" are never confused:

using TypeSafeAI;

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));

try
{
    var result = await client.SystemOneAsync(state, questions, cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("The caller cancelled; this was not a server timeout.");
}
catch (TypeSafeTimeoutException ex)
{
    Console.WriteLine($"An attempt exceeded {ex.Timeout}.");
}

The exception hierarchy

Everything the SDK raises derives from TypeSafeException.

Exception Raised when Retried by default
TypeSafeConfigurationException The client is not configured well enough to make a request — for example no API key was supplied and TYPESAFE_API_KEY is unset. Raised before any network call. Never
TypeSafeConnectionException The request never produced an HTTP response: DNS, TLS, socket, or a response that could not be read. Yes
TypeSafeTimeoutException One attempt exceeded its per-attempt timeout. Derives from TypeSafeConnectionException and adds Timeout. Yes
TypeSafeApiException The API returned an unsuccessful HTTP response, after any retries. See the concrete types
TypeSafeBadRequestException HTTP 400. No
TypeSafeAuthenticationException HTTP 401 — the key is missing, malformed, or revoked. No
TypeSafePermissionDeniedException HTTP 403 — the key is valid but not allowed to do this. No
TypeSafeNotFoundException HTTP 404. No
TypeSafeUnprocessableEntityException HTTP 422 — the request was well-formed but the API rejected its contents. No
TypeSafeRateLimitException HTTP 429, after the retries were exhausted. Adds RetryAfter, a TimeSpan?. Yes
TypeSafeServerException HTTP 5xx, after the retries were exhausted. Yes
TypeSafeResponseValidationException A successful response whose body was missing or structurally invalid. Usually a proxy, a captive portal, or an incompatible API version rather than a transient fault. Adds FieldPath. Derives from TypeSafeApiException. No
TypeSafeApiException itself Any other unsuccessful status code. No

The hierarchy is TypeSafeExceptionTypeSafeApiException → the concrete HTTP-status types, so TypeSafeResponseValidationException is a TypeSafeApiException too. A plain (non-derived) TypeSafeApiException is what you get for a status the SDK has no specific type for, such as 409. The concrete type is chosen from the status code alone. The body's detail.error_type string is surfaced through ErrorType but never used to select an exception type, because it is not part of the documented contract and new values can appear at any time.

An empty or unrecognised answer, by contrast, is not an error: unmodelled fields are preserved and an unrecognised answer kind becomes UnknownAnswer. See forward-compatibility.md.

Reading an API failure

using TypeSafeAI;

try
{
    var result = await client.SystemOneAsync(state, questions);
}
catch (TypeSafeApiException ex)
{
    Console.Error.WriteLine($"status      {ex.StatusCode}");
    Console.Error.WriteLine($"error type  {ex.ErrorType}");
    Console.Error.WriteLine($"message     {ex.ErrorMessage}");
    Console.Error.WriteLine($"endpoint    {ex.Endpoint}");
    Console.Error.WriteLine($"request id  {ex.RequestId}");
    Console.Error.WriteLine($"docs        {ex.DocumentationUrl}");

    // The whole parsed error body, for anything the properties above do not cover.
    Console.Error.WriteLine($"body        {ex.Body}");

    // Response headers, with multiple values per header preserved.
    if (ex.Headers.TryGetValue("retry-after", out var values))
    {
        Console.Error.WriteLine($"retry-after {string.Join(", ", values)}");
    }

    // The raw detail, which the API sends as an object for application errors and as a plain
    // string for framework errors.
    Console.Error.WriteLine($"detail      {ex.Details?.Body}");
}

Every property is a snapshot taken when the response was read, so the exception stays valid after the underlying HttpResponseMessage has been disposed, and Endpoint never contains credentials, query parameters, or a fragment — it is safe to log and safe to paste into an issue.

RequestId is the value of the x-typesafe-request-id header. It is not a credential, it is what TypeSafe support asks for when investigating a specific request, and it is safe to share publicly. See SECURITY.md for the rest of that list.

Catching in the right order

Derived types must be caught before their bases, or the base clause makes them unreachable:

using TypeSafeAI;

try
{
    var result = await client.SystemOneAsync(state, questions);
}
catch (TypeSafeRateLimitException ex)
{
    Console.Error.WriteLine($"Rate limited; back off for at least {ex.RetryAfter}.");
}
catch (TypeSafeAuthenticationException)
{
    Console.Error.WriteLine("The API key is missing, malformed, or revoked.");
}
catch (TypeSafeApiException ex)
{
    Console.Error.WriteLine($"The API rejected the request: {ex.StatusCode}.");
}
catch (TypeSafeTimeoutException ex)
{
    Console.Error.WriteLine($"No response within {ex.Timeout}.");
}
catch (TypeSafeConnectionException ex)
{
    Console.Error.WriteLine($"Could not reach TypeSafe: {ex.Message}");
}
catch (TypeSafeException ex)
{
    Console.Error.WriteLine(ex.Message);
}

If you also want to distinguish a TypeSafeResponseValidationException, catch it before TypeSafeApiException, because it derives from it — a base clause makes every derived clause after it unreachable, and the compiler reports CS0160.

Errors the SDK raises locally

Argument and state validation happens before any network call, mirroring the server's rules and saving a round trip. These are bugs in the caller, not API failures, and they are worth fixing rather than catching:

Exception Cause
ArgumentException A null or whitespace question id; an empty choice; more than 255 choice options; a duplicate option label; a score rubric with fewer than 2 or more than 10 levels; empty NoulCriteria.
ArgumentNullException A null state, questions collection, request, question, or id where one is required.
ArgumentOutOfRangeException A negative RetryPolicy value; a BackoffJitter outside 01; a negative count passed to ChoiceAnswer.Top.
TypeSafeConfigurationException No API key could be resolved.
KeyNotFoundException Get, Noul, Choice, or Score was called for an id that has no answer, or for an answer of a different kind.
InvalidOperationException Get<TAnswer>(question) found an answer of a different kind than the question produces.
ObjectDisposedException The client was used after Dispose.

Logging

The SDK logs through Microsoft.Extensions.Logging. Give it a factory and it records request headers (with credential-shaped ones masked), retries, and a warning for every unrecognised answer kind:

using Microsoft.Extensions.Logging;
using TypeSafeAI;

using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Debug));

var options = new TypeSafeClientOptions { LoggerFactory = loggerFactory };
using var client = new TypeSafeClient(options);

Any request header whose name contains authorization, cookie, token, secret, or key is logged as ***. Request and response bodies are not redacted — they are your data, and rewriting them would make the logs untrustworthy for debugging. Do not enable debug logging of bodies in an environment where those logs go somewhere you would not send the data itself.

TypeSafeAI.Sdk.DependencyInjection wires the container's ILoggerFactory in automatically, and additionally stops IHttpClientFactory's own handler from logging the Authorization header at Trace level.