From f0723521f18011c4e254b1c0c4d208aac5022682 Mon Sep 17 00:00:00 2001 From: alerickson Date: Thu, 5 Nov 2026 09:14:56 -0800 Subject: [PATCH 1/5] Point users to https://aka.ms/psgallery when a PowerShell Gallery request fails with a 404 or 5xx, add a manual Test-PSGalleryOutage.ps1 script to simulate gallery outages --- src/code/FindHelper.cs | 5 +++ src/code/Utils.cs | 31 +++++++++++++++ src/code/V2ServerAPICalls.cs | 32 ++++++++++----- test/Test-PSGalleryOutage.ps1 | 74 +++++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 test/Test-PSGalleryOutage.ps1 diff --git a/src/code/FindHelper.cs b/src/code/FindHelper.cs index abb8c773f..c43469c6b 100644 --- a/src/code/FindHelper.cs +++ b/src/code/FindHelper.cs @@ -924,7 +924,12 @@ private IEnumerable SearchByNames(ServerApiCall currentServer, R responses = response.GetAwaiter().GetResult(); + bool requestFailed = !errorMsgs.IsEmpty; Utils.WriteOutConcurrentQueue(_cmdletPassedIn, errorMsgs, warningMsgs, debugMsgs, verboseMsgs); + if (requestFailed) + { + continue; + } } else { diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 3e05f1089..8c0661823 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -223,6 +223,37 @@ public static string FormatRequestsExceptions(Exception exception, HttpRequestMe return exMsg; } + public const string PSGalleryStatusUri = "https://aka.ms/psgallery"; + + /// + /// Formats an HTTP request exception and, when the request targeted the PowerShell Gallery and failed + /// with a 404 or 5xx status code, appends a pointer to the PowerShell Gallery status page. + /// + public static string FormatRequestsExceptions(Exception exception, HttpRequestMessage request, HttpStatusCode statusCode) + { + string exMsg = FormatRequestsExceptions(exception, request); + if (IsPSGalleryServiceError(request?.RequestUri, statusCode)) + { + exMsg += $" The PowerShell Gallery may be experiencing an issue. Check {PSGalleryStatusUri} for the current service status."; + } + + return exMsg; + } + + 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 && (statusCode == HttpStatusCode.NotFound || (int)statusCode >= 500); + } + public static string FormatCredentialRequestExceptions(Exception exception) { string exMsg = $"'{exception.Message}' Re-run the command with -Credential."; diff --git a/src/code/V2ServerAPICalls.cs b/src/code/V2ServerAPICalls.cs index 6ea3361d7..3157653b9 100644 --- a/src/code/V2ServerAPICalls.cs +++ b/src/code/V2ServerAPICalls.cs @@ -435,6 +435,7 @@ public override async Task FindNameAsync(string packageName, bool i var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; string response = string.Empty; + int errorCountBeforeRequest = errorMsgs.Count; try { response = await HttpRequestCallAsync(requestUrlV2, errorMsgs, warningMsgs, debugMsgs, verboseMsgs); @@ -476,6 +477,12 @@ public override async Task FindNameAsync(string packageName, bool i } } + // HttpRequestCallAsync reports failures through errorMsgs rather than throwing; don't also report a parse/not-found error. + if (string.IsNullOrEmpty(response) && errorMsgs.Count > errorCountBeforeRequest) + { + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + } + int count = GetCountFromResponse(response, out string errStr); if (errStr != null) { @@ -833,6 +840,7 @@ public override async Task FindVersionAsync(string packageName, str var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; string response = string.Empty; + int errorCountBeforeRequest = errorMsgs.Count; try { response = await HttpRequestCallAsync(requestUrlV2, errorMsgs, warningMsgs, debugMsgs, verboseMsgs); @@ -874,6 +882,12 @@ public override async Task FindVersionAsync(string packageName, str } } + // HttpRequestCallAsync reports failures through errorMsgs rather than throwing; don't also report a parse/not-found error. + if (string.IsNullOrEmpty(response) && errorMsgs.Count > errorCountBeforeRequest) + { + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + } + int count = GetCountFromResponse(response, out string errStr); if (errStr != null) { @@ -1087,7 +1101,7 @@ private async Task 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)); @@ -1095,7 +1109,7 @@ private async Task HttpRequestCallAsync(string requestUrlV2, ConcurrentQ 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)); @@ -1103,7 +1117,7 @@ private async Task HttpRequestCallAsync(string requestUrlV2, ConcurrentQ catch (HttpRequestException e) { errorMsgs.Enqueue(new ErrorRecord( - new HttpRequestException($"Failure when attempting to make request for '{requestUrlV2}'", e), + e, "HttpRequestCallFailure", ErrorCategory.ConnectionError, this)); @@ -1143,7 +1157,7 @@ private async Task HttpRequestCallForContentAsync(string requestUrl 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)); @@ -2027,7 +2041,7 @@ public static async Task 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 @@ -2036,13 +2050,13 @@ public static async Task 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) { @@ -2072,14 +2086,14 @@ public static async Task 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) { diff --git a/test/Test-PSGalleryOutage.ps1 b/test/Test-PSGalleryOutage.ps1 new file mode 100644 index 000000000..d86363262 --- /dev/null +++ b/test/Test-PSGalleryOutage.ps1 @@ -0,0 +1,74 @@ +<# +.SYNOPSIS + Simulates a PowerShell Gallery outage locally to verify the aka.ms/psgallery hint in PSResourceGet errors. +.DESCRIPTION + 1. Maps a fake gallery host (test.powershellgallery.com) to 127.0.0.1 in the hosts file (requires elevation). + 2. Starts a tiny local HTTP server that returns the given status code for every request. + 3. Registers a temporary V2 repository pointing at the fake host and runs Find-PSResource against it. + 4. Cleans everything up. + + This is a manual test (not run by CI). Build the module first, then run from an elevated PowerShell 7 session: + ./build.ps1 -Clean -Build -BuildConfiguration Debug -BuildFramework net472 + ./test/FindPSResourceTests/Test-PSGalleryOutage.ps1 -StatusCode 503 +#> +param( + [int] $StatusCode = 503, + [int] $Port = 8080, + [string] $FakeHost = 'test.powershellgallery.com', + [string] $ModulePath = (Join-Path $PSScriptRoot '..\..\out\Microsoft.PowerShell.PSResourceGet\Microsoft.PowerShell.PSResourceGet.psd1'), + [switch] $SkipHostsFile +) + +$ErrorActionPreference = 'Stop' +$hostsFile = "$env:SystemRoot\System32\drivers\etc\hosts" +$hostsLine = "127.0.0.1`t$FakeHost # PSResourceGet outage test" +$repoName = 'FakePSGalleryOutage' + +$server = Start-ThreadJob -ArgumentList $Port, $StatusCode -ScriptBlock { + param($Port, $StatusCode) + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port) + $listener.Start() + try { + # Poll instead of blocking on AcceptTcpClient so Stop-Job can end the thread. + while ($true) { + if (-not $listener.Pending()) { Start-Sleep -Milliseconds 100; continue } + $client = $listener.AcceptTcpClient() + $stream = $client.GetStream() + $reader = [System.IO.StreamReader]::new($stream) + while (($line = $reader.ReadLine()) -ne '' -and $null -ne $line) { } + $body = "Simulated $StatusCode" + $response = "HTTP/1.1 $StatusCode Simulated`r`nContent-Type: text/plain`r`nContent-Length: $($body.Length)`r`nConnection: close`r`n`r`n$body" + $bytes = [System.Text.Encoding]::ASCII.GetBytes($response) + $stream.Write($bytes, 0, $bytes.Length) + $client.Close() + } + } + finally { $listener.Stop() } +} + +try { + if (-not $SkipHostsFile) { + Add-Content -Path $hostsFile -Value $hostsLine + Clear-DnsClientCache + } + + Import-Module $ModulePath -Force + Register-PSResourceRepository -Name $repoName -Uri "http://${FakeHost}:$Port/api/v2" -ApiVersion V2 -Trusted -Force + Start-Sleep -Seconds 1 + + Write-Host "`n--- Find-PSResource -Name against fake gallery returning HTTP $StatusCode ---" -ForegroundColor Cyan + Find-PSResource -Name PowerShellGet -Repository $repoName -ErrorAction Continue 2>&1 | + ForEach-Object { Write-Host $_ -ForegroundColor Yellow } + + Write-Host "`n--- Find-PSResource -Name -Version against fake gallery returning HTTP $StatusCode ---" -ForegroundColor Cyan + Find-PSResource -Name PowerShellGet -Version 2.2.5 -Repository $repoName -ErrorAction Continue 2>&1 | + ForEach-Object { Write-Host $_ -ForegroundColor Yellow } +} +finally { + Unregister-PSResourceRepository -Name $repoName -ErrorAction SilentlyContinue + if (-not $SkipHostsFile) { + (Get-Content $hostsFile) | Where-Object { $_ -ne $hostsLine } | Set-Content $hostsFile + Clear-DnsClientCache + } + Stop-Job $server; Remove-Job $server -Force +} \ No newline at end of file From 09293431977639f2bfe57e3a9aa2e143699411d1 Mon Sep 17 00:00:00 2001 From: alerickson Date: Thu, 5 Nov 2026 09:41:51 -0800 Subject: [PATCH 2/5] Add handling for install, update, save, and publish --- src/code/PublishHelper.cs | 4 ++-- src/code/Utils.cs | 28 ++++++++++++++++++++---- src/code/V2ServerAPICalls.cs | 41 ++++++++++++++++++++++++++++++------ 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/code/PublishHelper.cs b/src/code/PublishHelper.cs index 5a1dea45e..803daa9a3 100644 --- a/src/code/PublishHelper.cs +++ b/src/code/PublishHelper.cs @@ -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}", repoName, e.Message) + Utils.GetPSGalleryStatusMessage(publishLocation, e.Message)); if (e.Message.Contains("400")) { if (e.Message.Contains("Api")) @@ -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.InnerException.Message)), "ProtocolFailError", ErrorCategory.ProtocolError, this); diff --git a/src/code/Utils.cs b/src/code/Utils.cs index 8c0661823..e7c44ce09 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -231,13 +231,33 @@ public static string FormatRequestsExceptions(Exception exception, HttpRequestMe /// public static string FormatRequestsExceptions(Exception exception, HttpRequestMessage request, HttpStatusCode statusCode) { - string exMsg = FormatRequestsExceptions(exception, request); - if (IsPSGalleryServiceError(request?.RequestUri, statusCode)) + return FormatRequestsExceptions(exception, request) + GetPSGalleryStatusMessage(request?.RequestUri, statusCode); + } + + /// + /// Returns a sentence pointing to the PowerShell Gallery status page when the request targeted the + /// PowerShell Gallery and failed with a 404 or 5xx status code; otherwise returns an empty string. + /// + 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; + } + + /// + /// Same as GetPSGalleryStatusMessage, for callers (such as NuGet's PushRunner) that only expose the + /// status code inside an exception message like "Response status code does not indicate success: 503 (...)". + /// + public static string GetPSGalleryStatusMessage(string requestUri, string exceptionMessage) + { + if (string.IsNullOrEmpty(exceptionMessage) || !Uri.TryCreate(requestUri, UriKind.Absolute, out Uri uri)) { - exMsg += $" The PowerShell Gallery may be experiencing an issue. Check {PSGalleryStatusUri} for the current service status."; + return string.Empty; } - return exMsg; + Match match = Regex.Match(exceptionMessage, @"status code does not indicate success: (\d{3})", RegexOptions.IgnoreCase); + return match.Success ? GetPSGalleryStatusMessage(uri, (HttpStatusCode)int.Parse(match.Groups[1].Value)) : string.Empty; } public static bool IsPSGalleryServiceError(Uri requestUri, HttpStatusCode statusCode) diff --git a/src/code/V2ServerAPICalls.cs b/src/code/V2ServerAPICalls.cs index 3157653b9..95bc66ec0 100644 --- a/src/code/V2ServerAPICalls.cs +++ b/src/code/V2ServerAPICalls.cs @@ -1146,6 +1146,7 @@ private async Task HttpRequestCallForContentAsync(string requestUrl { debugMsgs.Enqueue("In V2ServerAPICalls::HttpRequestCallForContentAsync()"); HttpContent content = null; + int errorCountBeforeRequest = errorMsgs.Count; try { @@ -1154,6 +1155,14 @@ private async Task HttpRequestCallForContentAsync(string requestUrl content = await SendV2RequestForContentAsync(request, _sessionClient); } + catch (ResourceNotFoundException e) + { + errorMsgs.Enqueue(new ErrorRecord( + e, + "ResourceNotFound", + ErrorCategory.ObjectNotFound, + this)); + } catch (HttpRequestException e) { errorMsgs.Enqueue(new ErrorRecord( @@ -1179,7 +1188,8 @@ private async Task 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 ((content == null || string.IsNullOrEmpty(content.ToString())) && errorMsgs.Count == errorCountBeforeRequest) { errorMsgs.Enqueue(new ErrorRecord( new ArgumentException($"Response returned is null or empty. Request for content made: '{requestUrlV2}'"), @@ -1204,6 +1214,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( @@ -1727,8 +1745,15 @@ public override async Task FindVersionGlobbingAsync(string packageN List responses = new List(); int skip = 0; + int errorCountBeforeRequest = errorMsgs.Count; var initialResponse = await FindVersionGlobbingAsync(packageName, versionRange, includePrerelease, type, skip, getOnlyLatest, errorMsgs, warningMsgs, debugMsgs, verboseMsgs); + // HttpRequestCallAsync reports failures through errorMsgs rather than throwing; don't also report a parse/not-found error. + if (string.IsNullOrEmpty(initialResponse) && errorMsgs.Count > errorCountBeforeRequest) + { + return new FindResults(stringResponse: Utils.EmptyStrArray, hashtableResponse: emptyHashResponses, responseType: v2FindResponseType); + } + int initialCount = GetCountFromResponse(initialResponse, out string errStr); if (errStr != null) { @@ -1936,15 +1961,19 @@ private async Task InstallVersionAsync(string packageName, string versio requestUrlV2 = $"{Repository.Uri}/package/{packageName}/{version}"; } + int errorCountBeforeRequest = errorMsgs.Count; var response = await HttpRequestCallForContentAsync(requestUrlV2, errorMsgs, warningMsgs, debugMsgs, verboseMsgs); if (response is null) { - errorMsgs.Enqueue(new ErrorRecord( - new Exception($"No content was returned by repository '{Repository.Name}'"), - "InstallFailureContentNullv2Async", - ErrorCategory.InvalidResult, - this)); + if (errorMsgs.Count == errorCountBeforeRequest) + { + errorMsgs.Enqueue(new ErrorRecord( + new Exception($"No content was returned by repository '{Repository.Name}'"), + "InstallFailureContentNullv2Async", + ErrorCategory.InvalidResult, + this)); + } return new MemoryStream(); } From 19cb89db2495871660a96ce2c9f896da32989fbe Mon Sep 17 00:00:00 2001 From: alerickson Date: Thu, 5 Nov 2026 09:46:51 -0800 Subject: [PATCH 3/5] Update test script for install, save, update, publish --- test/Test-PSGalleryOutage.ps1 | 114 ++++++++++++++++++++++++++-------- 1 file changed, 88 insertions(+), 26 deletions(-) diff --git a/test/Test-PSGalleryOutage.ps1 b/test/Test-PSGalleryOutage.ps1 index d86363262..6a077fd22 100644 --- a/test/Test-PSGalleryOutage.ps1 +++ b/test/Test-PSGalleryOutage.ps1 @@ -3,19 +3,34 @@ Simulates a PowerShell Gallery outage locally to verify the aka.ms/psgallery hint in PSResourceGet errors. .DESCRIPTION 1. Maps a fake gallery host (test.powershellgallery.com) to 127.0.0.1 in the hosts file (requires elevation). - 2. Starts a tiny local HTTP server that returns the given status code for every request. - 3. Registers a temporary V2 repository pointing at the fake host and runs Find-PSResource against it. + 2. Starts a tiny local HTTP server on that host that returns -StatusCode: + -Mode All every request fails (search, download, and publish). + -Mode DownloadOnly search requests are forwarded to the real PowerShell Gallery (with download links + rewritten to the fake host) and only package downloads and publish fail. + 3. Registers a temporary V2 repository pointing at the fake host and runs Find, Install, Save, Update, + and Publish against it, printing every error each command reports. 4. Cleans everything up. + Every error from the fake gallery should end with: + The PowerShell Gallery may be experiencing an issue. Check https://aka.ms/psgallery for the current service status. + This is a manual test (not run by CI). Build the module first, then run from an elevated PowerShell 7 session: ./build.ps1 -Clean -Build -BuildConfiguration Debug -BuildFramework net472 - ./test/FindPSResourceTests/Test-PSGalleryOutage.ps1 -StatusCode 503 + ./test/Test-PSGalleryOutage.ps1 -StatusCode 503 + ./test/Test-PSGalleryOutage.ps1 -StatusCode 404 -Mode DownloadOnly -UpdateName +.PARAMETER UpdateName + Name of a module installed for the current user that has a newer version on the PowerShell Gallery. Update-PSResource + only reaches the download step for such a module. If omitted, the Update case is skipped. Downloads always fail, so the + installed module is never changed. #> param( [int] $StatusCode = 503, + [ValidateSet('All', 'DownloadOnly')] + [string] $Mode = 'All', + [string] $UpdateName, [int] $Port = 8080, [string] $FakeHost = 'test.powershellgallery.com', - [string] $ModulePath = (Join-Path $PSScriptRoot '..\..\out\Microsoft.PowerShell.PSResourceGet\Microsoft.PowerShell.PSResourceGet.psd1'), + [string] $ModulePath = (Join-Path $PSScriptRoot '..\out\Microsoft.PowerShell.PSResourceGet\Microsoft.PowerShell.PSResourceGet.psd1'), [switch] $SkipHostsFile ) @@ -23,27 +38,58 @@ $ErrorActionPreference = 'Stop' $hostsFile = "$env:SystemRoot\System32\drivers\etc\hosts" $hostsLine = "127.0.0.1`t$FakeHost # PSResourceGet outage test" $repoName = 'FakePSGalleryOutage' +$workDir = Join-Path ([System.IO.Path]::GetTempPath()) 'PSGalleryOutageTest' + +# Bind the port here (not inside the job) so a port that's already in use fails fast instead of +# silently sending requests to whatever else is listening on it. +$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port) +try { $listener.Start() } +catch { throw "Port $Port is already in use. Stop the process using it or pass a different -Port. $_" } -$server = Start-ThreadJob -ArgumentList $Port, $StatusCode -ScriptBlock { - param($Port, $StatusCode) - $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port) - $listener.Start() - try { - # Poll instead of blocking on AcceptTcpClient so Stop-Job can end the thread. - while ($true) { - if (-not $listener.Pending()) { Start-Sleep -Milliseconds 100; continue } - $client = $listener.AcceptTcpClient() +$server = Start-ThreadJob -ArgumentList $listener, $StatusCode, $Mode, "http://${FakeHost}:$Port/api/v2" -ScriptBlock { + param($listener, $StatusCode, $Mode, $fakeRepoUri) + $http = [System.Net.Http.HttpClient]::new() + # Poll instead of blocking on AcceptTcpClient so Stop-Job can end the thread. + while ($true) { + if (-not $listener.Pending()) { Start-Sleep -Milliseconds 50; continue } + $client = $listener.AcceptTcpClient() + try { $stream = $client.GetStream() $reader = [System.IO.StreamReader]::new($stream) + $requestLine = $reader.ReadLine() while (($line = $reader.ReadLine()) -ne '' -and $null -ne $line) { } - $body = "Simulated $StatusCode" - $response = "HTTP/1.1 $StatusCode Simulated`r`nContent-Type: text/plain`r`nContent-Length: $($body.Length)`r`nConnection: close`r`n`r`n$body" - $bytes = [System.Text.Encoding]::ASCII.GetBytes($response) + $method, $path = ($requestLine -split ' ')[0, 1] + + if ($Mode -eq 'DownloadOnly' -and $method -eq 'GET' -and $path -notmatch '/package/') { + # Forward search/metadata queries to the real gallery, pointing download links back at this server. + $body = $http.GetStringAsync("https://www.powershellgallery.com$path").GetAwaiter().GetResult() + $bytes = [System.Text.Encoding]::UTF8.GetBytes($body.Replace('https://www.powershellgallery.com/api/v2', $fakeRepoUri)) + $header = "HTTP/1.1 200 OK`r`nContent-Type: application/atom+xml;charset=utf-8`r`nContent-Length: $($bytes.Length)`r`nConnection: close`r`n`r`n" + } + else { + $bytes = [System.Text.Encoding]::ASCII.GetBytes("Simulated $StatusCode") + $header = "HTTP/1.1 $StatusCode Simulated`r`nContent-Type: text/plain`r`nContent-Length: $($bytes.Length)`r`nConnection: close`r`n`r`n" + } + + $headerBytes = [System.Text.Encoding]::ASCII.GetBytes($header) + $stream.Write($headerBytes, 0, $headerBytes.Length) $stream.Write($bytes, 0, $bytes.Length) - $client.Close() } + finally { $client.Close() } + } +} + +function Invoke-OutageCase([string] $Name, [scriptblock] $Command) { + Write-Host "`n--- $Name ---" -ForegroundColor Cyan + $errors = @() + try { $errors = @(& $Command 2>&1 | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) } + catch { $errors += $_ } + + if (-not $errors) { Write-Host '(no error)' -ForegroundColor Green } + foreach ($err in $errors) { + $color = if ($err.Exception.Message -like '*aka.ms/psgallery*') { 'Yellow' } else { 'DarkGray' } + Write-Host $err.Exception.Message -ForegroundColor $color } - finally { $listener.Stop() } } try { @@ -52,23 +98,39 @@ try { Clear-DnsClientCache } + Remove-Item $workDir -Recurse -Force -ErrorAction SilentlyContinue + $publishDir = New-Item -ItemType Directory -Path (Join-Path $workDir 'OutageTestModule') -Force + New-ModuleManifest -Path (Join-Path $publishDir 'OutageTestModule.psd1') -ModuleVersion 1.0.0 -Author 'test' -Description 'Outage test module' + Import-Module $ModulePath -Force Register-PSResourceRepository -Name $repoName -Uri "http://${FakeHost}:$Port/api/v2" -ApiVersion V2 -Trusted -Force - Start-Sleep -Seconds 1 - Write-Host "`n--- Find-PSResource -Name against fake gallery returning HTTP $StatusCode ---" -ForegroundColor Cyan - Find-PSResource -Name PowerShellGet -Repository $repoName -ErrorAction Continue 2>&1 | - ForEach-Object { Write-Host $_ -ForegroundColor Yellow } + Write-Host "Fake gallery: http://${FakeHost}:$Port/api/v2 Mode: $Mode Status: $StatusCode" -ForegroundColor Cyan + Write-Host 'Errors that include the aka.ms/psgallery hint are shown in yellow; other errors in gray.' -ForegroundColor Cyan - Write-Host "`n--- Find-PSResource -Name -Version against fake gallery returning HTTP $StatusCode ---" -ForegroundColor Cyan - Find-PSResource -Name PowerShellGet -Version 2.2.5 -Repository $repoName -ErrorAction Continue 2>&1 | - ForEach-Object { Write-Host $_ -ForegroundColor Yellow } + $ErrorActionPreference = 'Continue' + Invoke-OutageCase 'Find-PSResource -Name' { Find-PSResource -Name PowerShellGet -Repository $repoName } + Invoke-OutageCase 'Find-PSResource -Name -Version' { Find-PSResource -Name PowerShellGet -Version 2.2.5 -Repository $repoName } + Invoke-OutageCase 'Find-PSResource -Name -Version (range)' { Find-PSResource -Name PowerShellGet -Version '[2.0.0, 3.0.0]' -Repository $repoName } + # -Reinstall forces the download step even if the module is already installed; the download always fails. + Invoke-OutageCase 'Install-PSResource' { Install-PSResource -Name PowerShellGet -Version 2.2.5 -Repository $repoName -Scope CurrentUser -Reinstall } + Invoke-OutageCase 'Save-PSResource' { Save-PSResource -Name PowerShellGet -Version 2.2.5 -Repository $repoName -Path $workDir } + if ($UpdateName) { + Invoke-OutageCase "Update-PSResource -Name $UpdateName" { Update-PSResource -Name $UpdateName -Repository $repoName -Scope CurrentUser } + } + else { + Write-Host "`n--- Update-PSResource --- skipped (pass -UpdateName to test it)" -ForegroundColor DarkGray + } + Invoke-OutageCase 'Publish-PSResource' { Publish-PSResource -Path $publishDir -Repository $repoName -ApiKey 'fake' -SkipDependenciesCheck -AllowInsecureConnection $true } } finally { + $ErrorActionPreference = 'Continue' Unregister-PSResourceRepository -Name $repoName -ErrorAction SilentlyContinue if (-not $SkipHostsFile) { (Get-Content $hostsFile) | Where-Object { $_ -ne $hostsLine } | Set-Content $hostsFile Clear-DnsClientCache } Stop-Job $server; Remove-Job $server -Force -} \ No newline at end of file + $listener.Stop() + Remove-Item $workDir -Recurse -Force -ErrorAction SilentlyContinue +} From cee73333d62b11276548aa608beba232f466ddca Mon Sep 17 00:00:00 2001 From: alerickson Date: Thu, 5 Nov 2026 13:16:34 -0800 Subject: [PATCH 4/5] 404s should not return messaging --- src/code/Utils.cs | 10 +++++++--- test/Test-PSGalleryOutage.ps1 | 28 ++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/code/Utils.cs b/src/code/Utils.cs index e7c44ce09..e772258eb 100644 --- a/src/code/Utils.cs +++ b/src/code/Utils.cs @@ -227,7 +227,7 @@ public static string FormatRequestsExceptions(Exception exception, HttpRequestMe /// /// Formats an HTTP request exception and, when the request targeted the PowerShell Gallery and failed - /// with a 404 or 5xx status code, appends a pointer to the PowerShell Gallery status page. + /// with a 5xx status code, appends a pointer to the PowerShell Gallery status page. /// public static string FormatRequestsExceptions(Exception exception, HttpRequestMessage request, HttpStatusCode statusCode) { @@ -236,7 +236,7 @@ public static string FormatRequestsExceptions(Exception exception, HttpRequestMe /// /// Returns a sentence pointing to the PowerShell Gallery status page when the request targeted the - /// PowerShell Gallery and failed with a 404 or 5xx status code; otherwise returns an empty string. + /// PowerShell Gallery and failed with a 5xx status code; otherwise returns an empty string. /// public static string GetPSGalleryStatusMessage(Uri requestUri, HttpStatusCode statusCode) { @@ -260,6 +260,10 @@ public static string GetPSGalleryStatusMessage(string requestUri, string excepti return match.Success ? GetPSGalleryStatusMessage(uri, (HttpStatusCode)int.Parse(match.Groups[1].Value)) : string.Empty; } + /// + /// 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. + /// public static bool IsPSGalleryServiceError(Uri requestUri, HttpStatusCode statusCode) { if (requestUri == null || !requestUri.IsAbsoluteUri) @@ -271,7 +275,7 @@ public static bool IsPSGalleryServiceError(Uri requestUri, HttpStatusCode status bool isPSGallery = host.Equals("powershellgallery.com", StringComparison.OrdinalIgnoreCase) || host.EndsWith(".powershellgallery.com", StringComparison.OrdinalIgnoreCase); - return isPSGallery && (statusCode == HttpStatusCode.NotFound || (int)statusCode >= 500); + return isPSGallery && (int)statusCode >= 500 && (int)statusCode < 600; } public static string FormatCredentialRequestExceptions(Exception exception) diff --git a/test/Test-PSGalleryOutage.ps1 b/test/Test-PSGalleryOutage.ps1 index 6a077fd22..0bb36ec04 100644 --- a/test/Test-PSGalleryOutage.ps1 +++ b/test/Test-PSGalleryOutage.ps1 @@ -11,13 +11,15 @@ and Publish against it, printing every error each command reports. 4. Cleans everything up. - Every error from the fake gallery should end with: + For a 5xx -StatusCode, every error from the fake gallery should end with: The PowerShell Gallery may be experiencing an issue. Check https://aka.ms/psgallery for the current service status. + For any other status code (for example 404, which can legitimately mean "not found"), the errors should NOT include it. This is a manual test (not run by CI). Build the module first, then run from an elevated PowerShell 7 session: ./build.ps1 -Clean -Build -BuildConfiguration Debug -BuildFramework net472 ./test/Test-PSGalleryOutage.ps1 -StatusCode 503 - ./test/Test-PSGalleryOutage.ps1 -StatusCode 404 -Mode DownloadOnly -UpdateName + ./test/Test-PSGalleryOutage.ps1 -StatusCode 500 -Mode DownloadOnly -UpdateName + ./test/Test-PSGalleryOutage.ps1 -StatusCode 404 .PARAMETER UpdateName Name of a module installed for the current user that has a newer version on the PowerShell Gallery. Update-PSResource only reaches the download step for such a module. If omitted, the Update case is skipped. Downloads always fail, so the @@ -34,6 +36,26 @@ param( [switch] $SkipHostsFile ) +# Run in a fresh pwsh process so the module under test is loaded from the current build and released afterward. +# Importing it into the caller's session would lock the DLLs in .\out (breaking ./build.ps1 -Clean) and, because +# .NET can't unload assemblies, later runs in the same session would keep using the old build. +if (-not $env:PSRESOURCEGET_OUTAGE_TEST_CHILD) { + $childArgs = @('-NoProfile', '-File', $PSCommandPath) + foreach ($param in $PSBoundParameters.GetEnumerator()) { + if ($param.Value -is [switch]) { + if ($param.Value.IsPresent) { $childArgs += "-$($param.Key)" } + } + else { + $childArgs += "-$($param.Key)", "$($param.Value)" + } + } + + $env:PSRESOURCEGET_OUTAGE_TEST_CHILD = '1' + try { & (Get-Process -Id $PID).Path @childArgs } + finally { Remove-Item Env:\PSRESOURCEGET_OUTAGE_TEST_CHILD -ErrorAction SilentlyContinue } + return +} + $ErrorActionPreference = 'Stop' $hostsFile = "$env:SystemRoot\System32\drivers\etc\hosts" $hostsLine = "127.0.0.1`t$FakeHost # PSResourceGet outage test" @@ -107,6 +129,8 @@ try { Write-Host "Fake gallery: http://${FakeHost}:$Port/api/v2 Mode: $Mode Status: $StatusCode" -ForegroundColor Cyan Write-Host 'Errors that include the aka.ms/psgallery hint are shown in yellow; other errors in gray.' -ForegroundColor Cyan + $expectHint = $StatusCode -ge 500 -and $StatusCode -lt 600 + Write-Host ("Expected for HTTP {0}: {1}" -f $StatusCode, $(if ($expectHint) { 'gallery errors include the hint (yellow).' } else { 'no error includes the hint (all gray).' })) -ForegroundColor Cyan $ErrorActionPreference = 'Continue' Invoke-OutageCase 'Find-PSResource -Name' { Find-PSResource -Name PowerShellGet -Repository $repoName } From 3d7a751024d71a637ed2925c2cbf7b67a550390f Mon Sep 17 00:00:00 2001 From: alerickson Date: Mon, 9 Nov 2026 14:08:27 -0800 Subject: [PATCH 5/5] Make V2 request failures suppress follow-up parse/not-found errors via an explicit return value instead of the shared error count, and add tests for the PowerShell Gallery status hint --- src/code/V2ServerAPICalls.cs | 41 ++++++++----------- .../PublishPSResource.Tests.ps1 | 17 ++++++++ 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/src/code/V2ServerAPICalls.cs b/src/code/V2ServerAPICalls.cs index 95bc66ec0..ec1287aa9 100644 --- a/src/code/V2ServerAPICalls.cs +++ b/src/code/V2ServerAPICalls.cs @@ -434,8 +434,7 @@ public override async Task FindNameAsync(string packageName, bool i } var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; - string response = string.Empty; - int errorCountBeforeRequest = errorMsgs.Count; + string response = null; try { response = await HttpRequestCallAsync(requestUrlV2, errorMsgs, warningMsgs, debugMsgs, verboseMsgs); @@ -477,8 +476,8 @@ public override async Task FindNameAsync(string packageName, bool i } } - // HttpRequestCallAsync reports failures through errorMsgs rather than throwing; don't also report a parse/not-found error. - if (string.IsNullOrEmpty(response) && errorMsgs.Count > errorCountBeforeRequest) + // 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); } @@ -839,8 +838,7 @@ public override async Task FindVersionAsync(string packageName, str } var requestUrlV2 = $"{Repository.Uri}/FindPackagesById()?{queryBuilder.BuildQueryString()}"; - string response = string.Empty; - int errorCountBeforeRequest = errorMsgs.Count; + string response = null; try { response = await HttpRequestCallAsync(requestUrlV2, errorMsgs, warningMsgs, debugMsgs, verboseMsgs); @@ -882,8 +880,8 @@ public override async Task FindVersionAsync(string packageName, str } } - // HttpRequestCallAsync reports failures through errorMsgs rather than throwing; don't also report a parse/not-found error. - if (string.IsNullOrEmpty(response) && errorMsgs.Count > errorCountBeforeRequest) + // 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); } @@ -1085,11 +1083,13 @@ private string HttpRequestCall(string requestUrlV2, out ErrorRecord errRecord) /// /// 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. /// private async Task HttpRequestCallAsync(string requestUrlV2, ConcurrentQueue errorMsgs, ConcurrentQueue warningMsgs, ConcurrentQueue debugMsgs, ConcurrentQueue verboseMsgs) { debugMsgs.Enqueue("In V2ServerAPICalls::HttpRequestCallAsync()"); - string response = string.Empty; + string response = null; try { @@ -1146,7 +1146,7 @@ private async Task HttpRequestCallForContentAsync(string requestUrl { debugMsgs.Enqueue("In V2ServerAPICalls::HttpRequestCallForContentAsync()"); HttpContent content = null; - int errorCountBeforeRequest = errorMsgs.Count; + bool requestFailed = true; try { @@ -1154,6 +1154,7 @@ private async Task HttpRequestCallForContentAsync(string requestUrl HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, requestUrlV2); content = await SendV2RequestForContentAsync(request, _sessionClient); + requestFailed = false; } catch (ResourceNotFoundException e) { @@ -1189,7 +1190,7 @@ private async Task HttpRequestCallForContentAsync(string requestUrl } // Only report an empty response if the request itself didn't already report a failure. - if ((content == null || string.IsNullOrEmpty(content.ToString())) && errorMsgs.Count == errorCountBeforeRequest) + 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}'"), @@ -1745,11 +1746,10 @@ public override async Task FindVersionGlobbingAsync(string packageN List responses = new List(); int skip = 0; - int errorCountBeforeRequest = errorMsgs.Count; var initialResponse = await FindVersionGlobbingAsync(packageName, versionRange, includePrerelease, type, skip, getOnlyLatest, errorMsgs, warningMsgs, debugMsgs, verboseMsgs); - // HttpRequestCallAsync reports failures through errorMsgs rather than throwing; don't also report a parse/not-found error. - if (string.IsNullOrEmpty(initialResponse) && errorMsgs.Count > errorCountBeforeRequest) + // 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); } @@ -1782,7 +1782,7 @@ public override async Task 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--; } } @@ -1961,20 +1961,11 @@ private async Task InstallVersionAsync(string packageName, string versio requestUrlV2 = $"{Repository.Uri}/package/{packageName}/{version}"; } - int errorCountBeforeRequest = errorMsgs.Count; var response = await HttpRequestCallForContentAsync(requestUrlV2, errorMsgs, warningMsgs, debugMsgs, verboseMsgs); + // HttpRequestCallForContentAsync has already reported why no content was returned. if (response is null) { - if (errorMsgs.Count == errorCountBeforeRequest) - { - errorMsgs.Enqueue(new ErrorRecord( - new Exception($"No content was returned by repository '{Repository.Name}'"), - "InstallFailureContentNullv2Async", - ErrorCategory.InvalidResult, - this)); - } - return new MemoryStream(); } diff --git a/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 b/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 index c8965ed24..dce3f74b2 100644 --- a/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 +++ b/test/PublishPSResourceTests/PublishPSResource.Tests.ps1 @@ -42,6 +42,23 @@ function script:CreateTestModule '@ | Out-File -FilePath $moduleSrc } +Describe 'PowerShell Gallery status hint' -tags 'CI' { + BeforeAll { + $utilsType = [Microsoft.PowerShell.PSResourceGet.Cmdlets.PublishPSResource].Assembly.GetType('Microsoft.PowerShell.PSResourceGet.UtilClasses.Utils') + $statusMessageMethod = $utilsType.GetMethod('GetPSGalleryStatusMessage', [type[]]@([uri], [System.Net.HttpStatusCode])) + } + + It 'includes the hint for a 503 from the PowerShell Gallery' { + $hint = $statusMessageMethod.Invoke($null, @([uri]'https://www.powershellgallery.com/api/v2', [System.Net.HttpStatusCode]::ServiceUnavailable)) + $hint | Should -Match 'https://aka.ms/psgallery' + } + + It 'does not include the hint for a 503 from a non-Gallery host' { + $hint = $statusMessageMethod.Invoke($null, @([uri]'https://example.org/api/v2', [System.Net.HttpStatusCode]::ServiceUnavailable)) + $hint | Should -BeNullOrEmpty + } +} + Describe "Test Publish-PSResource" -tags 'CI' { BeforeAll { Get-NewPSResourceRepositoryFile