internal/Invoke-AzureBatchRequest.ps1

function Invoke-AzureBatchRequest {
    <#
    .SYNOPSIS
    Function to invoke Azure Resource Manager Api batch request(s).
 
    .DESCRIPTION
    Function to invoke Azure Resource Manager Api batch request(s).
 
    Handles throttling and server-side errors.
 
    .PARAMETER batchRequest
    PSobject(s) representing the requests to be run in a batch.
 
    Can be created manually or via New-AzureBatchRequest.
 
    https://github.com/Azure/azure-sdk-for-python/issues/9271
 
    .PARAMETER dontBeautifyResult
    Switch for returning original/non-modified batch request(s) results.
 
    By default batch-request-related properties like batch status, headers, nextlink, etc are stripped.
 
    To be able to filter returned objects by their originated request, new property 'RequestName' is added.
 
    .PARAMETER dontAddRequestName
    Switch to avoid adding extra 'RequestName' property to the "beautified" results.
 
    .PARAMETER separateErrors
    Switch to return batch request errors one by one instead of all at once.
    Moreover returned errors will contain 'TargetObject' property with original request and response objects for easier troubleshooting.
 
    .EXAMPLE
    $batch = (
        @{
            Name = "group"
            HttpMethod = "GET"
            URL = "https://management.azure.com/providers/Microsoft.Management/managementGroups/SOMEMGGROUP/providers/microsoft.authorization/permissions?api-version=2018-01-01-preview"
        },
 
        @{
            Name = "subPim"
            HttpMethod = "GET"
            URL = "/subscriptions/f3b08c7f-99a9-4a70-ba56-1e877abb77f7/providers/Microsoft.Authorization/roleEligibilitySchedules?api-version=2020-10-01"
        }
    )
 
    Invoke-AzureBatchRequest -batchRequest $batch
 
    Invokes both requests in one batch.
 
    .EXAMPLE
    $batchRequest = New-AzureBatchRequest -url "/providers/Microsoft.Authorization/roleDefinitions?%24filter=type%20eq%20%27BuiltInRole%27&api-version=2022-05-01-preview", "/subscriptions/f3b08c7f-99a9-4a70-ba56-1e877abb77f7/providers/Microsoft.Authorization/roleEligibilitySchedules?api-version=2020-10-01"
 
    Invoke-AzureBatchRequest -batchRequest $batchRequest
 
    Creates batch request object containing both urls & run it.
 
    .EXAMPLE
    $subscriptionId = (Get-AzSubscription | ? State -EQ 'Enabled').Id
 
    New-AzureBatchRequest -url "https://management.azure.com/subscriptions/<placeholder>/providers/Microsoft.Authorization/roleEligibilitySchedules?api-version=2020-10-01" -placeholder $subscriptionId | Invoke-AzureBatchRequest
 
    Creates batch request object containing dynamically generated urls for every id in the $subscriptionId array & run it.
 
    .NOTES
    Uses undocumented API https://github.com/Azure/azure-sdk-for-python/issues/9271 :).
    #>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [PSObject[]]$batchRequest,

        [switch] $dontBeautifyResult,

        [Alias("dontAddRequestId")]
        [switch] $dontAddRequestName,

        [switch] $separateErrors
    )

    begin {
        if ($PSCmdlet.MyInvocation.PipelineLength -eq 1) {
            Write-Verbose "Total number of requests to process is $($batchRequest.count)"
        }

        if ($dontBeautifyResult -and $dontAddRequestName) {
            Write-Verbose "'dontAddRequestName' parameter will be ignored, 'RequestName' property is not being added when 'dontBeautifyResult' parameter is used"
        }

        # api batch requests are limited to 20 requests
        $chunkSize = 20
        # buffer to hold chunks of requests
        $requestChunk = [System.Collections.ArrayList]::new()
        # paginated or remotely failed requests that should be processed too, to get all the results
        $extraRequestChunk = [System.Collections.ArrayList]::new()
        # throttled requests that have to be repeated after given time
        $throttledRequestChunk = [System.Collections.ArrayList]::new()

        function _processChunk {
            <#
                .SYNOPSIS
                Helper function with the main chunk-processing logic that invokes batch request.
 
                Based on request return code and availability of nextlink url it:
                 - creates another request to get missing data
                 - retry the request (with wait time in case of throttled request)
            #>


            [CmdletBinding()]
            param (
                [Parameter(Mandatory = $true)]
                [System.Collections.ArrayList] $requestChunk
            )

            $duplicityId = $requestChunk | Select-Object -ExpandProperty Name | Group-Object | ? { $_.Count -gt 1 }
            if ($duplicityId) {
                throw "Batch requests must have unique names. Name $(($duplicityId | select -Unique) -join ', ') is there more than once"
            }

            Write-Debug ($requestChunk | ConvertTo-Json)

            Write-Verbose "Processing batch of $($requestChunk.count) request(s):`n$(($requestChunk | sort Url | % {" - $($_.Name) - $($_.Url)"} ) -join "`n")"

            #region process given chunk of batch requests
            $start = Get-Date

            $payload = @{
                requests = [array]$requestChunk
            }

            # invoke the batch
            $result = Invoke-AzRestMethod -Uri "https://management.azure.com/batch?api-version=2020-06-01" -Method POST -Payload ($payload | ConvertTo-Json -Depth 20) -ErrorAction Stop

            $responses = ($result.content | ConvertFrom-Json).responses

            #region return the output
            if ($dontBeautifyResult) {
                # return original response

                $responses
            } else {
                # return just actually requested data without batch-related properties and enhance the returned object with 'RequestName' property for easier filtering

                foreach ($response in $responses) {
                    $noteProperty = $null
                    if ($response.content) { $noteProperty = $response.content | Get-Member -MemberType NoteProperty }

                    # there was some error, no real values were returned, skipping
                    if ($response.httpStatusCode -in (400..509)) {
                        continue
                    }

                    # properties to return
                    $property = @("*")
                    if (!$dontAddRequestName) {
                        $property += @{n = 'RequestName'; e = { $response.Name } }
                    }

                    if ($response.content.value) {
                        # the result is in the 'value' property
                        $response.content.value | select -Property $property
                    } elseif ($response.content -and $noteProperty.Name -contains 'value') {
                        # the result is stored in 'value' property, but no results were returned, skipping
                    } elseif ($response.content -and $response.contentLength) {
                        # the result is in the 'content' property itself
                        if ($response.content.data -and $response.content.totalRecords -and $response.content.resultTruncated) {
                            # the result is in the 'data' property (Resource Graph KQL response)
                            $response.content.data | select -Property $property
                        } else {
                            $response.content | select -Property $property
                        }
                    } else {
                        # no results were returned, skipping
                    }
                }
            }
            #endregion return the output

            #region handle the responses based on their status code
            # load the next pages, retry throttled requests, repeat failed requests, ...

            $failedBatchJob = [System.Collections.Generic.List[Object]]::new()

            foreach ($response in $responses) {
                if ($response.httpStatusCode -in 200, 201, 204) {
                    # success

                    # not sure where the nextLink might be stored, so checking both 'body' and 'content'
                    $nextLink = $null
                    if ($response.body.nextLink) {
                        $nextLink = $response.body.nextLink
                    } elseif ($response.content.nextLink) {
                        $nextLink = $response.content.nextLink
                    }

                    if ($nextLink) {
                        # paginated (get remaining results by query returned NextLink URL)

                        Write-Verbose "Batch result for request '$($response.Name)' is paginated. Nextlink will be processed in the next batch"

                        # make a request object copy, so I can modify it without interfering with the original object
                        $nextLinkRequest = $requestChunk | ? Name -EQ $response.Name | ConvertTo-Json -Depth 10 | ConvertFrom-Json
                        # replace original URL with the nextLink
                        $nextLinkRequest.Url = $nextLink
                        # add the request for later processing
                        $null = $extraRequestChunk.Add($nextLinkRequest)
                    }

                    $skipToken = $null
                    if ($skipToken = $response.content.'$skipToken') {
                        # paginated (get remaining results by using '$skipToken')

                        Write-Verbose "Batch result for request '$($response.Name)' is paginated (total records: $($response.content.totalRecords)). Request will be repeated with the returned `$skipToken"

                        # make a request object copy, so I can modify it without interfering with the original object
                        $nextPageRequest = $requestChunk | ? Name -EQ $response.Name | ConvertTo-Json -Depth 10 | ConvertFrom-Json
                        # set '$skipToken' option
                        if ($nextPageRequest.content.Options) {
                            if ($nextPageRequest.content.Options.'$skipToken') {
                                $nextPageRequest.content.Options.'$skipToken' = $skipToken
                            } else {
                                $nextPageRequest.content.Options | Add-Member -MemberType NoteProperty -Name '$skipToken' -Value $skipToken
                            }
                        } else {
                            $nextPageRequest.content | Add-Member -MemberType NoteProperty -Name Options -Value @{'$skipToken' = $skipToken }
                        }
                        # add the request for later processing
                        $null = $extraRequestChunk.Add($nextPageRequest)
                    }
                } elseif ($response.httpStatusCode -eq 429) {
                    # throttled (will be repeated after given time)

                    $jobRetryAfter = $response.Headers.'Retry-After'
                    $throttledBatchRequest = $requestChunk | ? Name -EQ $response.Name

                    Write-Verbose "Batch request with Id: '$($throttledBatchRequest.Name)', Url:'$($throttledBatchRequest.Url)' was throttled, hence will be repeated after $jobRetryAfter seconds"

                    if ($jobRetryAfter -eq 0) {
                        # request can be repeated without any delay
                        #TIP for performance reasons adding to $extraRequestChunk batch (to avoid invocation of unnecessary batch job)
                        $null = $extraRequestChunk.Add($throttledBatchRequest)
                    } else {
                        # request can be repeated after delay
                        # add the request for later processing
                        $null = $throttledRequestChunk.Add($throttledBatchRequest)
                    }

                    # get highest retry-after wait time
                    if ($jobRetryAfter -gt $script:retryAfter) {
                        Write-Verbose "Setting $jobRetryAfter retry-after time"
                        $script:retryAfter = $jobRetryAfter
                    }
                } elseif ($response.httpStatusCode -in 500, 502, 503, 504) {
                    # some internal error on remote side (will be repeated)

                    $problematicBatchRequest = $requestChunk | ? Name -EQ $response.Name

                    Write-Verbose "Batch request with Id: '$($problematicBatchRequest.Name)', Url:'$($problematicBatchRequest.Url)' had internal error '$($response.httpStatusCode)', hence will be repeated"

                    $extraRequestChunk.Add($problematicBatchRequest)
                } else {
                    # failed

                    $failedBatchRequest = $requestChunk | ? Name -EQ $response.Name

                    $failedBatchJob.Add(
                        @{
                            Name       = $response.Name
                            Url        = $failedBatchRequest.Url
                            StatusCode = $response.httpStatusCode
                            Error      = $response.content.error.message
                            Object     = [ordered]@{
                                request  = $failedBatchRequest
                                response = $response
                            }
                        }
                    )
                }
            }

            # exit if critical failure occurred
            if ($failedBatchJob) {
                if ($separateErrors) {
                    # output errors one by one, so you can handle them separately if needed
                    $failedBatchJob | % {
                        #TIP only the first one will be returned if $ErrorActionPreference is set to stop!
                        Write-Error -Message "`nFailed batch request:`n$(" - Name: '$($_.Name)'", " - Url: '$($_.Url)'", " - StatusCode: '$($_.StatusCode)'", " - Error: '$($_.Error)'`n`n" -join "`n")" -ErrorId $_.StatusCode -Category "InvalidOperation" -TargetObject $_.Object
                    }
                } else {
                    #TIP all errors at once, because batch can contain non-related requests and if errorAction is set to stop, only the first error would be returned, which can be confusing
                    Write-Error "`nFollowing batch request(s) failed:`n`n$(($failedBatchJob | % {
                        " - Name: '$($_.Name)'", " - Url: '$($_.Url)'", " - StatusCode: '$($_.StatusCode)'", " - Error: '$($_.Error)'" -join "`n"
                    }) -join "`n`n")"
 -Category "InvalidOperation" -TargetObject $failedBatchJob.Object
                }
            }
            #endregion handle the responses based on their status code

            $end = Get-Date

            Write-Verbose "It took $((New-TimeSpan -Start $start -End $end).TotalSeconds) seconds to process the batch"
            #endregion process given chunk of batch requests
        }
    }

    process {
        # check url validity
        $batchRequest.URL | % {
            if ($_ -notlike "https://management.azure.com/*" -and $_ -notlike "/*") {
                throw "url '$_' has to be relative (without the whole 'https://management.azure.com' part) or absolute!"
            }

            if ($_ -notmatch "/subscriptions/|\?" -and $_ -notmatch "/providers/|\?" -and $_ -notmatch "/resources/|\?" -and $_ -notmatch "/locations/|\?" -and $_ -notmatch "/tenants/|\?" -and $_ -notmatch "/bulkdelete/|\?") {
                throw "url '$_' is not valid. Is should starts with:`n/subscriptions, /providers, /resources, /locations, /tenants or /bulkdelete!"
            }
        }

        foreach ($request in $batchRequest) {
            $null = $requestChunk.Add($request)

            # check if the buffer has reached the required chunk size
            if ($requestChunk.count -eq $chunkSize) {
                [int] $script:retryAfter = 0
                _processChunk $requestChunk

                # clear the buffer
                $requestChunk.Clear()

                # process requests that need to be repeated (paginated, failed on remote server,...)
                if ($extraRequestChunk) {
                    Write-Warning "Processing $($extraRequestChunk.count) paginated or server-side-failed request(s)"
                    Invoke-AzureBatchRequest -batchRequest $extraRequestChunk -dontBeautifyResult:$dontBeautifyResult

                    $extraRequestChunk.Clear()
                }

                # process throttled requests
                if ($throttledRequestChunk) {
                    Write-Warning "Processing $($throttledRequestChunk.count) throttled request(s) with $script:retryAfter seconds wait time"
                    Start-Sleep -Seconds $script:retryAfter
                    Invoke-AzureBatchRequest -batchRequest $throttledRequestChunk -dontBeautifyResult:$dontBeautifyResult

                    $throttledRequestChunk.Clear()
                }
            }
        }
    }

    end {
        # process any remaining requests in the buffer

        if ($requestChunk.Count -gt 0) {
            [int] $script:retryAfter = 0
            _processChunk $requestChunk

            # process requests that need to be repeated (paginated, failed on remote server,...)
            if ($extraRequestChunk) {
                Write-Warning "Processing $($extraRequestChunk.count) paginated or server-side-failed request(s)"
                Invoke-AzureBatchRequest -batchRequest $extraRequestChunk -dontBeautifyResult:$dontBeautifyResult
            }

            # process throttled requests
            if ($throttledRequestChunk) {
                Write-Warning "Processing $($throttledRequestChunk.count) throttled request(s) with $script:retryAfter seconds wait time"
                Start-Sleep -Seconds $script:retryAfter
                Invoke-AzureBatchRequest -batchRequest $throttledRequestChunk -dontBeautifyResult:$dontBeautifyResult
            }
        }
    }
}