Private/Invoke-ConfluenceHttpRequest.ps1
|
function Invoke-ConfluenceHttpRequest { <# .SYNOPSIS Sends one HTTP request to Confluence and returns the status code, body and Retry-After value. .DESCRIPTION Wraps Invoke-WebRequest so HTTP error responses are returned instead of thrown on both Windows PowerShell 5.1 and PowerShell 7. Returns an object with StatusCode, StatusDescription, Content and RetryAfter. Transport failures (DNS, TLS, timeouts) are rethrown and not retried. The request headers, which carry the credential, are never written to any output stream. A 429 Too Many Requests response is retried up to -MaxRetry times with tcs.core Invoke-WithRetry. The wait is the Retry-After header (seconds or an HTTP date, capped at 60 seconds) or, without that header, 2, 4, 8 ... seconds. No other status is retried. When the last retry is still throttled, the 429 response is returned like any other error response. #> [CmdletBinding()] [OutputType([pscustomobject])] param ( [Parameter(Mandatory)] [string]$Uri, [Parameter(Mandatory)] [ValidateSet('GET', 'POST', 'PUT', 'DELETE')] [string]$Method, [string]$Body, [byte[]]$BodyBytes, [string]$ContentType = 'application/json; charset=utf-8', [hashtable]$AdditionalHeaders, [ValidateRange(0, 10)] [int]$MaxRetry = 4 ) # Shared with the script blocks below: the number of requests sent, the 429 response of the # latest request and the wait before the next retry $state = @{ Attempt = 0; Throttled = $null; DelaySeconds = 0 } $sendRequest = { $state.Attempt++ $state.Throttled = $null $response = Invoke-ConfluenceHttpRequestOnce -Uri $Uri -Method $Method -Body $Body -BodyBytes $BodyBytes -ContentType $ContentType -AdditionalHeaders $AdditionalHeaders if ($response.StatusCode -ne 429) { return $response } # Throw the 429 so that Invoke-WithRetry retries it. The wait is passed on as the error's # Retry-After value, so Invoke-WithRetry sleeps exactly that long. $state.Throttled = $response $state.DelaySeconds = Get-ConfluenceRetryDelay -RetryAfter $response.RetryAfter -Attempt $state.Attempt $exception = New-Object -TypeName System.Exception -ArgumentList "Confluence returned $($response.StatusCode) $($response.StatusDescription)." $throttledResponse = [pscustomobject]@{ StatusCode = $response.StatusCode StatusDescription = $response.StatusDescription Headers = @{ 'Retry-After' = [string]$state.DelaySeconds } } Add-Member -InputObject $exception -MemberType NoteProperty -Name Response -Value $throttledResponse throw $exception } # Only a 429 is retried; errors without an HTTP response (transport failures) are not $isThrottled = { param ($ErrorRecord) $detail = Get-HttpErrorDetail -ErrorRecord $ErrorRecord return ($null -ne $state.Throttled -and $null -ne $detail -and $detail.StatusCode -eq 429) } $onRetry = { param ($RetryException, $RetryAttempt) Write-Verbose "Confluence returned 429 Too Many Requests; retry $RetryAttempt of $MaxRetry in $($state.DelaySeconds) second(s)." } $retryParams = @{ ScriptBlock = $sendRequest MaxRetries = $MaxRetry DelaySeconds = 0 MaxDelaySeconds = 60 RetryOnStatusCode = 429 ShouldRetry = $isThrottled OnRetry = $onRetry } try { Invoke-WithRetry @retryParams } catch { if ($null -ne $state.Throttled) { return $state.Throttled } throw } } function Get-ConfluenceRetryDelay { <# .SYNOPSIS Returns the number of seconds to wait before retrying a 429 response. .DESCRIPTION Uses the Retry-After value (a number of seconds or an HTTP date) when there is one, otherwise 2 to the power of the attempt number (2, 4, 8 ... seconds). The result is between 0 and 60. #> [CmdletBinding()] [OutputType([double])] param ( [string]$RetryAfter, [Parameter(Mandatory)] [int]$Attempt ) $delaySeconds = [math]::Pow(2, $Attempt) if ($RetryAfter) { $seconds = 0 $date = [datetime]::MinValue if ([int]::TryParse($RetryAfter, [ref]$seconds)) { $delaySeconds = $seconds } elseif ([datetime]::TryParse($RetryAfter, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal, [ref]$date)) { $delaySeconds = [math]::Ceiling(($date - [datetime]::UtcNow).TotalSeconds) } } return [math]::Min([math]::Max($delaySeconds, 0), 60) } function Invoke-ConfluenceHttpRequestOnce { <# .SYNOPSIS Sends a single HTTP request without retrying. Used by Invoke-ConfluenceHttpRequest. #> [CmdletBinding()] [OutputType([pscustomobject])] param ( [Parameter(Mandatory)] [string]$Uri, [Parameter(Mandatory)] [string]$Method, [string]$Body, [byte[]]$BodyBytes, [string]$ContentType, [hashtable]$AdditionalHeaders ) if ($null -eq $script:ConfluenceContext -or $null -eq $script:ConfluenceCredential) { throw 'No Confluence context is set. Run Set-ConfluenceContext first.' } # Built per request and never stored, so the token is not kept in plain text in session state $headers = New-BasicAuthHeader -Credential $script:ConfluenceCredential $headers.Accept = 'application/json' if ($AdditionalHeaders) { foreach ($key in @($AdditionalHeaders.get_Keys())) { $headers[$key] = $AdditionalHeaders[$key] } } $requestParams = @{ Uri = $Uri Method = $Method Headers = $headers ContentType = $ContentType UseBasicParsing = $true ErrorAction = 'Stop' } if ($null -ne $BodyBytes -and $BodyBytes.Length -gt 0) { $requestParams.Body = $BodyBytes } elseif (-not [string]::IsNullOrEmpty($Body)) { # Send UTF-8 bytes so non-ASCII page content survives on Windows PowerShell 5.1 $requestParams.Body = [System.Text.Encoding]::UTF8.GetBytes($Body) } if ($PSVersionTable.PSVersion.Major -ge 7) { $requestParams.SkipHttpErrorCheck = $true } try { $response = Invoke-WebRequest @requestParams return [pscustomobject]@{ StatusCode = [int]$response.StatusCode StatusDescription = [string]$response.StatusDescription Content = [string]$response.Content RetryAfter = (Get-ConfluenceRetryAfterValue -Headers $response.Headers) } } catch { # Windows PowerShell 5.1 throws on HTTP errors; recover the status and body from the error. # An error without an HTTP response is a transport failure and is rethrown. $detail = Get-HttpErrorDetail -ErrorRecord $_ if ($null -eq $detail) { throw } $responseHeaders = $null if ($null -ne $detail.Response -and $detail.Response.PSObject.Properties['Headers']) { $responseHeaders = $detail.Response.Headers } return [pscustomobject]@{ StatusCode = [int]$detail.StatusCode StatusDescription = [string]$detail.StatusDescription Content = [string]$detail.Body RetryAfter = (Get-ConfluenceRetryAfterValue -Headers $responseHeaders) } } } function Get-ConfluenceRetryAfterValue { <# .SYNOPSIS Returns the Retry-After header value from a response header collection, or nothing. #> [CmdletBinding()] [OutputType([string])] param ( $Headers ) if ($null -eq $Headers) { return } # Dictionaries (PowerShell 7, and Windows PowerShell for successful responses) throw for a # missing key; WebHeaderCollection returns $null if ($Headers -is [System.Collections.IDictionary] -and -not ([System.Collections.IDictionary]$Headers).Contains('Retry-After')) { return } $value = $null try { $value = $Headers['Retry-After'] } catch { Write-Verbose 'The response headers could not be read.' } if ($null -eq $value) { return } # PowerShell 7 returns a string[] per header; Windows PowerShell returns a string return [string](@($value)[0]) } |