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?