private/Get-RequestData.ps1
|
# Typed API exception so callers can branch programmatically on the failure kind instead of parsing # the message string. Guarded against re-import type collision (F-30), like the VSAConnection class. # Defined here (dot-sourced at import) because it is only referenced at runtime, never in a param type. if (-not ('VSAApiException' -as [type])) { Add-Type -TypeDefinition @' using System; public class VSAApiException : Exception { // HTTP status code of the failed call. 0 means no HTTP response was received at all // (the socket was reset / the endpoint was unreachable or blocked) -- see ConnectionReset. public int StatusCode; public string HttpMethod; public string RequestUri; // The VSA-specific "Error" field parsed from the response body, when present. public string VSAError; // True when no HTTP response arrived (connection reset / blocked / unreachable). On hardened // (post-2021) VSA builds, user-mutation endpoints reset the connection, surfacing here. public bool ConnectionReset; public VSAApiException(string message) : base(message) { } public VSAApiException(string message, Exception inner) : base(message, inner) { } } '@ } function New-VSAApiError { <# .SYNOPSIS Builds a typed [System.Management.Automation.ErrorRecord] (wrapping a VSAApiException) so callers can branch on $_.Exception.StatusCode / .ConnectionReset or on $_.CategoryInfo.Category. #> # Pure object factory -- constructs and returns an ErrorRecord, changes no system state; the # 'New' verb here does not warrant ShouldProcess. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [CmdletBinding()] [OutputType([System.Management.Automation.ErrorRecord])] param( [Parameter(Mandatory)] [string] $Message, [int] $StatusCode = 0, [string] $Method, [string] $Uri, [string] $VSAError, [Exception] $InnerException, # Set only when no HTTP response arrived (socket reset / blocked / unreachable). [switch] $ConnectionReset ) $ex = if ($InnerException) { New-Object VSAApiException($Message, $InnerException) } else { New-Object VSAApiException($Message) } $ex.StatusCode = $StatusCode $ex.HttpMethod = $Method $ex.RequestUri = $Uri $ex.VSAError = $VSAError $ex.ConnectionReset = $ConnectionReset.IsPresent $category = switch ($StatusCode) { 401 { [System.Management.Automation.ErrorCategory]::AuthenticationError } 403 { [System.Management.Automation.ErrorCategory]::PermissionDenied } 404 { [System.Management.Automation.ErrorCategory]::ObjectNotFound } 0 { [System.Management.Automation.ErrorCategory]::ConnectionError } default { [System.Management.Automation.ErrorCategory]::InvalidOperation } } $errorId = if ($StatusCode -eq 0) { 'VSAConnectionReset' } else { "VSAHttp$StatusCode" } return New-Object System.Management.Automation.ErrorRecord($ex, $errorId, $category, $Uri) } function Get-RequestData { <# .SYNOPSIS Executes a web request and returns the response with automatic retry on transient failures. .DESCRIPTION This function performs a web request and returns the response. It supports various HTTP methods such as GET, POST, PUT, DELETE, and PATCH, and allows customization of the request parameters. This is the blocking entry point to the module's single HTTP stack: it issues the request via Invoke-VSAHttp (System.Net.Http.HttpClient) and interprets the result with Resolve-VSAResponse. Both are shared verbatim with the parallel engine, so the two dispatch modes cannot drift in their retry, envelope or error-typing behaviour (F-67). This function therefore contains no HTTP logic of its own -- it is the sequential adapter over that stack. Automatic retry covers transient HTTP errors (429, 502, 503, 504) and honours the server's Retry-After hint when present, otherwise exponential backoff. A request that gets no HTTP response at all (socket reset / timeout) is retried only for idempotent methods: for a POST/PUT/DELETE the server may have applied the change before the connection dropped, so a retry could duplicate it, and on hardened VSA builds a reset is simply how a blocked write endpoint answers. Such calls therefore fail fast with ConnectionReset. Works on both Windows PowerShell 5.1 (Desktop) and PowerShell 7 (Core): the certificate-error bypass strategy is feature-detected once at import and applied inside the shared stack. .PARAMETER URI Specifies the URI for the web request. .PARAMETER AuthString Specifies the authentication string. .PARAMETER Method Specifies the REST API method (GET, POST, PUT, DELETE, PATCH). .PARAMETER Body Specifies the request's body. .PARAMETER ContentType Specifies the content type of the request (default is "application/json"). .PARAMETER IgnoreCertificateErrors Ignores certificate errors if this switch is present. .PARAMETER MaxRetries Specifies the maximum number of retry attempts for transient errors (default is 3). Valid Range: 0 to 10. Applies to HTTP status codes 429, 502, 503, 504. .PARAMETER TimeoutSec Specifies the request timeout in seconds (default 100). .PARAMETER OutFile When specified, the response body is written to this path (file download) instead of being parsed as an API envelope. .EXAMPLE Get-RequestData -URI $URI -AuthString $AuthString -IgnoreCertificateErrors .EXAMPLE Get-RequestData -URI $URI -AuthString $AuthString -Method POST -Body $Body .INPUTS None. You cannot pipe objects to Get-RequestData. .OUTPUTS Array of custom objects returned by the remote server. .NOTES RELIABILITY: Automatic retry with Retry-After / exponential backoff for transient failures. #> [CmdletBinding()] param ( [parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true)] [ValidateNotNullOrEmpty()] [string] $URI, [parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true)] [ValidateNotNullOrEmpty()] [string] $AuthString, [parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true)] [ValidateSet('GET', 'POST', 'PUT', 'DELETE', 'PATCH')] [string] $Method = 'GET', [parameter(Mandatory=$false)] [ValidateNotNull()] # [string] for JSON request bodies, [byte[]] for raw/multipart bodies (e.g. file uploads). [object] $Body, [parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true)] [ValidateNotNullOrEmpty()] [string] $ContentType = 'application/json', [parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true)] [ValidateRange(0, 10)] [int] $MaxRetries = 3, [parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true)] [ValidateRange(1, 3600)] [int] $TimeoutSec = 100, [parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true)] [ValidateNotNullOrEmpty()] [string] $OutFile, [switch] $IgnoreCertificateErrors ) process { $LogMessage = "URI: '$URI': Method '$Method'" "Get-RequestData. $LogMessage" | Write-Debug Write-Verbose "Get-RequestData. $LogMessage" [hashtable] $HttpParams = @{ Uri = $URI Method = $Method AuthString = $AuthString ContentType = $ContentType MaxRetries = $MaxRetries TimeoutSec = $TimeoutSec } if ($Body) { $HttpParams['Body'] = $Body } if ($OutFile) { $HttpParams['OutFile'] = $OutFile } if ($IgnoreCertificateErrors) { $HttpParams['IgnoreCertificateErrors'] = $true } # Transport: throws a typed VSAApiException on a terminal HTTP/transport failure. $Result = Invoke-VSAHttp @HttpParams # A file download has no API envelope to validate. if ($OutFile) { return } "$($MyInvocation.MyCommand). Response:`n{0}" -f ($Result.Body | Out-String) | Write-Debug # An empty-body 2xx response (HTTP 204 No Content, returned by DELETE and some PUT endpoints) # is a success with nothing to deserialize; Resolve-VSAResponse normalizes it to $null (F-21). if ([string]::IsNullOrWhiteSpace($Result.Body)) { return $null } # A 2xx body that is not JSON. Some VSA 9 endpoints return XML by design (e.g. the agent-procedure # export api/v1.0/automation/agentprocs/proclist, Kaseya's ScExport format), and a genuine error # page can also arrive as HTML/text. ConvertFrom-Json throws an opaque parser error on either # edition (on 5.1: "Invalid JSON primitive"); surface it as a typed VSAApiException that names the # cause, so callers get the same branchable error contract as any other transport failure. (F-72) try { $Response = $Result.Body | ConvertFrom-Json } catch { $bodyStart = ("$($Result.Body)").TrimStart() $kind = if ($bodyStart -like '<?xml*' -or $bodyStart -like '<*') { 'XML' } else { 'not JSON' } throw (New-VSAApiError -Message "The VSA API returned a non-JSON response for $Method $URI. The response body is $kind, not JSON, so it cannot be deserialized. Underlying error: $($_.Exception.Message)" ` -StatusCode ([int]$Result.StatusCode) -Method $Method -Uri $URI -InnerException $_.Exception) } return (Resolve-VSAResponse -Response $Response -Method $Method -Uri $URI) } } #endregion function Get-RequestData |