Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Helper/Invoke-AzDevOpsApiRestMethod.ps1
|
<#
.SYNOPSIS This is a light, generic, wrapper around 'Invoke-RestMethod' to handle multiple retries and error/exception handling. This function makes no assumptions around the versions of the API used, the resource being operated/actioned upon, the operation/method being performed, nor the content of the HTTP headers and body. .PARAMETER ApiUri The URI of the Azure DevOps API to be connected to. For example: https://dev.azure.com/someOrganizationName/_apis/ .PARAMETER HttpMethod The HTTP method being used in the HTTP/REST request sent to the Azure DevOps API. .PARAMETER HttpHeaders The headers for the HTTP/REST request sent to the Azure DevOps API. .PARAMETER HttpBody The body for the HTTP/REST request sent to the Azure DevOps API. If performing a 'Post', 'Put' or 'Patch' method/request, this will typically contain the JSON document of the resource. .PARAMETER RetryAttempts The number of times the method/request will attempt to be resent/retried if unsuccessful on the initial attempt. If any attempt is successful, the remaining attempts are ignored. .PARAMETER RetryIntervalMs The interval (in Milliseconds) between retry attempts. .EXAMPLE Invoke-AzDevOpsApiRestMethod -ApiUri 'YourApiUriHere' -HttpMethod 'Get' -HttpHeaders $YouHttpHeadersHashtableHere Submits a 'Get' request to the Azure DevOps API (relying on the 'ApiUri' value to determine what is being retrieved). .EXAMPLE Invoke-AzDevOpsApiRestMethod -ApiUri 'YourApiUriHere' -HttpMethod 'Patch' -HttpHeaders $YourHttpHeadersHashtableHere ` -HttpBody $YourHttpBodyHere -RetryAttempts 3 Submits a 'Patch' request to the Azure DevOps API with the supplied 'HttpBody' and will attempt to retry 3 times (4 in total, including the intitial attempt) if unsuccessful. #> function Invoke-AzDevOpsApiRestMethod { [CmdletBinding()] [OutputType([System.Management.Automation.PSObject])] param ( [Parameter(Mandatory=$true)] [Alias('Uri')] [System.String] $ApiUri, [Parameter(Mandatory=$true)] [ValidateSet('Get','Post','Patch','Put','Delete')] [System.String] [Alias('Method')] $HttpMethod, [Parameter()] [ValidateScript( { Test-AzDevOpsApiHttpRequestHeader -HttpRequestHeader $_ -IsValid })] [Hashtable] [Alias('Headers','HttpRequestHeader')] $HttpHeaders=@{}, [Parameter()] [System.String] [Alias('Body')] $HttpBody, [Parameter()] [System.String] [Alias('ContentType')] [ValidateSet('application/json','application/json-patch+json')] $HttpContentType = 'application/json', [Parameter()] [ValidateRange(0,5)] [Int32] $RetryAttempts = 5, [Parameter()] [ValidateRange(250,10000)] [Int32] $RetryIntervalMs = 250, [Parameter()] [String] $ApiVersion = $(Get-AzDevOpsApiVersion -Default), [Parameter()] [Switch] $NoAuthentication, [Parameter()] [Switch] $AzureArcAuthentication ) $invokeRestMethodParameters = @{ Uri = $ApiUri Method = $HttpMethod Headers = $HttpHeaders Body = $HttpBody ContentType = $HttpContentType ResponseHeadersVariable = 'responseHeaders' } Write-Verbose -Message ('[Invoke-AzDevOpsApiRestMethod] Invoking the Azure DevOps API REST method {0}' -f $HttpMethod) Write-Verbose -Message ('[Invoke-AzDevOpsApiRestMethod] API URI: {0}' -f $ApiUri) # Remove the 'Body' and 'ContentType' if not relevant to request if ($HttpMethod -in $('Get','Delete')) { $invokeRestMethodParameters.Remove('Body') $invokeRestMethodParameters.Remove('ContentType') } # Intially set this value to -1, as the first attempt does not want to be classed as a "RetryAttempt" $CurrentNoOfRetryAttempts = -1 # Set the Continuation Token to be False $isContinuationToken = $false $results = [System.Collections.ArrayList]::new() while ($CurrentNoOfRetryAttempts -lt $RetryAttempts) { <# Slow down the retry attempts if the API resource is close to being overwelmed If there are any retry attempts, wait for the specified number of seconds before retrying #> if (($null -ne $Global:DSCAZDO_APIRateLimit.xRateLimitRemaining) -and ($Global:DSCAZDO_APIRateLimit.retryAfter -ge 0)) { Write-Verbose -Message ('[Invoke-AzDevOpsApiRestMethod] Waiting for {0} seconds before retrying.' -f $Global:DSCAZDO_APIRateLimit.retryAfter) Start-Sleep -Seconds $Global:DSCAZDO_APIRateLimit.retryAfter } # If the API resouce is close to beig overwelmed, wait for the specified number of seconds before sending the request if (($null -ne $Global:DSCAZDO_APIRateLimit.xRateLimitRemaining) -and ($Global:DSCAZDO_APIRateLimit.xRateLimitRemaining -le 50) -and ($Global:DSCAZDO_APIRateLimit.xRateLimitRemaining -ge 5)) { Write-Verbose -Message "[Invoke-AzDevOpsApiRestMethod] Resource is close to being overwelmed. Waiting for $RetryIntervalMs seconds before sending the request." Start-Sleep -Milliseconds $RetryIntervalMs } # If the API resouce is overwelmed, wait for the specified number of seconds before sending the request elseif (($null -ne $Global:DSCAZDO_APIRateLimit.xRateLimitRemaining) -and ($Global:DSCAZDO_APIRateLimit.xRateLimitRemaining -lt 5)) { Write-Verbose -Message ('[Invoke-AzDevOpsApiRestMethod] Resource is overwhelmed. Waiting for {0} seconds to reset the TSTUs.' -f $Global:DSCAZDO_APIRateLimit.xRateLimitReset) Start-Sleep -Milliseconds $RetryIntervalMs } # # Invoke the REST method. Loop until the Continuation Token is False. Do { # # Add the Authentication Header # If the 'NoAuthentication' switch is NOT PRESENT and the 'Authentication' header is empty, add the authentication header if (([String]::IsNullOrEmpty($invokeRestMethodParameters.Headers.Authentication)) -and (-not $NoAuthentication.IsPresent)) { $invokeRestMethodParameters.Headers.Authorization = Add-AuthenticationHTTPHeader } # # Invoke the REST method try { # Invoke the REST method. If the 'Verbose' switch is present, set it to $false. # This is to prevent the output from being displayed in the console. $response = Invoke-RestMethod @invokeRestMethodParameters -Verbose:$false # Zero out the 'Authorization' header $invokeRestMethodParameters.Headers.Authorization = $null # Add the response to the results array $null = $results.Add($response) # # Test to see if there is no continuation token if ([String]::IsNullOrEmpty($responseHeaders.'x-ms-continuationtoken')) { # If not, set the continuation token to False $isContinuationToken = $false # Update the Rate Limit information $Global:DSCAZDO_APIRateLimit = $null Write-Verbose "[Invoke-AzDevOpsApiRestMethod] No continuation token found. Breaking loop." return $results } # # A continuation token is present. # If so, set the continuation token to True $isContinuationToken = $true # Response headers are returned as string arrays; take the first value so the token # is not stringified as 'System.String[]'. $continuationToken = @($responseHeaders.'x-ms-continuationtoken')[0] # Update the URI to include the continuation token. $ApiUri already carries the # correct 'api-version' query parameter, so only the continuation token is appended. # (Previously a bare '&7.1' was appended, producing a malformed query and, for the # preview-only graph endpoints, a 'version 7.1 is under preview' 400 error.) $invokeRestMethodParameters.Uri = '{0}&continuationToken={1}' -f $ApiUri, $continuationToken # Reset the RetryAttempts counter $CurrentNoOfRetryAttempts = -1 } catch { # If AzureArcAuthentication is present, then we need to handle the error differently. # Stop and Pass the error back to the caller. The caller will handle the error. if ($AzureArcAuthentication.IsPresent) { throw $_ } # Zero out the 'Authorization' header $invokeRestMethodParameters.Headers.Authorization = $null # Check to see if it is an HTTP 429 (Too Many Requests) error if ($_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::TooManyRequests) { # If so, wait for the specified number of seconds before retrying $retryAfter = $_.Exception.Response.Headers['Retry-After'] if ($retryAfter) { $retryAfter = [int]$retryAfter Write-Verbose -Message "Received a 'Too Many Requests' response from the Azure DevOps API. Waiting for $retryAfter seconds before retrying." $Global:DSCAZDO_APIRateLimit = [APIRateLimit]::New($retryAfter) } else { # If the Retry-After header is not present, wait for the specified number of milliseconds before retrying Write-Verbose -Message "Received a 'Too Many Requests' response from the Azure DevOps API. Waiting for $RetryIntervalMs milliseconds before retrying." $Global:DSCAZDO_APIRateLimit = [APIRateLimit]::New($RetryIntervalMs) } } # Increment the number of retries attempted and obtain any exception message $CurrentNoOfRetryAttempts++ $responseBody = $null try { $responseBody = $_.ErrorDetails.Message } catch {} if (-not $responseBody) { try { $responseBody = $_.Exception.Response.Content.ReadAsStringAsync().Result } catch {} } if (-not $responseBody) { try { $stream = $_.Exception.Response.GetResponseStream() $responseBody = [System.IO.StreamReader]::new($stream).ReadToEnd() } catch {} } $restMethodExceptionMessage = if ($responseBody) { "$($_.Exception.Message) | ResponseBody: $responseBody" } else { $_.Exception.Message } # Wait before the next attempt/retry Start-Sleep -Milliseconds $RetryIntervalMs # Break the continuation token loop so that the next attempt can be made break; } } Until (-not $isContinuationToken) } # If all retry attempts have failed, throw an exception $localizedMsg = $script:localizedData.AzDevOpsApiRestMethodException if ([String]::IsNullOrEmpty($localizedMsg)) { $localizedMsg = "The '{0}' function returned an error after {1} retry attempts: ""{2}""" } $errorMessage = $localizedMsg -f $MyInvocation.MyCommand, $RetryAttempts, $restMethodExceptionMessage throw "[Invoke-AzDevOpsApiRestMethod] $errorMessage" } |