.NET 6 HttpClient Timeout Mechanism

Introduction

In .NET 6, the recommended way for network communication is to use the HttpClient type. In the peculiar network environment in China, there are many weak network conditions to consider, one of the most important being network timeouts.

This article will explain how to properly use the timeout mechanism of HttpClient.

Main Content

In HttpClient, there is a Timeout property, which indicates the timeout duration for the entire network activity. This definition has certain pitfalls. For example, if I access an API data interface and only POST a small amount of data, setting a timeout of 100 seconds is completely reasonable.

However, if I am uploading a large file, which takes a long time, then using a timeout of 100 seconds is clearly unreasonable. If the file has not finished uploading within 100 seconds, meaning the network activity has not completed, a timeout exception will be triggered.

This is a rather unexpected logic. In most cases, in the weak network environment in China, the network may be almost disconnected during the upload process, which is equivalent to a very slow upload speed.

The entire file upload process can be divided into two stages. The first stage is the process of establishing a connection with the server, which is reasonable to use the HttpClient’s Timeout property as the timeout duration. The second stage is the data upload process, which is entirely related to the amount of data being uploaded. Clearly, if the second stage is also included in the timeout duration, it is not in line with expectations.

When using HttpClient, for most network requests that do not involve file uploads, the timeout duration is logically reasonable. However, for file uploads, this logic does not hold. More often, what is needed is to report a timeout to the business side if the upload speed drops to a certain extent. For example, if the upload speed has been almost zero for a long time, it should be reported to the upper business side.

As mentioned above, the file upload process can be divided into two stages. You can set the connection timeout for HttpClient and the server using lower-level control methods, as shown in the code below:

var socketsHttpHandler = new SocketsHttpHandler()
{
    ConnectTimeout = TimeSpan.FromSeconds(20),
};
var httpClient = new HttpClient(socketsHttpHandler)
{
    Timeout = TimeSpan.FromSeconds(100)
};

By passing a SocketsHttpHandler object into HttpClient, you can achieve lower-level control over the connection timeout.

In .NET 6, the default HttpClient implementation calls the SocketsHttpHandler object, so the above code does not change the underlying behavior of HttpClient.

For more details, see the differences between HttpClientHandler and SocketsHttpHandler in .NET 6 (https://blog.lindexi.com/post/dotnet-6-HttpClientHandler-和-SocketsHttpHandler-有什么差别.html).

Some partners, when encountering this issue, found some ancient solutions online, which involve using HttpWebRequest. However, the bad news is that in .NET 6, since HttpWebRequest is implemented using HttpClient, it cannot solve this problem.

For more details, see using HttpWebRequest for POST files in .NET 6 will consume a lot of memory (https://blog.lindexi.com/post/dotnet-6-使用-HttpWebRequest-进行-POST-文件将占用大量内存.html).

As the official implementation mechanism states, if you want sufficient control over the upload logic, make good use of the last parameter of PostAsync. In other words, a good approach is to divide the upload of large amounts of data into two timeout stages. The first stage is the connection phase, controlled by the ConnectTimeout of SocketsHttpHandler, and the second stage is controlled by the cancellation parameter of PostAsync.

The implementation method is to first set the HttpClient’s Timeout to a sufficiently long time, or even use the <span><span>Timeout.InfiniteTimeSpan</span></span> property to set it to infinite timeout, and then control the timeout using the cancellation parameter.

var socketsHttpHandler = new SocketsHttpHandler()
{
    ConnectTimeout = TimeSpan.FromSeconds(20),
};
var httpClient = new HttpClient(socketsHttpHandler)
{
    Timeout = Timeout.InfiniteTimeSpan
};

Next, define a type called UploadHttpContent that inherits from HttpContent, which will handle the actual upload speed control logic.

class UploadHttpContent : HttpContent
{
}

You need to pass the actual file upload data as HttpContent and the set timeout duration.

public UploadHttpContent(HttpContent content, CancellationTokenSource tokenSource, TimeSpan? timeout = null)
{
    _content = content;
    _tokenSource = tokenSource;
    _stream = content.ReadAsStream();
    _timeout = timeout ?? TimeSpan.FromSeconds(100);
}

private TimeSpan _timeout;

private readonly HttpContent _content;
private Stream _stream;
private CancellationTokenSource _tokenSource;

The timeout duration defined here is not the total upload time, but the time during which the network is disconnected during the upload process. Here, a network disconnection is equivalent to a sufficiently slow network speed. For example, if it takes 100 seconds to upload less than 1 MB of data, then a timeout should be reported.

First, ignore the implementation logic of UploadHttpContent and look at the usage method.

First, obtain the data to be uploaded, using a test Stream as a substitute.

var streamContent = new StreamContent(new FakeStream(1024_0000_0000));

The FakeStream can generate data according to the parameters passed to it, and it can be seen that this is a relatively large amount of data.

Next, define the cancellation parameters.

var cancellationTokenSource = new CancellationTokenSource();

Then create the UploadHttpContent object.

var uploadHttpContent = new UploadHttpContent(streamContent, cancellationTokenSource);

Pass UploadHttpContent as the upload parameter, as shown in the code below:

var result = await httpClient.PostAsync("http://127.0.0.1:12367/Upload", uploadHttpContent, cancellationTokenSource.Token);

In UploadHttpContent, by overriding the SerializeToStreamAsync method, you can enter the method each time the upload buffer is read. Each time you enter the method, you can record the elapsed time to determine if the upload has timed out.

class UploadHttpContent : HttpContent
{
    // Ignore other logic
    protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context)
    {
        var buffer = ArrayPool<byte>.Shared.Rent(1024 * 1024);
        int count;

        StartDog();

        while ((count = _stream.Read(buffer, 0, buffer.Length)) > 0)
        {
            // There is a problem here: if the buffer is read completely first and then sent slowly, it will still fail.
            _stopwatch.Restart();

            await stream.WriteAsync(new ReadOnlyMemory<byte>(buffer, 0, count), _tokenSource.Token);
        }
    }

    private readonly Stopwatch _stopwatch = new Stopwatch();
}

When entering the SerializeToStreamAsync method, which is when the request is initiated, the StartDog method will be called. Entering the SerializeToStreamAsync method does not need to wait for the server connection to start because the underlying call to SerializeToStreamAsync first reads the data into the buffer, and after the connection is established, the data will be sent from the buffer to the server.

The reason for this design is to improve performance. If the SerializeToStreamAsync method is called after the connection is established, it will cause a delay before the data can be read from the business side.

When entering the first read call, StartDog will enter a loop logic, where it checks the <span><span>_stopwatch</span></span> field to understand the call frequency. This read frequency is approximately equal to the network upload speed, but it should be noted that the input parameter stream is local cache. When the local cache is filled slowly, calling WriteAsync will not return.

private async void StartDog()
{
    while (!_isFinished)
    {
        await Task.Delay(_timeout / 2);
        if (_isFinished)
        {
            return;
        }

        if (_stopwatch.Elapsed > _timeout)
        {
            _tokenSource.Cancel();
            return;
        }
    }
}

private bool _isFinished;

public void SetIsFinished() => _isFinished = true;

In StartDog, the approximate wait time interval is <span><span>_timeout / 2</span></span>. Within this range, it checks whether the <span><span>_stopwatch</span></span> time since the last start exceeds the <span><span>_timeout</span></span><code><span><span> value. If it exceeds, it indicates that the network speed is sufficiently slow. The wait interval is chosen as </span></span><code><span><span>_timeout / 2</span></span>, and the worst-case wait timeout will be 1.5 times the actual timeout. If you are concerned about the timeout duration, set this interval smaller.

The SetIsFinished method in the above code is designed to be called after the upload is completely finished. If it is not called, it is not a big issue, as the final timeout check will still return; it just means that the last check logic is not actually used.

var uploadHttpContent = new UploadHttpContent(streamContent, cancellationTokenSource);

var result = await httpClient.PostAsync("http://127.0.0.1:12367/Upload", uploadHttpContent, cancellationTokenSource.Token);
uploadHttpContent.SetIsFinished(); // Set as completed

If you remove the above <span><span>SetIsFinished</span></span> method and modify it to set the <span><span>_isFinished</span></span> value at the end of the SerializeToStreamAsync method, there is a small problem: the last time entering the loop in the SerializeToStreamAsync method will write data into the buffer. If the network speed is slow when sending the last buffered data, there will be no subsequent logic to indicate the timeout. To solve this problem, the SetIsFinished method was created to set it after the actual Post is completed. Of course, not setting it at this point is not a big issue; it just adds an unnecessary timeout call.

Next, write some test code, setting the server to upload data in a slow read manner, as shown in the code below:

using System.Buffers;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://*:12367");
builder.WebHost.UseKestrel(options =>
{
    options.Limits.MaxRequestBodySize = 1024_0000_0000_0000_000;
});
var app = builder.Build();

app.MapPost("/Upload", async context =>
{
    var length = 1024 * 1024 * 100;
    var buffer = ArrayPool<byte>.Shared.Rent(length);

    int count;
    while ((count = await context.Request.Body.ReadAsync(buffer, 0, length)) > 0)
    {
        await Task.Delay(1000);
    }

    ArrayPool<byte>.Shared.Return(buffer);

    context.Response.StatusCode = StatusCodes.Status200OK;
    await context.Response.WriteAsync("Hello World!");
});

app.Run();

The above server-side code receives client uploads at an acceptable speed, waiting one second for each read, which is shorter than the set timeout duration, so the Upload will not timeout.

Next, write another server-side method that receives data even slower than the set timeout duration.

app.MapPost("/UploadTimeout", async context =>
{
    var length = 1024 * 1024 * 100;
    var buffer = ArrayPool<byte>.Shared.Rent(length);

    int count;
    int n = 0;
    while ((count = await context.Request.Body.ReadAsync(buffer, 0, length)) > 0)
    {
        await Task.Delay(1000);
        n++;
        if (n == 10)
        {
            await Task.Delay(TimeSpan.FromHours(10));
        }
    }

    ArrayPool<byte>.Shared.Return(buffer);

    context.Response.StatusCode = StatusCodes.Status200OK;
    await context.Response.WriteAsync("Hello World!");
});

At this point, the client upload will be prompted for a timeout.

The above logic can achieve setting a timeout based on upload speed when uploading large amounts of data, which can effectively address the weak network environment in China.

The above code is available on GitHub (https://github.com/lindexi/lindexi_gd/tree/3015fafa0a38e1eb98b0b7eed117f46911253ea4/NekejawcharlereJibabearcel) and Gitee (https://gitee.com/lindexi/lindexi_gd/tree/3015fafa0a38e1eb98b0b7eed117f46911253ea4/NekejawcharlereJibabearcel). Feel free to visit.

You can obtain the source code of this article as follows: first create an empty folder, then use the command line to navigate to this empty folder, and enter the following code in the command line to obtain the code from this article.

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 3015fafa0a38e1eb98b0b7eed117f46911253ea4

The above uses the Gitee source. If Gitee is inaccessible, please replace it with the GitHub source.

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git

After obtaining the code, navigate to the NekejawcharlereJibabearcel folder.

However, the HttpClient’s Timeout property does not impose restrictions on the download process. That is, after the request is made, if the download time exceeds the set Timeout duration, it can still continue downloading.

To test the impact of download timeout, add the following code on the server to provide a very large amount of data for the client to download.

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://*:12367");
builder.WebHost.UseKestrel(options =>
{
    options.Limits.MaxRequestBodySize = 1024_0000_0000_0000_000;
});
var app = builder.Build();

app.MapGet("/Download", async context =>
{
    var length = 1024 * 1024 * 100;
    var buffer = ArrayPool<byte>.Shared.Rent(length);

    for (int i = 0; i < 1000000; i++)
    {
        await context.Response.Body.WriteAsync(new ReadOnlyMemory<byte>(buffer));

        if (i < 10)
        {
            await Task.Delay(TimeSpan.FromSeconds(1));
        }
        else
        {
            await Task.Delay(TimeSpan.FromMinutes(1));
        }
    }
});
app.Run();

The client sets a timeout of 10 seconds and then proceeds to download. The following code will definitely not complete the download within 10 seconds.

async Task Download()
{
    var httpClient = new HttpClient()
    {
        Timeout = TimeSpan.FromSeconds(10)
    };

   var stream = await httpClient.GetStreamAsync("http://127.0.0.1:12367/Download");

   var count = 0;
   var buffer = ArrayPool<byte>.Shared.Rent(1024 * 1024);

   while ((count = stream.Read(buffer.AsSpan())) > 0)
   {
       Console.WriteLine($"{count}");
   }
}

It can be seen that the download continues even after exceeding 10 seconds, proving that the Timeout property is ineffective for downloads.

Source: lindexi

Link: blog.lindexi.com/post/dotnet-6-使用-HttpClient-的超时机制.html

.NET 6 HttpClient Timeout MechanismReply [Close]Learn to permanently close app splash adsReply [Delete]Learn to automatically detect which WeChat friends have deleted or blockedReply [Manual]Get a 30,000-word .NET, C# engineer interview manualReply [Help]Get 100+ commonly used C# helper librariesReply [Join Group]Join the DotNet learning exchange group

Leave a Comment