Private/Invoke-AACArmRequest.ps1

function Invoke-AACArmRequest {
    <#
    .SYNOPSIS
        Calls Azure Resource Manager with the Connect-AAC sign-in and returns
        the JSON response as hashtables.
    .DESCRIPTION
        Invoke-RestMethod can't be used for resource data: it silently returns
        the raw text when the JSON has keys differing only by case - which
        tags often do ('Owner' and 'owner' on one resource) - while
        ConvertFrom-Json -AsHashtable accepts them.
 
        Retries throttled (429) and transient 5xx responses up to four times,
        honouring Retry-After. Other failures throw, with the HTTP status in
        the exception's Data['StatusCode'] so callers can tell "not found"
        from "forbidden". -Uri may be a full URL or a path starting with '/'
        (https://management.azure.com is added).
 
        -Resource calls another Azure API with the same sign-in: the token
        audience, e.g. https://api.loganalytics.io for a Log Analytics query
        (Get-AACAccessToken -Resource). Its errors have the same shape.
    #>

    [CmdletBinding()]
    param(
        [ValidateSet('Get', 'Post')]
        [string] $Method = 'Get',

        [Parameter(Mandatory)]
        [string] $Uri,

        [string] $Body,

        [string] $Resource = 'https://management.azure.com'
    )

    if ($Uri.StartsWith('/')) {
        $Uri = "https://management.azure.com$Uri"
    }
    # No web-request progress bar or verbose line per call: they would draw
    # over the Spectre progress display.
    $ProgressPreference = 'SilentlyContinue'
    for ($attempt = 1; ; $attempt++) {
        try {
            $request = @{
                Method      = $Method
                Uri         = $Uri
                Headers     = @{ Authorization = "Bearer $(Get-AACAccessToken -Resource $Resource)" }
                ErrorAction = 'Stop'
                Verbose     = $false
            }
            if ($Body) {
                $request.Body = $Body
                $request.ContentType = 'application/json'
            }
            $content = (Invoke-WebRequest @request).Content
            if ($content -is [byte[]]) {
                $content = [System.Text.Encoding]::UTF8.GetString($content)
            }
            if ([string]::IsNullOrWhiteSpace($content)) {
                return $null
            }
            return ConvertFrom-Json -InputObject $content -AsHashtable -Depth 100
        }
        catch {
            # Network failures (DNS, TLS, ...) have no Response at all.
            $response = Get-AACPropertyValue -InputObject $_.Exception -Name 'Response'
            $status = if ($response) { [int](Get-AACPropertyValue -InputObject $response -Name 'StatusCode') } else { 0 }
            if ($attempt -ge 4 -or ($status -ne 429 -and $status -lt 500)) {
                $details = if ($_.ErrorDetails) { $_.ErrorDetails.Message } else { $null }
                $message = if ($details) { $details } else { $_.Exception.Message }
                # A readable reason from ARM's error body when there is one.
                try {
                    $parsed = ConvertFrom-Json -InputObject $details -AsHashtable -ErrorAction Stop
                    if ($parsed -is [System.Collections.IDictionary] -and $parsed['error'] -is [System.Collections.IDictionary] -and $parsed['error']['message']) {
                        # The query APIs nest the useful part ("Failed to
                        # resolve column 'x'") in innererror: add each level.
                        $messages = [System.Collections.Generic.List[string]]::new()
                        for ($level = $parsed['error']; $level -is [System.Collections.IDictionary]; $level = $level['innererror']) {
                            $text = ([string]$level['message']).Trim()
                            if ($text -and -not $messages.Contains($text)) { $messages.Add($text) }
                        }
                        $message = $messages -join ' '
                    }
                }
                catch {
                    Write-Debug "The error body is not ARM JSON: $details"
                }
                $exception = [System.Exception]::new($message)
                $exception.Data['StatusCode'] = $status
                throw $exception
            }
            $retryAfter = $null
            if ($response) {
                $headers = Get-AACPropertyValue -InputObject $response -Name 'Headers'
                $retry = if ($headers) { Get-AACPropertyValue -InputObject $headers -Name 'RetryAfter' } else { $null }
                if ($retry) { $retryAfter = Get-AACPropertyValue -InputObject $retry -Name 'Delta' }
            }
            Start-Sleep -Seconds $(if ($retryAfter) { [math]::Min(60, $retryAfter.TotalSeconds) } else { 5 * $attempt })
        }
    }
}