Any way to differentiate Cancel and Timeout

2020-02-25 09:09发布

问题:

I have some code that is validating some data by making calls to a number of other services. I start all of the calls in parallel and then wait until at least one of them finishes. If any of the requests fail, I don't care about the result of the other calls.

I make the calls with HttpClient and I have passed an HttpMessageHandler in that does a bunch of logging. Essentially:

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
    HttpResponseMessage response = null;

    try
    {
        response = await base.SendAsync(request, cancellationToken);
    }
    catch (OperationCanceledException ex)
    {
        LogTimeout(...);
        throw;
    }
    catch (Exception ex)
    {
        LogFailure(...);
        throw;
    }
    finally
    {
        LogComplete(...);
    }

    return response;
}

No the part that I'm having trouble with is when I cancel the requests. When I cancel a request, I'm doing it on purpose, so I don't want it to get logged as a timeout, but there doesn't appear to be any difference between a cancellation and a real timeout.

Is there anyway to accomplish this?

Edit: I need to clarify this, a little bit. The service making the calls in parallel is passing in CancellationTokens with a timeout:

var ct = new CancellationTokenSource(TimeSpan.FromSeconds(2));

So when the server takes more than two seconds to respond, I get an OperationCanceledException, and if I manually cancel the token source (say because another server returned an error after 1 second), then I still get an OperationCanceledException. Ideally, I would be able to look at CancellationToken.IsCancellationRequested to determine if it was cancelled due to a timeout, as opposed to explicitly requested to be cancelled, but it appears that you get the same value regardless of how it was canceled.

回答1:

If you want to distinguish the two cancellation types, then you need to use two different cancellation tokens. There's no other way. This is not too hard since they can be linked - just a bit awkward.

The cleanest way to write this IMO is to move the timeout code into the SendAsync method instead of the calling method:

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
  using (var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
  {
    cts.CancelAfter(TimeSpan.FromSeconds(2));
    try
    {
      return await base.SendAsync(request, cts.Token);
    }
    catch (OperationCanceledException ex)
    {
      if (cancellationToken.IsCancellationRequested)
        return null;
      LogTimeout(...);
      throw;
    }
    catch (Exception ex)
    {
      LogFailure(...);
      throw;
    }
    finally
    {
      LogComplete(...);
    }
  }
}

If you don't want to move the timeout code into SendAsync, then you'll need to do the logging outside of that method, too.



回答2:

If the exceptions aren't telling you the difference between the two cases then you will need to check with either the Task or the CancellationToken to see if there was actually a cancellation.

I would lean toward asking the Task which will have its IsCanceled property return true if an unhandled OperationCanceledException was thrown (using CancellationToken.ThrowIfCancellationRequested inside base.SendAsync most likely). Something like this...

HttpResponseMessage response = null;
Task sendTask = null;

try
{
  sendTask = base.SendAsync(request, cancellationToken);
  await sendTask;
}
catch (OperationCanceledException ex)
{
  if (!sendTask.IsCancelled)
  {
    LogTimeout(...);
    throw;
  }
}

EDIT

In response to the update to the question, I wanted to update my answer. You are right cancellation whether it is specifically requested on the CancellationTokenSource or if it is caused by a timeout will lead to exactly the same outcome. If you decompile CancellationTokenSource you will see that for the timeout it just sets a Timer callback that will explicitly call CancellationTokenSource.Cancel when the timeout is reached, so both ways will end up calling the same Cancel method.

I think if you want to tell the difference you will need to derive from CancellationTokenSource (it isn't a sealed class) and then add your own custom cancel method that will set a flag to let you know that you explicitly cancelled the operation rather than letting it time out.

This is unfortunate since you will have both your custom cancel method and the original Cancel method available and will have to be sure to use the custom one. You may be able to get away with your custom logic just hiding the existing Cancel operation with something like this:

class CustomCancellationTokenSource : CancellationTokenSource
{
  public bool WasManuallyCancelled {get; private set;}

  public new void Cancel()
  {
    WasManuallyCancelled = true;
    base.Cancel();
  }
}

I would think that hiding the base method will work, you can give it a shot and find out.