Core/New-TestWebRequestError.ps1

function New-TestWebRequestError {
    <#
    .SYNOPSIS
        Builds the one error shape a failed HTTP response has, on either edition

    .DESCRIPTION
        Windows PowerShell and PowerShell 7 report a failed HTTP response differently: one throws
        a WebException whose Response holds the body on a stream that can be read once, the other
        keeps the body in the error record's ErrorDetails and the status on an HttpResponseMessage.
        Every provider used to handle both. Invoke-TestWebRequest now reads the status, the headers
        and the body once, whichever way it got them, and throws this record, so a provider reads
        one shape:

        - $_.Exception.Message names the method, the URI and the status.
        - $_.Exception.Response.StatusCode is an integer, and .Headers a hashtable - which
          PowerShell keys case-insensitively - with each header's values joined by a comma and a space, so
          $_.Exception.Response.Headers['Retry-After'] reads the same on both editions.
        - $_.ErrorDetails.Message is the body, decoded as UTF-8, when there was one.

        The exception is a plain System.Exception carrying Response as an added property, not a
        WebException, whose Response is read-only and typed to a class PowerShell 7 never
        produces. A transport failure - a refused connection, a name that does not resolve - has
        no response and is never wrapped in this: Invoke-TestWebRequest lets those through
        untouched, and a provider that reads a missing Response as status 0 keeps doing so.

    .PARAMETER Method
        The HTTP method of the request that failed.

    .PARAMETER Uri
        The URI it was sent to.

    .PARAMETER StatusCode
        The HTTP status the server answered with.

    .PARAMETER StatusDescription
        The reason phrase, when one was given.

    .PARAMETER Headers
        The response headers, already normalised to a hashtable.

    .PARAMETER Body
        The decoded body, or nothing.

    .PARAMETER InnerException
        The exception the cmdlet threw, when it threw one, kept as the inner exception.

    .EXAMPLE
        PS> $PSCmdlet.ThrowTerminatingError((New-TestWebRequestError -Method GET -Uri $uri -StatusCode 429 -Headers $headers -Body $body))

        DESCRIPTION: What Invoke-TestWebRequest does with a failed response
        OUTPUT: Nothing; the caller's catch receives the record
        USE CASE: Called only by Invoke-TestWebRequest

    .OUTPUTS
        System.Management.Automation.ErrorRecord

    .NOTES
        Author: Jeffrey Stuhr
        Blog: https://www.techbyjeff.net
        LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Builds an error record in memory; nothing outside the process changes.')]
    [CmdletBinding()]
    [OutputType([System.Management.Automation.ErrorRecord])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Method,

        [Parameter(Mandatory = $true)]
        [string]$Uri,

        [Parameter(Mandatory = $true)]
        [int]$StatusCode,

        [Parameter()]
        [AllowNull()]
        [AllowEmptyString()]
        [string]$StatusDescription,

        [Parameter()]
        [AllowNull()]
        [hashtable]$Headers,

        [Parameter()]
        [AllowNull()]
        [AllowEmptyString()]
        [string]$Body,

        [Parameter()]
        [AllowNull()]
        [System.Exception]$InnerException
    )

    $reason = if ([string]::IsNullOrWhiteSpace($StatusDescription)) { '' } else { " $StatusDescription" }
    $message = "$Method $Uri answered HTTP $StatusCode$reason"

    $exception = if ($InnerException) { New-Object System.Exception($message, $InnerException) } else { New-Object System.Exception($message) }
    $response = [PSCustomObject]@{
        PSTypeName        = 'TestWebResponse'
        StatusCode        = $StatusCode
        StatusDescription = $StatusDescription
        Headers           = if ($Headers) { $Headers } else { @{} }
    }
    # An added property survives the exception being handed around as a .NET object: PowerShell
    # keeps instance members it added in a table keyed by the object, so $_.Exception.Response
    # is there in the caller's catch.
    $exception | Add-Member -NotePropertyName 'Response' -NotePropertyValue $response

    $category = if ($StatusCode -eq 401 -or $StatusCode -eq 403) { [System.Management.Automation.ErrorCategory]::PermissionDenied }
    elseif ($StatusCode -eq 404) { [System.Management.Automation.ErrorCategory]::ObjectNotFound }
    elseif ($StatusCode -ge 500) { [System.Management.Automation.ErrorCategory]::ResourceUnavailable }
    else { [System.Management.Automation.ErrorCategory]::InvalidOperation }

    $record = New-Object System.Management.Automation.ErrorRecord($exception, "TestWebRequest.HTTP$StatusCode", $category, $Uri)
    if (-not [string]::IsNullOrEmpty($Body)) {
        $record.ErrorDetails = New-Object System.Management.Automation.ErrorDetails($Body)
    }
    return $record
}