2

I have a Android Xamarin Forms application, and I am trying to set a timeout for an HttpClient using a CancellationToken, but it doesn't appear to be working. The request seems to time out after about 2 minuntes as opposed to the 5 seconds I expect:

private HttpClient _httpClient = new HttpClient(new HttpClientHandler() { UseProxy = false })
{
    Timeout = TimeSpan.FromSeconds(5)
};

public async Task<T> GetAsync<T>(string url, TimeSpan timeout) where T : new()
{
    using (var tokenSource = new CancellationTokenSource(timeout))
    {
        try
        {
            using (var response = await _httpClient.GetAsync(url, tokenSource.Token))
            {
                // never gets here when it times out on a bad address.

                response.EnsureSuccessStatusCode();
                using (var responseStream = await response.Content.ReadAsStreamAsync())
                {
                    if (response.IsSuccessStatusCode)
                    {
                        using (var textReader = new StreamReader(responseStream))
                        {
                            using (var jsonReader = new JsonTextReader(textReader))
                            {
                                return JsonSerializer.CreateDefault().Deserialize<T>(jsonReader);
                            }
                        }
                    }
                    else
                    {
                        return default(T);
                    }
                }
            }
        }
        catch (TaskCanceledException)
        {
            // this gets hit after about 2 minutes as opposed to the 5 seconds I expected.
            return default(T);
        }
        catch
        {
            return default(T);
        }
    }
}

And then usage:

var myObject = await GetAsync<MyObject>("https://example.com/badRequest", TimeSpan.FromSeconds(5));

If I run this same code from a .NET Core application, it works as I would expect (timing out in 5 seconds).

Does anybody have any idea why the Mono framework is ignoring the cancellation token and what a reasonable workaround would be?

Mike Luken
  • 415
  • 6
  • 19
  • `catch (OperationCanceledException)` I think. Also `await response.Content.ReadAsStreamAsync(tokenSource.Token)` – Charlieface Jul 27 '22 at 08:44
  • I tried catching OperationCanceledException as well, but it never gets hit. It only hits TaskCanceledException, but only after 2 minutes. I also should have clarified that it hangs during ```await _httpClient.GetAsync(...)```. It never gets down to ReadAsStreamAsync... – Mike Luken Jul 27 '22 at 14:10
  • Sounds like you have an `async` deadlock, I'm guessing somewhere up the call stack you are calling `.Wait` or `.Result`. See https://blog.stephencleary.com/2012/07/dont-block-on-async-code.html – Charlieface Jul 27 '22 at 14:11
  • I checked but not calling either of those. If I hit a valid endpoint, everything works. It is when I try to hit the endpoint and the site is down that it hangs on ```httpClient.GetAsync(...)```... – Mike Luken Jul 27 '22 at 14:16
  • If it's a bad address then most likely it's a well-known bug that the default resolver blocks and ignores your timeout. Which version of .NET are you running? – Charlieface Jul 27 '22 at 14:17
  • Try manually resolving the address using `var host = (await Dns.GetHostAddressesAsync(new Uri(url).Host, tokenSource.Token)).FirstOrDefault() ?? throw new Exception("Host not found");` see also https://stackoverflow.com/a/58549362/14868997 – Charlieface Jul 27 '22 at 14:22
  • It is a Xamarin application targeting .NET Standard 2.1. – Mike Luken Jul 27 '22 at 14:22
  • Strange result. When I move the calling logic to a .NET core web application, the timeout happens in 2 seconds as expected. But when it is in my Xamarin application, it times out for 2 minutes. Not sure why it would behave differently? – Mike Luken Jul 27 '22 at 14:49
  • Makes sense: DNS resolving was improved in .NET Core as far as I remember, although I can't find a reference to it now. – Charlieface Jul 27 '22 at 14:57
  • Relevant: https://github.com/dotnet/runtime/blob/272551004874cdf8b49ba07cc1881285a6f86e64/src/libraries/System.Net.Sockets/src/System/Net/Sockets/SocketAsyncEventArgs.cs#L669 and https://github.com/dotnet/runtime/pull/43661 where the change was made – Charlieface Jul 27 '22 at 15:18

2 Answers2

3

It seems that this is related to a well-known issue on older versions of .NET, that Socket.ConnectAsync (which is used by HttpClientHandler under the hood) does not call Dns.GetHostAddressesAsync, it calls the non-async version, and ignores the timeout. This is now fixed on newer versions of .NET.

As a workaround, you can call Dns.GetHostAddressesAsync yourself first. This does not cause a double DNS lookup, due to operating-system-level caching. Unfortunately, Dns.GetHostAddressesAsync does not accept a CancellationToken unless you are on newer version of .NET anyway. So you need Task.WhenAny along with a helper function.

public static Task WhenCanceled(CancellationToken cancellationToken)
{
    var tcs = new TaskCompletionSource<bool>();
    cancellationToken.Register(s => ((TaskCompletionSource<bool>)s).SetResult(true), tcs);
    return tcs.Task;
}

public async Task<T> GetAsync<T>(string url, TimeSpan timeout) where T : new()
{
    using (var tokenSource = new CancellationTokenSource(timeout))
    {
        try
        {
            using (var dnsTask = Dns.GetHostAddressesAsync(new Uri(url).Host))
                _ = await Task.WhenAny(WhenCanceled(tokenSource.Token), dnsTask);

            tokenSource.Token.ThrowIfCancellationRequested();

            using (var response = await _httpClient.GetAsync(url, tokenSource.Token))
            {
                response.EnsureSuccessStatusCode();
                using (var responseStream = await response.Content.ReadAsStreamAsync(tokenSource.Token))
                using (var textReader = new StreamReader(responseStream))
                using (var jsonReader = new JsonTextReader(textReader))
                {
                    return _serializer.Deserialize<T>(jsonReader);
                }
            }
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine("Timeout");
            // this gets hit after about 2 minutes as opposed to the 5 seconds I expected.
            return default(T);
        }
        catch
        {
            return default(T);
        }
    }
}
Charlieface
  • 52,284
  • 6
  • 19
  • 43
  • It is saying that ```Dns.GetHostAddressesAsync(...)``` only can take one argument? It won't take CancellationTokenSource as a param? – Mike Luken Jul 27 '22 at 21:22
  • Oh that's only available in newer versions anyway. Have modified – Charlieface Jul 27 '22 at 21:28
  • 1
    It looks like .net standard 2.1 doesn't have the same params. See [here](https://learn.microsoft.com/en-us/dotnet/api/system.net.dns.gethostaddressesasync?view=netstandard-2.1). – Mike Luken Jul 27 '22 at 21:28
0

So I am not sure if this is the best approach, but I found that I can wrap a Polly policy around the call and it will fix the issue. Not the cleanest solution. Maybe somebody can think of a better one?

var policy = Policy.TimeoutAsync(
    TimeSpan.FromMilliseconds(5000),
    TimeoutStrategy.Pessimistic,
    (context, timespan, task) => throw new Exception("Cannot connect to server.")
);

await policy.ExecuteAsync(async () =>
{
    var myObject = await GetAsync<MyObject>(
        "https://example.com/badRequest", 
        TimeSpan.FromSeconds(4)
    );
});

It feels a bit "hacky" to do it this way but at least it works? I am going to be porting my app over the MAUI when VS 17.3 gets released in a few weeks. Perhaps this issue won't exist on MAUI?

Mike Luken
  • 415
  • 6
  • 19
  • Please do not use the onTimeout callback to throw exception. It is designed for logging or manipulating Polly context. – Peter Csala Aug 26 '22 at 14:10
  • Also if possible please prefer optimistic timeout strategy whenever possible. In your case it would require a slight modification of the GetAsync method to anticipate a CancellationToken. – Peter Csala Aug 26 '22 at 14:12