Hulkstance
Hulkstance

Reputation: 1445

Result<T> - if result has faulted, throw an exception in order to trigger Polly retries

I recently found out about a nuget called LanguageExt.Core and why it is not so efficient to throw exceptions while handling them via middleware or so.

Speaking of it, I would like to know what is the best way to simply check if the result has faulted, so I can throw the exception in order to trigger Polly's retry pattern logic.

The best I could think of:

private async Task RunAsync(Uri uri)
{
    await ConnectAsync(uri);

    var result = await ConnectAsync(uri);

    if (result.IsFaulted)
    {
        // Cannot access result.Exception because it's internal
    }

    _ = result.IfFail(ex =>
    {
        _logger.LogError(ex, "Error while connecting to the endpoint");
        throw ex;
    });

    ...
private async Task<Result<Unit>> ConnectAsync(Uri uri)
{
    if (_ws is not null)
    {
        return new Result<Unit>(new InvalidOperationException("The websocket client is already connected"));
    }

    var ws = Options.ClientFactory();
    try
    {
        using var connectTimeout = new CancellationTokenSource(Options.Timeout);
        await ws.ConnectAsync(uri, connectTimeout.Token).ConfigureAwait(false);
    }
    catch
    {
        ws.Dispose();
        return new Result<Unit>(new InvalidOperationException("Failed to connect to the endpoint"));
    }

    await _connectedEvent.InvokeAsync(new ConnectedEventArgs(uri));

    _ws = ws;
    IsRunning = true;

    return Unit.Default;
}

Upvotes: 2

Views: 1035

Answers (2)

Peter Csala
Peter Csala

Reputation: 22819

Based on the comments it seems like that ConnectAsync can fail with an InvalidOperationException inside a Result object. In this particular case the Result's IsFaulted is set to true only if an IOE is passed to the Result's ctor.

So, if you want to perform a retry then you should define your policy something like this:

var retryPolicy = Policy<Result<Unit>>
    .HandleResult(r => r.IsFaulted)
    .WaitAndRetryAsync(...)

Upvotes: 1

Heehaaw
Heehaaw

Reputation: 2827

You can just handle a result with IsFaulted == true and not throw the exceptions yourself.

This is a snippet from one of my apps:

this.policy = Policy
  .TimeoutAsync(ctx => ((MyContext)ctx).Timeout)
  .WrapAsync(Policy
    .HandleResult<Result<string>>(result => result.IsFaulted)
    .WaitAndRetryForeverAsync( // Will get stopped by the total timeout. Fits as many retries as possible.
      (retry, _) => TimeSpan.FromSeconds(retry / 2d),
      (result, retry, wait, _) => result.Result.IfFail(
        exception => this.logger.Error(exception ?? result.Exception, "Error! {Retry}, {Wait}", retry, wait)
      )
    )
  );
var policyResult = await this.policy.ExecuteAndCaptureAsync(
  async (_, ct1) => await Prelude
    .TryAsync(this.ConnectAsync(ct1))
    .MapAsync(async _ => await this.SendAsync(mimeMessage, ct1))
    .Invoke(),
  new MyContext(timeout),
  ct
);

return policyResult.Result.Match(
  _ => policyResult.Outcome == OutcomeType.Failure
    ? new InfrastructureException("Error!", policyResult.FinalException).ToResult<Unit>()
    : Unit.Default,
  e => new InfrastructureException("Error!", e).ToResult<Unit>()
);
public static class LangExtExtensions
{
    [Pure, MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static TryAsync<T> TryAsync<T>(this Task<T> self) => Prelude.TryAsync(self);

    [Pure, MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static TryAsync<T> TryAsyncSucc<T>(this T self) => Prelude.TryAsyncSucc(self);

    [Pure, MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static TryAsync<T> TryAsyncFail<T>(this Exception self) => Prelude.TryAsyncFail<T>(self);

    [Pure, MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static Try<T> TrySucc<T>(this T self) => Prelude.TrySucc(self);

    [Pure, MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static Try<T> TryFail<T>(this Exception self) => Prelude.TryFail<T>(self);

    [Pure, MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static Result<T> ToResult<T>(this Exception self) => new(self);
}

Hope this helps a litte 🙂

EDIT: some more code

You can try something like this:

public ctor()
{
  this.policy = Policy
    // There will be no exceptions thrown outside the TryAsync monad
    // Handling faulted results only is enough
    .HandleResult<Result<string>>(result => result.IsFaulted)
    .WaitAndRetryAsync(
      10,
      (retry, _) => TimeSpan.FromSeconds(retry / 2d),
      (result, retry, wait, _) => result.Result.IfFail(
        exception => this.logger.Error(exception ?? result.Exception, "Error! {Retry}, {Wait}", retry, wait)
      )
    );
}

public Task<Result<Unit>> RetryAsync()
{
  var uri = ...;
  var policyResult = await this.policy.ExecuteAndCaptureAsync(
    async (_, ct1) => await this.ConnectAsync(uri) // Should use CancellationToken!
      .MapAsync(async _ => await this.RunAsync(ct1))
      .Do(result => this.logger.Log(result))
      .Invoke(),
    new Context(),
    ct
  );
  return policyResult.Result.Match(
    _ => policyResult.Outcome == OutcomeType.Failure
      ? new Exception("Retries did not complete successfully", policyResult.FinalException).ToResult<Unit>()
      : Unit.Default,
    e => new Exception("Error even after retries", e).ToResult<Unit>()
  );
}

private Task<string> RunAsync(CancellationToken ct = default)
{
  // Logic here
  return "Success"
}

private TryAsync<Unit> ConnectAsync(Uri uri)
{
  if (_ws is not null)
  {
    return new InvalidOperationException("...").TryAsyncFail<Unit>();
  }
  
  return Prelude
    .TryAsync(async () => {
      var ws = Options.ClientFactory();
      using var connectTimeout = new CancellationTokenSource(Options.Timeout);
      await ws.ConnectAsync(uri, connectTimeout.Token).ConfigureAwait(false);
      return ws;
    })
    .Do(async ws => {
      await _connectedEvent.InvokeAsync(new ConnectedEventArgs(uri));
      _ws = ws;
      IsRunning = true;
    })
    .Map(_ => Unit.Default);
}

// The _ws is long-lived so you can move dispose logic to here
// and let service lifetime handle that for you.
// You might want to add checks for being connected to the code above too
public async ValueTask DisposeAsync()
{
  if(_ws is { IsConnected: true })
  {
    await _ws.DisconnectAsync();
  }
  await _ws?.DisposeAsync();
}

As you can see, there is no throwing and catching of exceptions. Wrapping an exception in a result (and a delegate monad) is ~100x more efficient than throwing. The TryAsync monad lets you write short and declarative code overhead of which is inconsequent compared to classical exception workflow.

Upvotes: 1

Related Questions