Public/Invoke/Invoke-CWMWebRequest.ps1
|
<#
.SYNOPSIS Low-level HTTP request handler for all ConnectWise Manage API calls. .DESCRIPTION Core function that handles all HTTP requests to the ConnectWise Manage API. Manages authentication headers, connection state, error handling, and automatic retry of transient failures that are safe to repeat. This is the foundation function used by all other Invoke-CWM*Master functions. It should rarely be called directly - use the higher-level master functions instead. Behaves the same on Windows PowerShell 5.1 and PowerShell 7. Key Features: - Validates connection state ($CWMServerConnection) - Adds authentication headers automatically - Handles URI formatting corrections - Retries 429 (any method) and 502/503/504 (GET/HEAD and read-only searches) with Retry-After or exponential backoff - Provides detailed error messages with troubleshooting hints - Records the server's api-current-version response header .PARAMETER Arguments Hashtable of arguments to pass to Invoke-WebRequest. Should include: Uri, Method, and optionally Body, Headers, ContentType The API version is not set per request: the Accept header from Connect-CWM (the server's current version) is used for every call. The hashtable is copied, so the caller's hashtable (and its Headers) is not modified. ReadOnly = $true marks a POST that only reads (a /search endpoint), so it is retried like a GET. The key is not sent to Invoke-WebRequest. A WebSession in the hashtable is used instead of the connection's session. .PARAMETER MaxRetry Maximum number of retry attempts. Default: 5 Only 429 Too Many Requests (any method) and 502/503/504 on GET/HEAD (or a request with ReadOnly = $true) are retried. Writes that fail with a 5xx are never retried, because the change may already have been applied and repeating it could create a duplicate. The wait is the server's Retry-After when present, otherwise 2, 4, 8, 16, 32 seconds (capped at 60 seconds). .EXAMPLE $RequestArguments = @{ Uri = 'https://na.myconnectwise.net/v4_6_release/apis/3.0/service/tickets/12345' Method = 'GET' } Invoke-CWMWebRequest -Arguments $RequestArguments Makes a GET request to retrieve a ticket. .NOTES Author: Chris Taylor Internal Function: - Called by all Invoke-CWM*Master functions - Handles authentication automatically - Manages retry logic with exponential backoff - Provides detailed error messages Insecure redirection (PowerShell 7.4+): - When connected with Connect-CWM -AllowInsecureRedirect, non-GET requests are sent with -AllowInsecureRedirect -MaximumRedirection 0 (redirects are never followed), and GET requests with -MaximumRedirection 50 (the default, re-applied because the setting persists on a shared session). - A caller-supplied non-zero MaximumRedirection disables the workaround for that call. Error Handling: - Validates connection state before requests - Parses API error responses - Provides context-specific troubleshooting hints - Retries only transient failures that are safe to repeat (see MaxRetry) - The parsed error body is kept in $script:ErrBody and the raw ErrorDetails in $script:ErrDetails .LINK https://developer.connectwise.com/manage/rest .LINK Connect-CWM .LINK Invoke-CWMGetMaster #> function Invoke-CWMWebRequest { [CmdletBinding()] param( $Arguments, [int]$MaxRetry = 5 ) # Check that we have cached connection info if (!$script:CWMServerConnection) { $ErrorMessage = @() $ErrorMessage += 'Not connected to a Manage server.' $ErrorMessage += '--> $CWMServerConnection variable not found.' $ErrorMessage += "----> Run 'Connect-CWM' to initialize the connection before issuing other CWM cmdlets." return Write-Error ($ErrorMessage | Out-String) } # Work on copies: callers reuse their hashtable (Invoke-CWMAllResult, per page) and may pass a Headers # hashtable they still own (e.g. the connection's), which must not pick up per-request changes. if ($Arguments -is [hashtable]) { $Arguments = $Arguments.Clone() } if ($Arguments.Headers -is [hashtable]) { $Arguments.Headers = $Arguments.Headers.Clone() } elseif (!$Arguments.Headers) { $Arguments.Headers = @{} } # Per-request API versions are no longer supported; drop a stale 'Version' key instead of failing. # ReadOnly (a search POST) is an instruction for the retry check, not an Invoke-WebRequest parameter. $ReadOnly = [bool]$Arguments.ReadOnly if ($Arguments -is [hashtable]) { $Arguments.Remove('Version'); $Arguments.Remove('ReadOnly') } # Add the connection's headers (Authorization, ClientID, Accept with the API version), unless the caller set them foreach ($Key in $script:CWMServerConnection.Headers.Keys) { if ($Arguments.Headers.Keys -notcontains $Key) { $Arguments.Headers[$Key] = $script:CWMServerConnection.Headers.$Key } } if (!$Arguments.SessionVariable -and !$Arguments.WebSession) { $Arguments.WebSession = $script:CWMServerConnection.Session } if (!$Arguments.UserAgent -and $script:CWMUserAgent) { $Arguments.UserAgent = $script:CWMUserAgent } # Check URI format if ($Arguments.URI -notlike '*`?*' -and $Arguments.URI -like '*`&*') { $Arguments.URI = $Arguments.URI -replace '(.*?)&(.*)', '$1?$2' } # Opt-in workaround (Connect-CWM -AllowInsecureRedirect) for ConnectWise-hosted servers that return an # http:// Location header on writes, which PowerShell 7.4+ rejects as an insecure redirection. # -AllowInsecureRedirect is ONLY ever paired with -MaximumRedirection 0, so no redirect is followed and # nothing (including non-Secure session cookies) is sent over http. Any other redirect count combined # with -AllowInsecureRedirect would follow https->http redirects, so a caller-supplied non-zero # MaximumRedirection disables the workaround for that call. if ($script:CWMServerConnection.AllowInsecureRedirect -and $script:CWMSupportsInsecureRedirect) { $CallerSetRedirect = $Arguments.ContainsKey('MaximumRedirection') if (!$Arguments.Method -or $Arguments.Method -eq 'Get') { # MaximumRedirection 0 persists on a shared WebSession, so reads restore the .NET default explicitly if (!$CallerSetRedirect) { $Arguments.MaximumRedirection = 50 } } elseif (!$CallerSetRedirect -or $Arguments.MaximumRedirection -eq 0) { $Arguments.AllowInsecureRedirect = $true $Arguments.MaximumRedirection = 0 } } # Hide the Invoke-WebRequest progress bar. Function scope only: never touch the caller's or the global preference. $ProgressPreference = 'SilentlyContinue' # Only build the debug dump when it will be shown: serializing a large (e.g. byte[] document) body is slow. # Binary bodies are summarized, secrets in the body (passwords, tokens, keys) are masked, the WebSession object # is reduced to its type, and Authorization is masked. if ($DebugPreference -ne 'SilentlyContinue') { $DebugArguments = $Arguments.Clone() $DebugArguments.Body = ConvertTo-CWMRedacted $DebugArguments.Body if ($DebugArguments.WebSession) { $DebugArguments.WebSession = $DebugArguments.WebSession.GetType().FullName } if ($DebugArguments.Headers -is [hashtable] -and $DebugArguments.Headers.Authorization) { $DebugArguments.Headers = $DebugArguments.Headers.Clone() $DebugArguments.Headers.Authorization = '[redacted]' } Write-Debug "Arguments: $($DebugArguments | ConvertTo-Json -Depth 5)" } # Issue request, retrying only where repeating the request cannot duplicate a change (see Test-CWMRetryable) $Attempt = 0 while ($true) { $RetryDelay = $null try { # -Debug:$false: PowerShell 7's own debug output prints request headers, including Authorization $Result = Invoke-WebRequest @Arguments -UseBasicParsing -Debug:$false } catch { # Capture now: nested try/catch blocks below replace $_ $WebError = $_ # Reads (GET/HEAD, and ReadOnly search POSTs) change nothing, so the warnings below differ for them $IsRead = $ReadOnly -or !$Arguments.Method -or @('Get', 'Head') -contains $Arguments.Method # PowerShell 7.4+ insecure redirection check (any http:// Location header on an https request) if ($WebError.FullyQualifiedErrorId -like 'InsecureRedirection*') { if ($IsRead) { $ErrorMessage = @( 'The server redirected this read request to an http:// address, which PowerShell 7.4+ rejects as an insecure redirection. The redirect was not followed.' '--> Nothing was changed. Check the -Server value passed to Connect-CWM.' '--> If it keeps happening, please report it at https://github.com/christaylorcodes/ConnectWiseManageAPI/issues' $WebError.ScriptStackTrace ) } else { $ErrorMessage = @( 'The server response pointed to an http:// address, which PowerShell 7.4+ rejects as an insecure redirection.' '--> On ConnectWise-hosted servers this is usually a Location header on a successful create/update, not a real redirect.' '--> The record MAY HAVE BEEN CREATED or changed. Check before retrying to avoid duplicates.' "--> To work around it, reconnect with 'Connect-CWM ... -AllowInsecureRedirect'. See: Get-Help Connect-CWM -Parameter AllowInsecureRedirect" $WebError.ScriptStackTrace ) } return Write-Error ($ErrorMessage | Out-String) } # 5.1: HttpWebResponse; 7: HttpResponseMessage. Both expose StatusCode. $Response = $null try { $Response = $WebError.Exception.Response } catch { $Response = $null } $StatusCode = 0 try { if ($Response) { $StatusCode = [int]$Response.StatusCode } } catch { $StatusCode = 0 } # Redirect returned while redirects are disabled (e.g. by -AllowInsecureRedirect) if ($StatusCode -ge 300 -and $StatusCode -lt 400) { $Location = $null # 7: HttpResponseHeaders.Location; 5.1: WebHeaderCollection['Location'] try { $Location = [string]$Response.Headers.Location } catch { $Location = $null } if (!$Location) { try { $Location = [string]$Response.Headers['Location'] } catch { $Location = $null } } $LocationHost = $null try { if ($Location) { $LocationHost = ([uri]$Location).Host } } catch { $LocationHost = $null } if (!$LocationHost) { $LocationHost = '(none)' } $ErrorMessage = @( "The server responded with a redirect ($StatusCode) to host $LocationHost that was not followed." "--> Location: $Location" ) if ($IsRead) { $ErrorMessage += '--> Redirects are not followed for this request (MaximumRedirection 0), so it was not completed. Nothing was changed.' } else { $ErrorMessage += "--> Redirects are not followed for $($Arguments.Method) requests (MaximumRedirection 0, e.g. via Connect-CWM -AllowInsecureRedirect), so the redirect was not completed." $ErrorMessage += "--> The server may have processed the request before redirecting: the record MAY HAVE BEEN CREATED or changed. Check before retrying; do not retry blindly or you may create a duplicate." } $ErrorMessage += '--> Please report this, including the status code and Location, at https://github.com/christaylorcodes/ConnectWiseManageAPI/issues' $ErrorMessage += $WebError.ScriptStackTrace return Write-Error ($ErrorMessage | Out-String) } if ($Attempt -lt $MaxRetry -and (Test-CWMRetryable -StatusCode $StatusCode -Method $Arguments.Method -ReadOnly:$ReadOnly)) { $RetryDelay = Get-CWMRetryDelay -Response $Response -Attempt ($Attempt + 1) Write-Warning "Issue with request, status: $StatusCode. Retry $($Attempt + 1)/$($MaxRetry), waiting $($RetryDelay)s." } else { return Write-CWMWebRequestError -WebError $WebError -StatusCode $StatusCode -Attempt $Attempt } } if ($null -eq $RetryDelay) { break } $Attempt++ Start-Sleep -Seconds $RetryDelay } # Save current version if (!$script:CWMServerConnection.'api-current-version') { $script:CWMServerConnection.'api-current-version' = Get-CWMResponseHeader -Response $Result -Name 'api-current-version' } $Result } |