5 min readThe rules of the call

Retry the right things

Learn how NResilience uses Classifiers to map outcomes to verdicts, ensuring you only retry transient failures and respect server-side throttling.

retriesclassifiers

TL;DR: Retries are only useful if the failure is transient. NResilience uses a Classifier to map exceptions and results to one of four verdicts - Ok, Transient, Throttled, or Permanent - determining whether to retry, how long to wait, and if the dependency is unhealthy.

The danger of the "blind retry"

The most common retry snippet in .NET looks something like this:

// The "blind retry" pattern
for (int i = 0; i < 3; i++)
{
    try { return await CallAsync(); }
    catch { /* retry everything */ }
}

This is dangerous. If your API returns a 404 Not Found or a 400 Bad Request, retrying is useless. You're just wasting CPU, increasing latency for the user, and hammering a server with requests that will never succeed.

The goal of a resilience library isn't just to retry; it's to classify. You need to know if a failure is a fluke (Transient), a signal to slow down (Throttled), or a hard stop (Permanent).

The four verdicts

In NResilience, every attempt ends with a Verdict. This single value drives everything the executor does next.

Verdict Meaning Action Health Impact
Ok Success. Return result. Positive
Transient A fluke (e.g., socket reset). Retry (Short backoff). Negative
Throttled Server is defending itself. Retry (Long backoff / Retry-After). Neutral
Permanent A bug or logic error. Fail immediately. Neutral

By distinguishing Transient from Throttled, the library can use different pacing. A connection reset gets a quick retry; a 429 Too Many Requests gets a long pause. Most importantly, Throttled results don't count against the dependency's health - the server is working exactly as intended by telling you to slow down.

Using the built-in Classifiers

You don't usually write your own classification logic from scratch. NResilience ships with curated defaults.

Classifier.Default

The baseline. It treats TimeoutException, IOException, and SocketException as Transient. Everything else is Permanent. This prevents you from accidentally retrying a NullReferenceException and turning a fast crash into a slow, confusing one.

Classifier.Http

The one for web calls. It extends the default with HTTP-specific knowledge:

  • HttpRequestException is Transient
  • 503 Service Unavailable or 408 Request Timeout is Transient
  • 429 Too Many Requests is Throttled, honoring the Retry-After header
  • A 503 that carries Retry-After is Throttled too
  • 404 Not Found and the other 4xx statuses are Ok - a valid answer, not a failure

Resilience.Http already carries this classifier, so you only assign it when you are building a policy from Resilience.Default or one of your own.

Classifier.Data

For database calls. It uses DbException.IsTransient to let the provider (SqlClient, Npgsql, etc.) decide what is retryable, avoiding a hard-coded list of error numbers that go stale.

Customizing classification

If you have domain-specific exceptions, you can derive a new classifier using the On<T> methods. Because classifiers are immutable, these methods return a new instance.

var myClassifier = Classifier.Http
    .On<PaymentGatewayException>(ex =>
        ex.ErrorCode == "RATE_LIMIT" ? Verdict.Throttled() : Verdict.Transient)
    .On<InvalidCardException>(Verdict.Permanent);

Rules run in reverse order of addition, so the most recently added rule for an exception type wins. Exception type matching includes subclasses.

You can also classify the returned value of a call. This is how Classifier.Http handles status codes.

var customApiClassifier = Classifier.Http.OnResult<MyResponse>(res =>
    res.IsPartialSuccess ? Verdict.Transient : Verdict.Ok);

Tuning the retry pacing

Once the classifier decides a call is Transient or Throttled, the Backoff settings determine the wait.

var policy = Resilience.Http with
{
    Attempts = 3,
    Backoff = Backoff.Exponential(
        transientBase: TimeSpan.FromMilliseconds(200),
        throttledBase: TimeSpan.FromSeconds(2),
        factor: 2.0,
        maximumDelay: TimeSpan.FromSeconds(10)
    )
};

Why two bases? A transient network glitch is often resolved in milliseconds. A throttling event usually requires a much longer cooldown. Giving them separate base delays ensures you aren't too aggressive with a defending server, but aren't too slow when a socket just needs a second to reset.

Jitter by default All backoffs in NResilience include full jitter. If 1,000 clients all fail at the same time, jitter prevents them from retrying in a perfectly synchronized wave (the "thundering herd" problem), spreading the load evenly across the backoff window.

Summary

Retrying everything is a bug. By mapping outcomes to Verdict types, NResilience ensures that:

  1. You don't retry permanent failures.
  2. You pace your retries based on the reason for the failure.
  3. You honor server-side Retry-After hints.
  4. You don't treat server-side throttling as a sign of dependency instability.

But knowing what to retry is only half the story. You also need to know how long each attempt can take. Two clocks covers the difference between a Deadline and an AttemptTimeout and why confusing them is the primary cause of requests that hang for minutes.