9 min readStart here

Failure without exceptions

Stop using try/catch for control flow. Learn how TryRunAsync and CallResult<T> turn resilience outcomes into data, making fallbacks and error handling cleaner and faster.

getting-startedcall-resultserror-handling

TL;DR: TryRunAsync replaces the traditional try/catch dance with CallResult<T>. Treat failure as data, branch on the result, and implement fallbacks without the overhead of exceptions.

The try/catch dance

In most .NET apps, "did it work?" is answered with a try/catch block.

When you call a dependency, you wrap it in a catch. If it fails, you check the exception type. If it's a timeout, you maybe retry. If it's a 404, you return a default. If it's something else, you log it and fail.

The problem is that exceptions are for exceptional circumstances, not for standard control flow. Using them to decide "should I use the cache now?" is noisy. It buries your actual logic under layers of boilerplate and carries a performance cost every time a failure occurs.

Treat failure as data

NResilience provides a different path: TryRunAsync.

Instead of throwing when a resilience policy stops a call, TryRunAsync returns a CallResult<T>. This is a readonly struct, meaning it's allocated on the stack and doesn't put pressure on the GC.

It turns the outcome of your call into data you can inspect.

// The traditional way
try 
{
    var user = await api.GetUserAsync(id);
    return user;
} 
catch (Exception ex) 
{
    return _cache.GetLastKnownGood(id);
}

// The NResilience way
var result = await Resilience.Http.TryRunAsync(
    attempt => api.GetUserAsync(id, attempt), 
    cancellationToken);

if (result.TryGetValue(out var user))
{
    return user;
}

return _cache.GetLastKnownGood(id);

The TryGetValue method is the primary way to check for success. It returns true if an attempt returned a value that the classifier identified as Ok.

What is in a CallResult?

CallResult<T> carries everything you need to know about the operation without needing to parse a stack trace.

  • IsSuccess: A simple boolean. Did we get an Ok result?
  • Value: The value returned by the final attempt, or default if every attempt threw. This is populated even on failure. If the last attempt returned a 503 Service Unavailable response, you get that response here so you can dispose of it.
  • ReturnedValue: Tells you if Value actually contains something returned by the dependency, or if every attempt threw.
  • Exception: The exception from the last attempt, or the library-generated exception (like a deadline timeout).
  • Reason: A StopReason explaining why the loop stopped.
  • Attempts: A full log of every attempt, including durations and verdicts.

Branching on the stop reason

One of the biggest wins is Reason. Instead of checking if an exception is a DeadlineExceededException or a CallRejectedException, you just check the reason.

var result = await Resilience.Http.TryRunAsync(callback, token);

if (!result.IsSuccess)
{
    switch (result.Reason)
    {
        case StopReason.DeadlineExceeded:
            _logger.LogError("Call timed out after {Elapsed}ms", result.Attempts.Elapsed.TotalMilliseconds);
            break;
        case StopReason.DependencyUnavailable:
            _logger.LogWarning("Circuit breaker is open. Skipping call.");
            break;
        case StopReason.BudgetExhausted:
            _logger.LogWarning("Retry budget exhausted. Failing fast.");
            break;
        default:
            _logger.LogError("Call failed: {Exception}", result.Exception?.Message);
            break;
    }
}

This makes your error handling explicit and easy to read. You aren't guessing why a call failed based on the exception type; the library tells you exactly why it stopped.

When to use RunAsync

You might wonder why RunAsync still exists.

RunAsync is for callers who prefer the exception-based model. It rethrows the failure exception if the call doesn't succeed. If you have existing catch blocks in your architecture that you can't change, RunAsync keeps them working.

You do not have to give up the reason to use it, either. The four exceptions the library throws to end a call - CallRejectedException, DeadlineExceededException, AttemptTimeoutException and AttemptStalledException - all implement IResilienceFailure, so one catch reaches the same Reason and the same attempt log without a type switch:

catch (Exception e) when (e is IResilienceFailure failure)
{
    _logger.LogWarning("{Reason} after {Count} attempt(s)", failure.Reason, failure.Attempts.Count);
    throw;
}

It is an interface rather than a base class because three of the four derive from TimeoutException on purpose, and code that already catches TimeoutException should keep catching them.

AttemptStalledException is the one with a caveat, and it is worth knowing before you rely on the log. It fires when a transfer stops making progress - a response body or a stream that goes quiet for longer than the AttemptTimeout - and in the most common case it arrives at your read, after the call has already succeeded. An HTTP attempt ends when the response headers arrive; the body is read afterwards, so the retry loop is over before the stall exists. failure.Reason still reads correctly, but failure.Attempts is empty, because there is no attempt left to report. Set BufferResponses on the HTTP options to read the body inside the attempt instead, and a stalled body becomes an ordinary transient failure with an ordinary log - at the cost of holding the whole body in memory.

It also provides a middle ground via ValueOrThrow(). If you use TryRunAsync to handle a fallback, but then decide that a specific failure is actually fatal, call result.ValueOrThrow(). On a failed result it rethrows the original exception with its stack trace intact. ThrowIfFailed() is the same thing when you want the exception but not the value, and it is on both CallResult<T> and the void CallResult.

var result = await Resilience.Http.TryRunAsync(callback, token);

if (result.Reason == StopReason.Permanent)
{
    // A permanent failure is fatal here - rethrow the original exception
    result.ValueOrThrow();
}

return result.TryGetValue(out var val) ? val : fallback;

Streams get the same treatment

TryRunAsync isn't only for buffered calls. Hand it an IAsyncEnumerable<T> source and you get a CallResult<IAsyncEnumerable<T>>, decided at the first element:

var result = await policy.TryRunAsync(ct => client.GetStreamingDataAsync(url, ct), cancellationToken);

if (result.TryGetValue(out var stream))
{
    await foreach (var item in stream)
    {
        Process(item);
    }
}

The first element is where a stream's retry loop ends, so it is also where its outcome is decided. The one thing to remember: a successful result carries a started enumeration, so enumerate it once - or dispose it if you decide not to. Streams that fail mid-flight covers why the boundary sits there.

A note on cancellation

One detail to remember: TryRunAsync does not swallow OperationCanceledException.

If the CancellationToken you passed into the method is cancelled, the call will still throw. This is intentional. Cancellation is a request from the caller to stop everything immediately - it's not a "resilience failure" for the library to classify and wrap in a result.

Conclusion

Using exceptions for control flow is a habit we can break. By treating failure as data, your code becomes more linear, your intent becomes clearer, and your app performs better.

Branch on CallResult instead of catching your way through outcomes.

Now that failure is data you can branch on, the next question is how to make those calls efficient. One loop, one allocation covers the flat executor that powers TryRunAsync and keeps retries from becoming an allocation tax.