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"

}