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.