Home > Software engineering >  HttpClient with CancellationToken not working in Xamarin Application
HttpClient with CancellationToken not working in Xamarin Application

Time:07-28

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?

CodePudding user response:

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);
        }
    }
}

CodePudding user response:

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?

  • Related