Skip to content
Merged
5 changes: 5 additions & 0 deletions src/code/FindHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -924,7 +924,12 @@ private IEnumerable<PSResourceInfo> SearchByNames(ServerApiCall currentServer, R

responses = response.GetAwaiter().GetResult();

bool requestFailed = !errorMsgs.IsEmpty;
Utils.WriteOutConcurrentQueue(_cmdletPassedIn, errorMsgs, warningMsgs, debugMsgs, verboseMsgs);
if (requestFailed)
{
continue;
}
}
else
{
Expand Down
4 changes: 2 additions & 2 deletions src/code/PublishHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -715,7 +715,7 @@ private bool PushNupkg(string outputNupkgDir, string repoName, string repoUri, N
// look in PS repo for how httpRequestExceptions are handled

// Unfortunately there is no response message are no status codes provided with the exception and no
var ex = new ArgumentException(String.Format("Repository '{0}': {1}", repoName, e.Message));
var ex = new ArgumentException(String.Format("Repository '{0}': {1}. {2}", repoName, e.Message, Utils.GetPSGalleryStatusMessage(publishLocation, e)));
if (e.Message.Contains("400"))
{
if (e.Message.Contains("Api"))
Expand Down Expand Up @@ -850,7 +850,7 @@ private bool PushNupkg(string outputNupkgDir, string repoName, string repoUri, N
else
{
error = new ErrorRecord(
new ArgumentException($"Repository '{repoName}': {e.InnerException.Message}"),
new ArgumentException($"Repository '{repoName}': {e.InnerException.Message}. {Utils.GetPSGalleryStatusMessage(publishLocation, e) }"),
"ProtocolFailError",
ErrorCategory.ProtocolError,
this);
Expand Down
91 changes: 91 additions & 0 deletions src/code/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,97 @@ public static string FormatRequestsExceptions(Exception exception, HttpRequestMe
return exMsg;
}

public const string PSGalleryStatusUri = "https://aka.ms/psgallery";

/// <summary>
/// Formats an HTTP request exception and, when the request targeted the PowerShell Gallery and failed
/// with a 5xx status code, appends a pointer to the PowerShell Gallery status page.
/// </summary>
public static string FormatRequestsExceptions(Exception exception, HttpRequestMessage request, HttpStatusCode statusCode)
{
return FormatRequestsExceptions(exception, request) + GetPSGalleryStatusMessage(request?.RequestUri, statusCode);
}

/// <summary>
/// Returns a sentence pointing to the PowerShell Gallery status page when the request targeted the
/// PowerShell Gallery and failed with a 5xx status code; otherwise returns an empty string.
/// </summary>
public static string GetPSGalleryStatusMessage(Uri requestUri, HttpStatusCode statusCode)
{
return IsPSGalleryServiceError(requestUri, statusCode)
? $" The PowerShell Gallery may be experiencing an issue. Check {PSGalleryStatusUri} for the current service status."
: string.Empty;
}

/// <summary>
/// Same as GetPSGalleryStatusMessage, for callers (such as NuGet's PushRunner) that only surface the failure
/// as an exception. The status code is read from the exception (or its inner exceptions) without relying on
/// the English exception message, so the hint also works on localized .NET Framework installations.
/// </summary>
public static string GetPSGalleryStatusMessage(string requestUri, Exception exception)
{
if (!Uri.TryCreate(requestUri, UriKind.Absolute, out Uri uri) || !TryGetHttpStatusCode(exception, out HttpStatusCode statusCode))
{
return string.Empty;
}

return GetPSGalleryStatusMessage(uri, statusCode);
}

// HttpRequestException.StatusCode only exists on .NET 5+; look it up at runtime since this assembly targets net472.
private static readonly System.Reflection.PropertyInfo s_httpRequestExceptionStatusCode = typeof(HttpRequestException).GetProperty("StatusCode");

// EnsureSuccessStatusCode formats its message as "<localized text>{0} ({1})" in every locale, where {0} is the
// numeric status code and {1} the server's reason phrase, so match on that shape rather than on the English text.
private static readonly Regex s_statusCodeInMessage = new Regex(@"(?<![\d.])([1-5]\d{2})\s*\(", RegexOptions.CultureInvariant);

/// <summary>
/// Gets the HTTP status code from an exception or its inner exceptions, preferring the structured
/// HttpRequestException.StatusCode (.NET 5+) and falling back to a locale-independent parse of the message.
/// </summary>
internal static bool TryGetHttpStatusCode(Exception exception, out HttpStatusCode statusCode)
{
for (Exception ex = exception; ex != null; ex = ex.InnerException)
{
if (ex is HttpRequestException && s_httpRequestExceptionStatusCode?.GetValue(ex) is HttpStatusCode structuredCode)
{
statusCode = structuredCode;
return true;
}
}

for (Exception ex = exception; ex != null; ex = ex.InnerException)
{
Match match = string.IsNullOrEmpty(ex.Message) ? Match.Empty : s_statusCodeInMessage.Match(ex.Message);
if (match.Success)
{
statusCode = (HttpStatusCode)int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
return true;
}
}

statusCode = default;
return false;
}

/// <summary>
/// True when the request targeted the PowerShell Gallery and failed with a server-side (5xx) error.
/// 404 is deliberately excluded because it can legitimately mean the requested resource doesn't exist.
/// </summary>
public static bool IsPSGalleryServiceError(Uri requestUri, HttpStatusCode statusCode)
{
if (requestUri == null || !requestUri.IsAbsoluteUri)
{
return false;
}

string host = requestUri.Host;
bool isPSGallery = host.Equals("powershellgallery.com", StringComparison.OrdinalIgnoreCase) ||
host.EndsWith(".powershellgallery.com", StringComparison.OrdinalIgnoreCase);

return isPSGallery && (int)statusCode >= 500 && (int)statusCode < 600;
}

public static string FormatCredentialRequestExceptions(Exception exception)
{
string exMsg = $"'{exception.Message}' Re-run the command with -Credential.";
Expand Down
74 changes: 54 additions & 20 deletions src/code/V2ServerAPICalls.cs
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ public override async Task<FindResults> FindNameAsync(string packageName, bool i
}

var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}";
string response = string.Empty;
string response = null;
try
{
response = await HttpRequestCallAsync(requestUrlV2, errorMsgs, warningMsgs, debugMsgs, verboseMsgs);
Expand Down Expand Up @@ -476,6 +476,12 @@ public override async Task<FindResults> FindNameAsync(string packageName, bool i
}
}

// The request failure has already been reported; don't also report a parse/not-found error.
if (response == null)
{
return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType);
}

int count = GetCountFromResponse(response, out string errStr);
if (errStr != null)
{
Expand Down Expand Up @@ -832,7 +838,7 @@ public override async Task<FindResults> FindVersionAsync(string packageName, str
}

var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}";
string response = string.Empty;
string response = null;
try
{
response = await HttpRequestCallAsync(requestUrlV2, errorMsgs, warningMsgs, debugMsgs, verboseMsgs);
Expand Down Expand Up @@ -874,6 +880,12 @@ public override async Task<FindResults> FindVersionAsync(string packageName, str
}
}

// The request failure has already been reported; don't also report a parse/not-found error.
if (response == null)
{
return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType);
}

int count = GetCountFromResponse(response, out string errStr);
if (errStr != null)
{
Expand Down Expand Up @@ -1071,11 +1083,13 @@ private string HttpRequestCall(string requestUrlV2, out ErrorRecord errRecord)

/// <summary>
/// Helper method that makes the HTTP request for the V2 server protocol url passed in for find APIs.
/// Returns null if the request failed (the failure has already been added to errorMsgs), so callers
/// must not report an additional parse/not-found error for it.
/// </summary>
private async Task<string> HttpRequestCallAsync(string requestUrlV2, ConcurrentQueue<ErrorRecord> errorMsgs, ConcurrentQueue<string> warningMsgs, ConcurrentQueue<string> debugMsgs, ConcurrentQueue<string> verboseMsgs)
{
debugMsgs.Enqueue("In V2ServerAPICalls::HttpRequestCallAsync()");
string response = string.Empty;
string response = null;

try
{
Expand All @@ -1087,23 +1101,23 @@ private async Task<string> HttpRequestCallAsync(string requestUrlV2, ConcurrentQ
catch (ResourceNotFoundException e)
{
errorMsgs.Enqueue(new ErrorRecord(
new ResourceNotFoundException($"Failure when attempting to make request for '{requestUrlV2}'", e),
e,
"ResourceNotFound",
ErrorCategory.InvalidResult,
this));
}
catch (UnauthorizedException e)
{
errorMsgs.Enqueue(new ErrorRecord(
new UnauthorizedException($"Failure when attempting to make request for '{requestUrlV2}'", e),
new UnauthorizedException($"Failure when attempting to make request for '{requestUrlV2}'. {e.Message}", e),
"UnauthorizedRequest",
ErrorCategory.InvalidResult,
this));
}
catch (HttpRequestException e)
{
errorMsgs.Enqueue(new ErrorRecord(
new HttpRequestException($"Failure when attempting to make request for '{requestUrlV2}'", e),
e,
"HttpRequestCallFailure",
ErrorCategory.ConnectionError,
this));
Expand Down Expand Up @@ -1132,18 +1146,28 @@ private async Task<HttpContent> HttpRequestCallForContentAsync(string requestUrl
{
debugMsgs.Enqueue("In V2ServerAPICalls::HttpRequestCallForContentAsync()");
HttpContent content = null;
bool requestFailed = true;

try
{
debugMsgs.Enqueue($"Request url is '{requestUrlV2}'");
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, requestUrlV2);

content = await SendV2RequestForContentAsync(request, _sessionClient);
requestFailed = false;
}
catch (ResourceNotFoundException e)
{
errorMsgs.Enqueue(new ErrorRecord(
e,
"ResourceNotFound",
ErrorCategory.ObjectNotFound,
this));
}
catch (HttpRequestException e)
{
errorMsgs.Enqueue(new ErrorRecord(
new HttpRequestException($"Failure when attempting to make request for content with '{requestUrlV2}'", e),
e,
"HttpRequestFailure",
ErrorCategory.ConnectionError,
this));
Expand All @@ -1165,7 +1189,8 @@ private async Task<HttpContent> HttpRequestCallForContentAsync(string requestUrl
this));
}

if (content == null || string.IsNullOrEmpty(content.ToString()))
// Only report an empty response if the request itself didn't already report a failure.
if (!requestFailed && (content == null || string.IsNullOrEmpty(content.ToString())))
{
errorMsgs.Enqueue(new ErrorRecord(
new ArgumentException($"Response returned is null or empty. Request for content made: '{requestUrlV2}'"),
Expand All @@ -1190,6 +1215,14 @@ private HttpContent HttpRequestCallForContent(string requestUrlV2, out ErrorReco

content = SendV2RequestForContentAsync(request, _sessionClient).GetAwaiter().GetResult();
}
catch (ResourceNotFoundException e)
{
errRecord = new ErrorRecord(
exception: e,
"ResourceNotFound",
ErrorCategory.ObjectNotFound,
this);
}
catch (HttpRequestException e)
{
errRecord = new ErrorRecord(
Expand Down Expand Up @@ -1715,6 +1748,12 @@ public override async Task<FindResults> FindVersionGlobbingAsync(string packageN

var initialResponse = await FindVersionGlobbingAsync(packageName, versionRange, includePrerelease, type, skip, getOnlyLatest, errorMsgs, warningMsgs, debugMsgs, verboseMsgs);

// The request failure has already been reported; don't also report a parse/not-found error.
if (initialResponse == null)
{
return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType);
}

int initialCount = GetCountFromResponse(initialResponse, out string errStr);
if (errStr != null)
{
Expand Down Expand Up @@ -1743,7 +1782,7 @@ public override async Task<FindResults> FindVersionGlobbingAsync(string packageN
// skip 100
skip += 100;
var tmpResponse = await FindVersionGlobbingAsync(packageName, versionRange, includePrerelease, type, skip, getOnlyLatest, errorMsgs, warningMsgs, debugMsgs, verboseMsgs);
responses.Add(tmpResponse);
responses.Add(tmpResponse ?? string.Empty);
count--;
}
}
Expand Down Expand Up @@ -1924,14 +1963,9 @@ private async Task<Stream> InstallVersionAsync(string packageName, string versio

var response = await HttpRequestCallForContentAsync(requestUrlV2, errorMsgs, warningMsgs, debugMsgs, verboseMsgs);

// HttpRequestCallForContentAsync has already reported why no content was returned.
if (response is null)
{
errorMsgs.Enqueue(new ErrorRecord(
new Exception($"No content was returned by repository '{Repository.Name}'"),
"InstallFailureContentNullv2Async",
ErrorCategory.InvalidResult,
this));

return new MemoryStream();
}

Expand Down Expand Up @@ -2027,7 +2061,7 @@ public static async Task<string> SendV2RequestAsync(HttpRequestMessage message,
{
if (responseStatusCode.Equals(HttpStatusCode.NotFound))
{
throw new ResourceNotFoundException(Utils.FormatRequestsExceptions(e, message));
throw new ResourceNotFoundException(Utils.FormatRequestsExceptions(e, message, responseStatusCode));
}
// ADO feed will return a 401 if a package does not exist on the feed, with the following message:
// 401 (Unauthorized - No local versions of package 'NonExistentModule'; please provide authentication to access
Expand All @@ -2036,13 +2070,13 @@ public static async Task<string> SendV2RequestAsync(HttpRequestMessage message,
{
if (e.Message.Contains("access versions from upstream that have not yet been saved to your feed"))
{
throw new ResourceNotFoundException(Utils.FormatRequestsExceptions(e, message));
throw new ResourceNotFoundException(Utils.FormatRequestsExceptions(e, message, responseStatusCode));
}

throw new UnauthorizedException(Utils.FormatCredentialRequestExceptions(e));
}

throw new HttpRequestException(Utils.FormatRequestsExceptions(e, message));
throw new HttpRequestException(Utils.FormatRequestsExceptions(e, message, responseStatusCode));
}
catch (ArgumentNullException e)
{
Expand Down Expand Up @@ -2072,14 +2106,14 @@ public static async Task<HttpContent> SendV2RequestForContentAsync(HttpRequestMe
{
if (responseStatusCode.Equals(HttpStatusCode.NotFound))
{
throw new ResourceNotFoundException(Utils.FormatRequestsExceptions(e, message));
throw new ResourceNotFoundException(Utils.FormatRequestsExceptions(e, message, responseStatusCode));
}
if (responseStatusCode.Equals(HttpStatusCode.Unauthorized))
{
throw new UnauthorizedException(Utils.FormatCredentialRequestExceptions(e));
}

throw new HttpRequestException(Utils.FormatRequestsExceptions(e, message));
throw new HttpRequestException(Utils.FormatRequestsExceptions(e, message, responseStatusCode));
}
catch (ArgumentNullException e)
{
Expand Down
Loading
Loading