diff --git a/src/libraries/System.Net.Http/src/Resources/Strings.resx b/src/libraries/System.Net.Http/src/Resources/Strings.resx index 94beb1f5c6694f..456e14ce31d422 100644 --- a/src/libraries/System.Net.Http/src/Resources/Strings.resx +++ b/src/libraries/System.Net.Http/src/Resources/Strings.resx @@ -537,4 +537,7 @@ The HTTP/3 server attempted to reference a dynamic table index that does not exist. - + + The request was canceled due to the configured HttpClient.Timeout of {0} seconds elapsing. + + \ No newline at end of file diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/HttpClient.cs b/src/libraries/System.Net.Http/src/System/Net/Http/HttpClient.cs index 05288da777fec6..ba716dae88c534 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/HttpClient.cs +++ b/src/libraries/System.Net.Http/src/System/Net/Http/HttpClient.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.IO; using System.Net.Http.Headers; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -491,12 +492,14 @@ public Task SendAsync(HttpRequestMessage request, HttpCompl CancellationTokenSource cts; bool disposeCts; bool hasTimeout = _timeout != s_infiniteTimeout; + long timeoutTime = long.MaxValue; if (hasTimeout || cancellationToken.CanBeCanceled) { disposeCts = true; cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _pendingRequestsCts.Token); if (hasTimeout) { + timeoutTime = Environment.TickCount64 + (_timeout.Ticks / TimeSpan.TicksPerMillisecond); cts.CancelAfter(_timeout); } } @@ -512,19 +515,25 @@ public Task SendAsync(HttpRequestMessage request, HttpCompl { sendTask = base.SendAsync(request, cts.Token); } - catch + catch (Exception e) { HandleFinishSendAsyncCleanup(cts, disposeCts); + + if (e is OperationCanceledException operationException && TimeoutFired(cancellationToken, timeoutTime)) + { + throw CreateTimeoutException(operationException); + } + throw; } return completionOption == HttpCompletionOption.ResponseContentRead && !string.Equals(request.Method.Method, "HEAD", StringComparison.OrdinalIgnoreCase) ? - FinishSendAsyncBuffered(sendTask, request, cts, disposeCts) : - FinishSendAsyncUnbuffered(sendTask, request, cts, disposeCts); + FinishSendAsyncBuffered(sendTask, request, cts, disposeCts, cancellationToken, timeoutTime) : + FinishSendAsyncUnbuffered(sendTask, request, cts, disposeCts, cancellationToken, timeoutTime); } private async Task FinishSendAsyncBuffered( - Task sendTask, HttpRequestMessage request, CancellationTokenSource cts, bool disposeCts) + Task sendTask, HttpRequestMessage request, CancellationTokenSource cts, bool disposeCts, CancellationToken callerToken, long timeoutTime) { HttpResponseMessage response = null; try @@ -548,6 +557,13 @@ private async Task FinishSendAsyncBuffered( catch (Exception e) { response?.Dispose(); + + if (e is OperationCanceledException operationException && TimeoutFired(callerToken, timeoutTime)) + { + HandleSendAsyncTimeout(operationException); + throw CreateTimeoutException(operationException); + } + HandleFinishSendAsyncError(e, cts); throw; } @@ -558,7 +574,7 @@ private async Task FinishSendAsyncBuffered( } private async Task FinishSendAsyncUnbuffered( - Task sendTask, HttpRequestMessage request, CancellationTokenSource cts, bool disposeCts) + Task sendTask, HttpRequestMessage request, CancellationTokenSource cts, bool disposeCts, CancellationToken callerToken, long timeoutTime) { try { @@ -573,6 +589,12 @@ private async Task FinishSendAsyncUnbuffered( } catch (Exception e) { + if (e is OperationCanceledException operationException && TimeoutFired(callerToken, timeoutTime)) + { + HandleSendAsyncTimeout(operationException); + throw CreateTimeoutException(operationException); + } + HandleFinishSendAsyncError(e, cts); throw; } @@ -582,6 +604,14 @@ private async Task FinishSendAsyncUnbuffered( } } + private bool TimeoutFired(CancellationToken callerToken, long timeoutTime) => !callerToken.IsCancellationRequested && Environment.TickCount64 >= timeoutTime; + + private TaskCanceledException CreateTimeoutException(OperationCanceledException originalException) + { + return new TaskCanceledException(string.Format(SR.net_http_request_timedout, _timeout.TotalSeconds), + new TimeoutException(originalException.Message, originalException), originalException.CancellationToken); + } + private void HandleFinishSendAsyncError(Exception e, CancellationTokenSource cts) { if (NetEventSource.IsEnabled) NetEventSource.Error(this, e); @@ -595,6 +625,15 @@ private void HandleFinishSendAsyncError(Exception e, CancellationTokenSource cts } } + private void HandleSendAsyncTimeout(OperationCanceledException e) + { + if (NetEventSource.IsEnabled) + { + NetEventSource.Error(this, e); + NetEventSource.Error(this, "Canceled due to timeout"); + } + } + private void HandleFinishSendAsyncCleanup(CancellationTokenSource cts, bool disposeCts) { // Dispose of the CancellationTokenSource if it was created specially for this request diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientTest.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientTest.cs index 83efb460d574bc..ad642fc61b103a 100644 --- a/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientTest.cs +++ b/src/libraries/System.Net.Http/tests/FunctionalTests/HttpClientTest.cs @@ -593,14 +593,54 @@ public void CancelAllPending_AllPendingOperationsCanceled(bool withInfiniteTimeo } } - [Fact] - public void Timeout_TooShort_AllPendingOperationsCanceled() + [Theory] + [InlineData(HttpCompletionOption.ResponseContentRead)] + [InlineData(HttpCompletionOption.ResponseHeadersRead)] + public void Timeout_TooShort_AllPendingOperationsCanceled(HttpCompletionOption completionOption) { using (var client = new HttpClient(new CustomResponseHandler((r, c) => WhenCanceled(c)))) { client.Timeout = TimeSpan.FromMilliseconds(1); - Task[] tasks = Enumerable.Range(0, 3).Select(_ => client.GetAsync(CreateFakeUri())).ToArray(); - Assert.All(tasks, task => Assert.Throws(() => task.GetAwaiter().GetResult())); + Task[] tasks = Enumerable.Range(0, 3).Select(_ => client.GetAsync(CreateFakeUri(), completionOption)).ToArray(); + Assert.All(tasks, task => { + OperationCanceledException e = Assert.ThrowsAny(() => task.GetAwaiter().GetResult()); + TimeoutException timeoutException = (TimeoutException)e.InnerException; + Assert.NotNull(timeoutException); + Assert.NotNull(timeoutException.InnerException); + }); + } + } + + [Theory] + [InlineData(HttpCompletionOption.ResponseContentRead)] + [InlineData(HttpCompletionOption.ResponseHeadersRead)] + public async Task Timeout_CallerCanceledTokenAfterTimeout_TimeoutIsNotDetected(HttpCompletionOption completionOption) + { + using (var client = new HttpClient(new CustomResponseHandler((r, c) => WhenCanceled(c)))) + { + client.Timeout = TimeSpan.FromMilliseconds(0.01); + CancellationTokenSource cts = new CancellationTokenSource(); + CancellationToken token = cts.Token; + cts.Cancel(); + Task task = client.GetAsync(CreateFakeUri(), completionOption, token); + OperationCanceledException e = await Assert.ThrowsAnyAsync(async () => await task); + Assert.Null(e.InnerException); + } + } + + [Theory] + [InlineData(HttpCompletionOption.ResponseContentRead)] + [InlineData(HttpCompletionOption.ResponseHeadersRead)] + public void Timeout_CallerCanceledTokenBeforeTimeout_TimeoutIsNotDetected(HttpCompletionOption completionOption) + { + using (var client = new HttpClient(new CustomResponseHandler((r, c) => WhenCanceled(c)))) + { + client.Timeout = TimeSpan.FromDays(1); + CancellationTokenSource cts = new CancellationTokenSource(); + Task task = client.GetAsync(CreateFakeUri(), completionOption, cts.Token); + cts.Cancel(); + OperationCanceledException e = Assert.ThrowsAny(() => task.GetAwaiter().GetResult()); + Assert.Null(e.InnerException); } }