diff --git a/.spelling b/.spelling index 189f4bd544..08eb13f148 100644 --- a/.spelling +++ b/.spelling @@ -1061,10 +1061,13 @@ v6.0. #region test/tools/WebListener/README.md Overrides - test/tools/WebListener/README.md Auth +failureCode +failureCount NoResume NTLM NumberBytes ResponseHeaders +sessionId #endregion includeide interactivetesting diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs index ae1e7eb450..20b72d0fe7 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/Common/WebRequestPSCmdlet.Common.cs @@ -21,6 +21,7 @@ using System.Xml; using System.Collections.Generic; using System.Text.RegularExpressions; using System.Linq; +using System.Threading.Tasks; namespace Microsoft.PowerShell.Commands { @@ -228,6 +229,20 @@ namespace Microsoft.PowerShell.Commands } private int _maximumRedirection = -1; + /// + /// Gets or sets the MaximumRetryCount property, which determines the number of retries of a failed web request. + /// + [Parameter] + [ValidateRange(0, Int32.MaxValue)] + public virtual int MaximumRetryCount { get; set; } = 0; + + /// + /// Gets or sets the RetryIntervalSec property, which determines the number seconds between retries. + /// + [Parameter] + [ValidateRange(1, Int32.MaxValue)] + public virtual int RetryIntervalSec { get; set; } = 5; + #endregion #region Method @@ -630,6 +645,14 @@ namespace Microsoft.PowerShell.Commands WebSession.Headers[key] = Headers[key].ToString(); } } + + if (MaximumRetryCount > 0) + { + WebSession.MaximumRetryCount = MaximumRetryCount; + + // only set retry interval if retry count is set. + WebSession.RetryIntervalInSeconds = RetryIntervalSec; + } } #endregion Virtual Methods @@ -662,7 +685,6 @@ namespace Microsoft.PowerShell.Commands #endregion Helper Properties #region Helper Methods - private Uri PrepareUri(Uri uri) { uri = CheckProtocol(uri); @@ -1268,80 +1290,128 @@ namespace Microsoft.PowerShell.Commands ); } + private bool ShouldRetry(HttpStatusCode code) + { + int intCode = (int)code; + + if (((intCode == 304) || (intCode >= 400 && intCode <= 599)) && WebSession.MaximumRetryCount > 0) + { + return true; + } + + return false; + } + internal virtual HttpResponseMessage GetResponse(HttpClient client, HttpRequestMessage request, bool keepAuthorization) { if (client == null) { throw new ArgumentNullException("client"); } if (request == null) { throw new ArgumentNullException("request"); } - // Track the current URI being used by various requests and re-requests. - var currentUri = request.RequestUri; + // Add 1 to account for the first request. + int totalRequests = WebSession.MaximumRetryCount + 1; + HttpRequestMessage req = request; + HttpResponseMessage response = null; - _cancelToken = new CancellationTokenSource(); - HttpResponseMessage response = client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancelToken.Token).GetAwaiter().GetResult(); - - if (keepAuthorization && IsRedirectCode(response.StatusCode) && response.Headers.Location != null) + do { - _cancelToken.Cancel(); - _cancelToken = null; + // Track the current URI being used by various requests and re-requests. + var currentUri = req.RequestUri; - // if explicit count was provided, reduce it for this redirection. - if (WebSession.MaximumRedirection > 0) + _cancelToken = new CancellationTokenSource(); + response = client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, _cancelToken.Token).GetAwaiter().GetResult(); + + if (keepAuthorization && IsRedirectCode(response.StatusCode) && response.Headers.Location != null) { - WebSession.MaximumRedirection--; - } - // For selected redirects that used POST, GET must be used with the - // redirected Location. - // Since GET is the default; POST only occurs when -Method POST is used. - if (Method == WebRequestMethod.Post && IsRedirectToGet(response.StatusCode)) - { - // See https://msdn.microsoft.com/library/system.net.httpstatuscode(v=vs.110).aspx - Method = WebRequestMethod.Get; + _cancelToken.Cancel(); + _cancelToken = null; + + // if explicit count was provided, reduce it for this redirection. + if (WebSession.MaximumRedirection > 0) + { + WebSession.MaximumRedirection--; + } + // For selected redirects that used POST, GET must be used with the + // redirected Location. + // Since GET is the default; POST only occurs when -Method POST is used. + if (Method == WebRequestMethod.Post && IsRedirectToGet(response.StatusCode)) + { + // See https://msdn.microsoft.com/library/system.net.httpstatuscode(v=vs.110).aspx + Method = WebRequestMethod.Get; + } + + currentUri = new Uri(request.RequestUri, response.Headers.Location); + // Continue to handle redirection + using (client = GetHttpClient(handleRedirect: true)) + using (HttpRequestMessage redirectRequest = GetRequest(currentUri)) + { + response = GetResponse(client, redirectRequest, keepAuthorization); + } } - currentUri = new Uri(request.RequestUri, response.Headers.Location); - // Continue to handle redirection - using (client = GetHttpClient(handleRedirect: true)) - using (HttpRequestMessage redirectRequest = GetRequest(currentUri)) + // Request again without the Range header because the server indicated the range was not satisfiable. + // This happens when the local file is larger than the remote file. + // If the size of the remote file is the same as the local file, there is nothing to resume. + if (Resume.IsPresent && + response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable && + (response.Content.Headers.ContentRange.HasLength && + response.Content.Headers.ContentRange.Length != _resumeFileSize)) { - response = GetResponse(client, redirectRequest, keepAuthorization); + _cancelToken.Cancel(); + + WriteVerbose(WebCmdletStrings.WebMethodResumeFailedVerboseMsg); + + // Disable the Resume switch so the subsequent calls to GetResponse() and FillRequestStream() + // are treated as a standard -OutFile request. This also disables appending local file. + Resume = new SwitchParameter(false); + + using (HttpRequestMessage requestWithoutRange = GetRequest(currentUri)) + { + FillRequestStream(requestWithoutRange); + long requestContentLength = 0; + if (requestWithoutRange.Content != null) + { + requestContentLength = requestWithoutRange.Content.Headers.ContentLength.Value; + } + + string reqVerboseMsg = String.Format( + CultureInfo.CurrentCulture, + WebCmdletStrings.WebMethodInvocationVerboseMsg, + requestWithoutRange.Method, + requestWithoutRange.RequestUri, + requestContentLength); + WriteVerbose(reqVerboseMsg); + + return GetResponse(client, requestWithoutRange, keepAuthorization); + } } + + _resumeSuccess = response.StatusCode == HttpStatusCode.PartialContent; + + // When MaximumRetryCount is not specified, the totalRequests == 1. + if (totalRequests > 1 && ShouldRetry(response.StatusCode)) + { + string retryMessage = string.Format( + CultureInfo.CurrentCulture, + WebCmdletStrings.RetryVerboseMsg, + RetryIntervalSec, + response.StatusCode); + + WriteVerbose(retryMessage); + + _cancelToken = new CancellationTokenSource(); + Task.Delay(WebSession.RetryIntervalInSeconds * 1000, _cancelToken.Token).GetAwaiter().GetResult(); + _cancelToken.Cancel(); + _cancelToken = null; + + req.Dispose(); + req = GetRequest(currentUri); + FillRequestStream(req); + } + + totalRequests--; } + while (totalRequests > 0 && !response.IsSuccessStatusCode); - // Request again without the Range header because the server indicated the range was not satisfiable. - // This happens when the local file is larger than the remote file. - // If the size of the remote file is the same as the local file, there is nothing to resume. - if (Resume.IsPresent && - response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable && - (response.Content.Headers.ContentRange.HasLength && - response.Content.Headers.ContentRange.Length != _resumeFileSize)) - { - _cancelToken.Cancel(); - - WriteVerbose(WebCmdletStrings.WebMethodResumeFailedVerboseMsg); - - // Disable the Resume switch so the subsequent calls to GetResponse() and FillRequestStream() - // are treated as a standard -OutFile request. This also disables appending local file. - Resume = new SwitchParameter(false); - - using (HttpRequestMessage requestWithoutRange = GetRequest(currentUri)) - { - FillRequestStream(requestWithoutRange); - long requestContentLength = 0; - if (requestWithoutRange.Content != null) - requestContentLength = requestWithoutRange.Content.Headers.ContentLength.Value; - - string reqVerboseMsg = String.Format(CultureInfo.CurrentCulture, - WebCmdletStrings.WebMethodInvocationVerboseMsg, - requestWithoutRange.Method, - requestWithoutRange.RequestUri, - requestContentLength); - WriteVerbose(reqVerboseMsg); - - return GetResponse(client, requestWithoutRange, keepAuthorization); - } - } - - _resumeSuccess = response.StatusCode == HttpStatusCode.PartialContent; return response; } diff --git a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/WebRequestSession.cs b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/WebRequestSession.cs index 07c3522ef1..271691ce79 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/WebRequestSession.cs +++ b/src/Microsoft.PowerShell.Commands.Utility/commands/utility/WebCmdlet/WebRequestSession.cs @@ -64,6 +64,16 @@ namespace Microsoft.PowerShell.Commands /// public int MaximumRedirection { get; set; } + /// + /// Gets or sets the count of retries for request failures. + /// + public int MaximumRetryCount { get; set; } + + /// + /// Gets or sets the interval in seconds between retries. + /// + public int RetryIntervalInSeconds { get; set; } + /// /// Construct a new instance of a WebRequestSession object. /// diff --git a/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx b/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx index f22655aaf5..397718a09b 100644 --- a/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx +++ b/src/Microsoft.PowerShell.Commands.Utility/resources/WebCmdletStrings.resx @@ -258,4 +258,7 @@ received {0}-byte response of content type {1} + + Retrying after interval of {0} seconds. Status code for previous attempt: {1} + diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 index f80d0da355..775a5e8ca5 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/WebCmdlets.Tests.ps1 @@ -1723,6 +1723,49 @@ Describe "Invoke-WebRequest tests" -Tags "Feature" { $response.Headers.'Content-Range'[0] | Should -BeExactly "bytes */$referenceFileSize" } } + + Context "Invoke-WebRequest retry tests" { + + It "Invoke-WebRequest can retry - " -TestCases @( + @{Name = "specified number of times - error 304"; failureCount = 2; failureCode = 304; retryCount = 2} + @{Name = "specified number of times - error 400"; failureCount = 3; failureCode = 400; retryCount = 3} + @{Name = "specified number of times - error 599"; failureCount = 1; failureCode = 599; retryCount = 2} + @{Name = "specified number of times - error 404"; failureCount = 2; failureCode = 404; retryCount = 2} + @{Name = "when retry count is higher than failure count"; failureCount = 2; failureCode = 404; retryCount = 4} + ) { + param($failureCount, $retryCount, $failureCode) + + $uri = Get-WebListenerUrl -Test 'Retry' -Query @{ sessionid = (New-Guid).Guid; failureCode = $failureCode; failureCount = $failureCount } + $commandStr = "Invoke-WebRequest -Uri '$uri' -MaximumRetryCount $retryCount -RetryIntervalSec 1" + $result = ExecuteWebCommand -command $commandStr + + $result.output.StatusCode | Should -Be "200" + $jsonResult = $result.output.Content | ConvertFrom-Json + $jsonResult.failureResponsesSent | Should -Be $failureCount + } + + It "Invoke-WebRequest should fail when failureCount is greater than MaximumRetryCount" { + + $uri = Get-WebListenerUrl -Test 'Retry' -Query @{ sessionid = (New-Guid).Guid; failureCode = 400; failureCount = 4 } + $command = "Invoke-WebRequest -Uri '$uri' -MaximumRetryCount 1 -RetryIntervalSec 1" + $result = ExecuteWebCommand -command $command + $jsonError = $result.error | ConvertFrom-Json + $jsonError.error | Should -BeExactly 'Error: HTTP - 400 occurred.' + } + + It "Invoke-WebRequest can retry with POST" { + + $uri = Get-WebListenerUrl -Test 'Retry' + $sessionId = (New-Guid).Guid + $body = @{ sessionid = $sessionId; failureCode = 404; failureCount = 1 } + $commandStr = "Invoke-WebRequest -Uri '$uri' -MaximumRetryCount 2 -RetryIntervalSec 1 -Method POST -Body `$body" + $result = ExecuteWebCommand -command $commandStr + + $result.output.StatusCode | Should -Be "200" + $jsonResult = $result.output.Content | ConvertFrom-Json + $jsonResult.SessionId | Should -BeExactly $sessionId + } + } } Describe "Invoke-RestMethod tests" -Tags "Feature" { @@ -2969,6 +3012,33 @@ Describe "Invoke-RestMethod tests" -Tags "Feature" { $Headers.'Content-Range'[0] | Should -BeExactly "bytes */$referenceFileSize" } } + + Context "Invoke-RestMethod retry tests" { + + It "Invoke-RestMethod can retry - specified number of times - error 304" { + + $uri = Get-WebListenerUrl -Test 'Retry' + $sessionId = (New-Guid).Guid + $body = @{ sessionid = $sessionId; failureCode = 304; failureCount = 2 } + $commandStr = "Invoke-RestMethod -Uri '$uri' -MaximumRetryCount 2 -RetryIntervalSec 1 -Method POST -Body `$body" + $result = ExecuteWebCommand -command $commandStr + + $result.output.failureResponsesSent | Should -Be 2 + $result.output.sessionId | Should -BeExactly $sessionId + } + + It "Invoke-RestMethod can retry with POST" { + + $uri = Get-WebListenerUrl -Test 'Retry' + $sessionId = (New-Guid).Guid + $body = @{ sessionid = $sessionId; failureCode = 404; failureCount = 1 } + $commandStr = "Invoke-RestMethod -Uri '$uri' -MaximumRetryCount 2 -RetryIntervalSec 1 -Method POST -Body `$body" + $result = ExecuteWebCommand -command $commandStr + + $result.output.failureResponsesSent | Should -Be 1 + $result.output.sessionId | Should -BeExactly $sessionId + } + } } Describe "Validate Invoke-WebRequest and Invoke-RestMethod -InFile" -Tags "Feature" { @@ -3077,3 +3147,4 @@ Describe "Web cmdlets tests using the cmdlet's aliases" -Tags "CI" { $result.Hello | Should -Be "world" } } + diff --git a/test/tools/Modules/WebListener/WebListener.psm1 b/test/tools/Modules/WebListener/WebListener.psm1 index c477067ee2..c58db23ada 100644 --- a/test/tools/Modules/WebListener/WebListener.psm1 +++ b/test/tools/Modules/WebListener/WebListener.psm1 @@ -162,6 +162,7 @@ function Get-WebListenerUrl { 'Response', 'ResponseHeaders', 'Resume', + 'Retry', '/' )] [String]$Test, diff --git a/test/tools/WebListener/Controllers/RetryController.cs b/test/tools/WebListener/Controllers/RetryController.cs new file mode 100644 index 0000000000..bb11448d72 --- /dev/null +++ b/test/tools/WebListener/Controllers/RetryController.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Primitives; +using mvc.Models; + +namespace mvc.Controllers +{ + public class RetryController : Controller + { + // Dictionary for sessionId as key and failureCode, failureCount and failureResponsesSent as the value. + private static Dictionary> retryInfo; + + public JsonResult Retry(string sessionId, int failureCode, int failureCount) + { + if (retryInfo == null) + { + retryInfo = new Dictionary>(); + } + + if (retryInfo.TryGetValue(sessionId, out Tuple retry)) + { + // if failureResponsesSent is less than failureCount + if (retry.Item3 < retry.Item2) + { + Response.StatusCode = retry.Item1; + retryInfo[sessionId] = Tuple.Create(retry.Item1, retry.Item2, retry.Item3 + 1); + Hashtable error = new Hashtable { { "error", $"Error: HTTP - {retry.Item1} occurred." } }; + return Json(error); + } + else + { + retryInfo.Remove(sessionId); + + // echo back sessionId for POST test. + var resp = new Hashtable { { "failureResponsesSent", retry.Item3 }, { "sessionId", sessionId } }; + return Json(resp); + } + } + else + { + // initialize the failureResponsesSent as 1. + var newRetryInfoItem = Tuple.Create(failureCode, failureCount, 1); + retryInfo.Add(sessionId, newRetryInfoItem); + Response.StatusCode = failureCode; + Hashtable error = new Hashtable { { "error", $"Error: HTTP - {failureCode} occurred." } }; + return Json(error); + } + } + + public IActionResult Error() + { + return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); + } + } +} diff --git a/test/tools/WebListener/README.md b/test/tools/WebListener/README.md index a010d701d9..6abace80c6 100644 --- a/test/tools/WebListener/README.md +++ b/test/tools/WebListener/README.md @@ -28,8 +28,8 @@ The `WebListener.dll` takes 6 arguments: Import-Module .\build.psm1 Publish-PSTestTools $Listener = Start-WebListener -HttpPort 8083 -HttpsPort 8084 -Tls11Port 8085 -TlsPort 8086 -``` +``` ## Tests ### / or /Home/ @@ -682,3 +682,21 @@ X-WebListener-Has-Range: false Content-Length: 20 Content-Type: application/octet-stream ``` + +### /Retry/{sessionId}/{failureCode}/{failureCount} + +This endpoint causes the failure specified by `failureCode` for `failureCount` number of times. +After that a status 200 is returned with body containing the number of times the failure was caused. + +```powershell +$response = Invoke-WebRequest -Uri 'http://127.0.0.1:8083/Retry?failureCode=599&failureCount=2&sessionid=100&' -MaximumRetryCount 2 -RetryIntervalSec 1 +``` + +Response Body: + +```json +{ + "failureResponsesSent":2, + "sessionId":100 +} +``` diff --git a/test/tools/WebListener/Startup.cs b/test/tools/WebListener/Startup.cs index 775357d181..96aca01e2f 100644 --- a/test/tools/WebListener/Startup.cs +++ b/test/tools/WebListener/Startup.cs @@ -79,6 +79,10 @@ namespace mvc template: "Delete", defaults: new {controller = "Get", action = "Index"}, constraints: new RouteValueDictionary(new { httpMethod = new HttpMethodRouteConstraint("DELETE") })); + routes.MapRoute( + name: "retry", + template: "Retry/{sessionId?}/{failureCode?}/{failureCount?}", + defaults: new { controller = "Retry", action = "Retry" }); }); } } diff --git a/test/tools/WebListener/Views/Home/Index.cshtml b/test/tools/WebListener/Views/Home/Index.cshtml index b8f26512e4..6627e932d8 100644 --- a/test/tools/WebListener/Views/Home/Index.cshtml +++ b/test/tools/WebListener/Views/Home/Index.cshtml @@ -19,4 +19,5 @@
  • /Redirect/{count} - 302 redirect count times.
  • /Response/?statuscode=<StatusCode>&body=<ResponseBody>&contenttype=<ResponseContentType>&headers=<JsonHeadersObject> - Returns the given response.
  • /ResponseHeaders/?key=val - Returns given response headers.
  • +
  • /Retry/?sessionid=100&failureCode=599&failureCount=2 - Returns HTTP status code failureCode, failureCount number of times.