Skip to content

Classification ​

When a call fails, NResilience decides whether to retry, give up, or treat the failure as permanent. That decision is a verdict, and a classifier is the rule that turns an outcome into a verdict.

Classification is on by default: Resilience.Default uses Classifier.Default, and Resilience.Http uses Classifier.Http.

One classifier serves everything - retry logic, backoff curves, the attempt log, the circuit breaker, and the retry budget all read it, so they always agree on whether a failure happened. Unrecognized exception types count as Permanent, so a programming error (a null reference, a validation failure) fails fast instead of being retried into a slow, confusing failure.

Built-in classifiers ​

ClassifierTransient exceptionsUnrecognized exceptions
Classifier.DefaultTimeoutException, IOException, SocketExceptionPermanent
Classifier.HttpThe above, plus HttpRequestException and specific HTTP status codesPermanent
Classifier.DataThe above, plus any DbException the provider calls transientPermanent
Classifier.RetryEverythingEvery exceptionTransient

Classifier.Http classifies HTTP status codes by their standard meanings:

csharp
var http = Classifier.Http;

var throttled = http.ClassifyResult(value: new HttpResponseMessage(statusCode: HttpStatusCode.TooManyRequests)); // Throttled
var transient = http.ClassifyResult(value: new HttpResponseMessage(statusCode: HttpStatusCode.BadGateway)); // Transient
var answer = http.ClassifyResult(value: new HttpResponseMessage(statusCode: HttpStatusCode.NotFound)); // Ok - a 404 is an answer
StatusVerdict
429Throttled (includes Retry-After if provided by the server)
503 with Retry-AfterThrottled (includes Retry-After)
Other 5xx or 408Transient
404 and other 4xxOk (treated as a valid answer, not a failure)

A 404 is an answer, not a failure, so it is not retried. If a status is transient for your API, add a custom rule for it - see migrating a predicate.

Classify database failures ​

Classifier.Data adds one rule to Classifier.Default: a DbException is Transient when the provider says it is, and Permanent when it does not.

csharp
// Classifier.Data reads DbException.IsTransient, which maintained ADO.NET providers
// implement. This avoids using a driver package or a manual table of error numbers.
// Providers that do not implement this property report false, making Classifier.Data
// equivalent to Classifier.Default.
var db = Resilience.Default with
{
    Classifier = Classifier.Data,
    Backoff = Backoff.Constant(delay: TimeSpan.FromMilliseconds(value: 50)),
};

The judgment comes from DbException.IsTransient, which is part of the base class library, not any one driver. Microsoft.Data.SqlClient, Npgsql, and MySqlConnector all implement it, so this classifier needs no driver package reference and carries no list of error numbers to go stale.

A provider that never overrides IsTransient reports false for everything, which makes Classifier.Data behave exactly like Classifier.Default. That is the property to know before you reach for it: it is never worse than the default, so you don't have to audit your driver first.

What the provider cannot tell you is that a failure was the dependency defending itself rather than breaking. A resource-limit error is reported as transient like any other, so it takes the short backoff curve and counts as evidence against the dependency. If your provider distinguishes them, one rule of your own does too:

csharp
// Providers cannot distinguish between a dependency failing and one defending itself.
// For example, Azure SQL reports resource limits as 10928 and 10929. Both are
// throttling: they use a long backoff curve and do not count as evidence against the
// dependency's health.
var classifier = Classifier.Data.On<SqlLikeException>(e => e.Number is 10928 or 10929
    ? Verdict.Throttled()
    : Classifier.Data.ClassifyException(exception: e));

var db = Resilience.Default with { Classifier = classifier };

Add custom exception rules ​

Teach a classifier about your exception types with On.

csharp
// Classifier.Default does not retry an exception type it has never heard of - retrying a
// programming error turns a fast, clear failure into a slow, confusing one. Teaching it
// about yours is one line, and the receiver is unchanged.
var api = Resilience.Default with
{
    Classifier = Classifier.Default.On<MyDbException>(verdict: Verdict.Transient),
    Backoff = Backoff.None,
};

Rules run in reverse order of addition (most recently added first), so custom rules override derived ones. Exception type matching includes subclasses. Every call to On or OnResult returns a new classifier, so the built-in static classifiers stay immutable.

A predicate can inspect the exception for finer control:

csharp
var classifier = Classifier.Http
    .On<MyTransportException>(verdict: Verdict.Transient) // retried, short curve
    .On<MyQuotaException>(ex => Verdict.Throttled(retryAfter: ex.RetryAfter)) // retried, long curve or the server's own delay
    .On<MyValidationException>(verdict: Verdict.Permanent); // never retried

var api = Resilience.Http with { Classifier = classifier };

Classify a self-imposed refusal ​

Verdict.Throttled above is for pushback the dependency itself sent - a quota response, a 429. A different case is a refusal that never reached the dependency at all: your own admission-control check, a distributed lock, a hand-rolled limiter. Classify that to Verdict.Refused instead. The retry budget and the circuit breaker then treat it correctly - neither counts the refusal as evidence about the dependency:

csharp
public sealed class ConsensusRefusedException(TimeSpan? retryAfter = null) : Exception
{
    public TimeSpan? RetryAfter { get; } = retryAfter;
}

var api = Resilience.Default with
{
    Classifier = Classifier.Default.On<ConsensusRefusedException>(ex => Verdict.Refused(ex.RetryAfter)),
};

Verdict.Refused is named for what happened rather than for the mechanism, so it reads correctly for a guard that is not a rate limiter. This is the general form of what the shipped rate limiter does. See Building a custom guard for the full recipe, including where to throw the exception.

Classify returned results ​

Some dependencies report failures in a response envelope instead of throwing. Add rules for those results.

csharp
// Nothing is thrown: the dependency reports failure in its own envelope. A result rule is
// read by retry, the breaker and the budget alike, because they all read one classifier.
var api = Resilience.Default with
{
    Classifier = Classifier.Default.OnResult<Reply>(reply => reply.Code switch
    {
        "OK" => Verdict.Ok,
        "BUSY" => Verdict.Throttled(retryAfter: TimeSpan.FromMilliseconds(value: 50)),
        _ => Verdict.Permanent,
    }),
};

Result rules match the static result type of the call exactly, not by assignability. Any type without a registered rule counts as a success.

An exception cannot be turned into a value: if a classifier marks an exception Ok, the library treats it as "stop, do not retry" rather than as a successful result.

Inspect classifier rules ​

Print a classifier to see every active rule and its evaluation order.

csharp
// "What will this actually retry?" without reading the library's source.
Console.WriteLine(value: Classifier.Http);

ToString lists every rule in evaluation order, including the default behavior for unrecognized exceptions.

Released under the MIT License.