Skip to content

Instantly share code, notes, and snippets.

@thomaslevesque
Last active December 19, 2024 17:04
Show Gist options
  • Save thomaslevesque/b4fd8c3aa332c9582a57935d6ed3406f to your computer and use it in GitHub Desktop.
Save thomaslevesque/b4fd8c3aa332c9582a57935d6ed3406f to your computer and use it in GitHub Desktop.
TimeoutHandler for smarter timeout handling with HttpClient

TimeoutHandler for smarter timeout handling with HttpClient

This code illustrates the blog article Better timeout handling with HttpClient.

Key features:

  • control the timeout per request, rather than globally for all requests
  • throw a more sensible exception (TimeoutException) when a timeout occurs, instead of the usual OperationCanceledException
public static class HttpRequestExtensions
{
private const string TimeoutPropertyKey = "RequestTimeout";
public static void SetTimeout(this HttpRequestMessage request, TimeSpan? timeout)
{
if (request == null) throw new ArgumentNullException(nameof(request));
request.Properties[TimeoutPropertyKey] = timeout;
}
public static TimeSpan? GetTimeout(this HttpRequestMessage request)
{
if (request == null) throw new ArgumentNullException(nameof(request));
if (request.Properties.TryGetValue(TimeoutPropertyKey, out var value) && value is TimeSpan timeout)
return timeout;
return null;
}
}
public class TimeoutHandler : DelegatingHandler
{
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(100);
protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
using (var cts = GetCancellationTokenSource(request, cancellationToken))
{
try
{
return await base.SendAsync(request, cts?.Token ?? cancellationToken);
}
catch(OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException();
}
}
}
private CancellationTokenSource GetCancellationTokenSource(HttpRequestMessage request, CancellationToken cancellationToken)
{
var timeout = request.GetTimeout() ?? DefaultTimeout;
if (timeout == Timeout.InfiniteTimeSpan)
{
// No need to create a CTS if there's no timeout
return null;
}
else
{
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(timeout);
return cts;
}
}
}
async Task TestAsync()
{
var handler = new TimeoutHandler
{
DefaultTimeout = TimeSpan.FromSeconds(10),
InnerHandler = new HttpClientHandler()
};
using (var cts = new CancellationTokenSource())
using (var client = new HttpClient(handler))
{
client.Timeout = Timeout.InfiniteTimeSpan;
var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost:8888/");
// Uncomment to test per-request timeout
//request.SetTimeout(TimeSpan.FromSeconds(5));
// Uncomment to test that cancellation still works properly
//cts.CancelAfter(TimeSpan.FromSeconds(2));
using (var response = await client.SendAsync(request, cts.Token))
{
Console.WriteLine(response.StatusCode);
}
}
}
@tvanbaak
Copy link

Why not just subclass the derived class at that point then?

That couples the timeout functionality to the other property that the subclass implemented, which may not be desirable. Alternatively, I may not even be able to subclass it if it came from a third-party library that sealed it. But any subclass of HttpRequestMessage will have the property bag.

@bitm0de
Copy link

bitm0de commented Jan 28, 2021

Why not just subclass the derived class at that point then?

That couples the timeout functionality to the other property that the subclass implemented, which may not be desirable. Alternatively, I may not even be able to subclass it if it came from a third-party library that sealed it. But any subclass of HttpRequestMessage will have the property bag.

Yes, but if you’re debating use cases here then there’s also a case to be made for the mutability of the data in that dictionary being undesirable as well. If that was the case with a third party lib then it might also be the case that they hide that property to avoid external fiddling as well anyways. Thus, third party libs can introduce problems with your ability to access and use the internal dictionary just as equally as it can affect your ability to inherit from a class... There’s no guarantee that you’d have access to these things with a third party lib depending on the functionality it provides (i.e. Open-closed principle might hide this property but expose other things for extensibility). As such, I think this discussion should omit the unknown.

@JimWilcox3
Copy link

This is a lifesaver. I did have to tweak it to work on a mobile client. The error thrown is InvalidOperationException. Also, .net8.0 has obsoleted request.Properties, so I modified it to use request.Options. Other than that, it works great and I only have to change the timeout in the few places where I need to wait a little longer.

@munkii
Copy link

munkii commented Nov 5, 2024

This is a lifesaver. I did have to tweak it to work on a mobile client. The error thrown is InvalidOperationException. Also, .net8.0 has obsoleted request.Properties, so I modified it to use request.Options. Other than that, it works great and I only have to change the timeout in the few places where I need to wait a little longer.

@JimWilcox3 did you convert it for MAUI and .NET8? Any chance you could drop a link to the code here? Or just post the changes inline?

@JimWilcox3
Copy link

JimWilcox3 commented Nov 8, 2024

Mine is straight .net8-android but it should work in Maui. I already had an http handler that did my token management for me so I added the timeout handling to that. You can leave out the "Get Token" stuff if you don't need it. My goal there was to be able to just make HTTP calls as if there was no authentication and let all the authentication happen in the background. The AsyncLock class I got from the guy that wrote Akavache. He got it from someone else and the link is on the class doc.

public static class HttpRequestExtensions
{
    private const string TimeoutPropertyKey = "RequestTimeout";

    public static void SetTimeout(
        this HttpRequestMessage request,
        TimeSpan? timeout)
    {
        if (request == null)
            throw new ArgumentNullException(nameof(request));

        //request.Properties[TimeoutPropertyKey] = timeout;
        request.Options.Set(new HttpRequestOptionsKey<TimeSpan?>(TimeoutPropertyKey), timeout);
    }

    public static TimeSpan? GetTimeout(this HttpRequestMessage request)
    {
        if (request == null)
            throw new ArgumentNullException(nameof(request));

        //if (request.Properties.TryGetValue(
        //        TimeoutPropertyKey,
        //        out var value)
        //    && value is TimeSpan timeout)
        if (request.Options.TryGetValue(new HttpRequestOptionsKey<TimeSpan?>(TimeoutPropertyKey), out var value)
            && value is TimeSpan timeout)
            return timeout;

        return null;
    }
}

    public class MPSHttpHandler : DelegatingHandler
    {
        public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(100);

        private readonly IConfiguration Configuration;
        private readonly TokenProvider Tokens;
        private readonly ILogger<MPSHttpHandler> Logger;
        private readonly HttpClient client;

        public MPSHttpHandler(HttpClient httpClient, IConfiguration configuration, TokenProvider tokens, ILogger<MPSHttpHandler> logger)
        {
            Configuration = configuration;
            Tokens = tokens;
            Logger = logger;
            client = httpClient;

            InnerHandler = new HttpClientHandler();
        }

        static readonly AsyncLock tokenLock = new AsyncLock();

        public async Task<bool> CheckTokens()
        {
            using (await tokenLock.LockAsync())
            {
                //Check it again here so we don't grab a bunch of tokens from concurrent http calls.
                if ((!Tokens.AccessTokenExpiration.HasValue) || (Tokens.AccessTokenExpiration < DateTime.UtcNow))
                {

                    var disco = await client.GetDiscoveryDocumentAsync(Configuration["Settings:Authority"]);
                    if (disco.IsError) throw new Exception(disco.Error);

                    var A = disco.TokenEndpoint;
                    var ClientId = Configuration["Settings:ClientID"] ?? "";
                    var ClientSecret = Configuration["Settings:ClientSecret"];

                    var result = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
                    {
                        Address = disco.TokenEndpoint,
                        ClientId = Configuration["Settings:ClientID"] ?? "",
                        ClientSecret = Configuration["Settings:ClientSecret"],
                        Scope = Configuration["Settings:Scope"]
                    });

                    Logger.LogInformation("Request Token Result {0}", result.IsError ? "Error" : "Success");

                    if (result.IsError)
                    {
                        Logger.LogError("Error: {0}", result.ErrorDescription);
                        return false;
                    }

                    Tokens.AccessToken = result.AccessToken;
                    //Tokens.RefreshToken = result.RefreshToken;
                    Tokens.AccessTokenExpiration = DateTime.UtcNow.AddSeconds(result.ExpiresIn);

                    //Logger.LogInformation("Access Token: {0}", result.AccessToken);
                    //Logger.LogInformation("Refresh Token: {0}", result.RefreshToken);
                    Logger.LogInformation("Access Token Expires: {0}", Tokens.AccessTokenExpiration);
                }

                return true;
            }
        }

        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            if ((!Tokens.AccessTokenExpiration.HasValue) || (Tokens.AccessTokenExpiration < DateTime.UtcNow))
                await CheckTokens();

            request.SetBearerToken(Tokens.AccessToken ?? "");

            HttpResponseMessage response = null;

            using (var cts = GetCancellationTokenSource(request, cancellationToken))
            {
                try
                {
                    response = await base.SendAsync(request, cts?.Token ?? cancellationToken);

                    if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
                    {
                        if (await CheckTokens())
                        {
                            request.SetBearerToken(Tokens.AccessToken ?? "");

                            response = await base.SendAsync(request, cts?.Token ?? cancellationToken);
                        }
                    }
                }
                catch (InvalidOperationException)
                    when (!cancellationToken.IsCancellationRequested)
                {
                    throw new TimeoutException();
                }
                catch (TaskCanceledException ex) when (cancellationToken.IsCancellationRequested)
                {
                    // Handle case where the operation is canceled explicitly
                    Logger.LogWarning(ex.Message);
                    throw;
                }
                catch (HttpRequestException ex)
                {
                    // Handle network or request-level exceptions (e.g., 5xx errors)
                    // Optionally log ex
                    Logger.LogWarning(ex.Message);
                    throw;
                }
                catch (Exception ex)
                {
                    // Catch any unexpected exceptions, could be logged
                    Logger.LogError(ex.Message);
                    throw;
                }
            }

            return response;
        }

        private CancellationTokenSource GetCancellationTokenSource(
                        HttpRequestMessage request,
                        CancellationToken cancellationToken)
        {
            var timeout = request.GetTimeout() ?? DefaultTimeout;
            if (timeout == Timeout.InfiniteTimeSpan)
            {
                // No need to create a CTS if there's no timeout
                return null;
            }
            else
            {
                var cts = CancellationTokenSource
                    .CreateLinkedTokenSource(cancellationToken);
                cts.CancelAfter(timeout);
                return cts;
            }
        }
    }

  public class TokenProvider
  {
      public TokenProvider() 
      {
          Debug.Print("**** New instance of Token Provider ****");
      }
      public string? AccessToken { get; set; }

      //public string RefreshToken { get; set; }

      public DateTime? AccessTokenExpiration { get; set; }
  }
/// <summary>
/// A lock that allows for async based operations and returns a IDisposable which allows for unlocking.
/// </summary>
/// <remarks>Straight-up thieved from
/// http://www.hanselman.com/blog/ComparingTwoTechniquesInNETAsynchronousCoordinationPrimitives.aspx
/// and all credit to that article.</remarks>
    public sealed class AsyncLock
    {
        private readonly SemaphoreSlim m_semaphore = new SemaphoreSlim(1, 1);
        private readonly Task<IDisposable> m_releaser;

        public AsyncLock()
        {
            m_releaser = Task.FromResult((IDisposable)new Releaser(this));
        }

        public Task<IDisposable> LockAsync()
        {
            var wait = m_semaphore.WaitAsync();
            return wait.IsCompleted ?
                        m_releaser :
                        wait.ContinueWith((_, state) => (IDisposable)state,
                            m_releaser.Result, CancellationToken.None,
            TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
        }

        private sealed class Releaser : IDisposable
        {
            private readonly AsyncLock m_toRelease;
            internal Releaser(AsyncLock toRelease) { m_toRelease = toRelease; }
            public void Dispose() { m_toRelease.m_semaphore.Release(); }
        }
    }

You can do something like this to wire it all up. It will depend on how you are starting up your app.

 var tokenProvider = new TokenProvider();

 var handler = new MPSHttpHandler(new HttpClient(), builder.Configuration, tokenProvider);

 builder.Services.AddSingleton(new HttpClient(handler)
 {
     BaseAddress = new Uri(config["Settings:ApiAddress"] ?? "")
 });

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment