Private/Invoke-ArgusGraphBatch.ps1

function Invoke-ArgusGraphBatch {
    <#
    .SYNOPSIS
        Runs many read-only Graph GET requests through JSON batching (20 per batch).

    .DESCRIPTION
        Each request is a hashtable with Id and Url (relative, e.g. '/groups/{id}/owners?$select=id').
        Every sub-request is a GET: any other Method throws before anything is sent.
        The batch envelope itself is a POST to /$batch, the documented transport for JSON batching;
        it carries only the GET sub-requests and never changes tenant data. This is the only POST
        ARGUS issues.

        Sub-requests throttled with 429/503/504 are retried (honouring their Retry-After header) up to
        -MaxRetries times. Returns one object per request, in input order: Id, Status, Body.
        Sub-request paging (@odata.nextLink inside a body) is not followed; callers that need more
        than one page call Invoke-ArgusGraphRequest -All for that item.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]] $Request,

        [Parameter()]
        [switch] $Beta,

        [Parameter()]
        [ValidateRange(0, 10)]
        [int] $MaxRetries = 5
    )

    $items = foreach ($entry in $Request) {
        $method = if ($entry -is [System.Collections.IDictionary] -and $entry.Contains('Method')) { [string]$entry['Method'] } else { 'GET' }
        if ($method -ne 'GET') {
            throw "ARGUS is read-only: batch sub-request method '$method' is not allowed (only GET)."
        }
        $id = [string]$entry['Id']
        $url = [string]$entry['Url']
        if (-not $id -or -not $url) { throw 'Each batch request needs an Id and a Url.' }
        if ($url -match '^https?://') { throw "Batch request '$id' must use a relative URL." }
        [pscustomobject]@{ Id = $id; Url = '/' + $url.TrimStart('/') }
    }
    $items = @($items)
    if ($items.Count -eq 0) { return }
    if (@($items.Id | Sort-Object -Unique).Count -ne $items.Count) { throw 'Batch request Ids must be unique.' }

    $version = if ($Beta) { 'beta' } else { 'v1.0' }
    $batchUri = '{0}/{1}/$batch' -f $script:ArgusGraphBaseUri, $version
    $results = @{}

    for ($offset = 0; $offset -lt $items.Count; $offset += 20) {
        $pending = @($items[$offset..([math]::Min($offset + 19, $items.Count - 1))])
        $attempt = 0
        while ($pending.Count -gt 0) {
            $body = @{ requests = @($pending | ForEach-Object { @{ id = $_.Id; method = 'GET'; url = $_.Url } }) } | ConvertTo-Json -Depth 5 -Compress
            $response = Invoke-ArgusWithRetry -MaxRetries $MaxRetries -Activity 'POST $batch' -ScriptBlock {
                # Read-only: the envelope only transports the GET sub-requests built above.
                Invoke-MgGraphRequest -Method POST -Uri $batchUri -Body $body -ContentType 'application/json' -OutputType PSObject -ErrorAction Stop
            }

            $byId = @{}
            foreach ($entry in $pending) { $byId[$entry.Id] = $entry }
            $retry = [System.Collections.Generic.List[object]]::new()
            $delay = 0
            foreach ($sub in @($response.responses)) {
                $status = [int]$sub.status
                $request = $byId[[string]$sub.id]
                if ($status -in 429, 503, 504 -and $attempt -lt $MaxRetries -and $request) {
                    $retry.Add($request)
                    $headers = $sub.PSObject.Properties['headers']
                    $after = if ($headers -and $headers.Value -and $headers.Value.PSObject.Properties['Retry-After']) { [int]$headers.Value.'Retry-After' } else { 0 }
                    $delay = [math]::Max($delay, $after)
                    continue
                }
                $results[[string]$sub.id] = [pscustomobject]@{
                    Id     = [string]$sub.id
                    Status = $status
                    Body   = if ($sub.PSObject.Properties['body']) { $sub.body } else { $null }
                }
            }

            $pending = @($retry)
            if ($pending.Count -gt 0) {
                $attempt++
                if ($delay -le 0) { $delay = [int][math]::Min(60, [math]::Pow(2, $attempt)) }
                Write-Verbose "Batch: $($pending.Count) sub-request(s) throttled; retry $attempt of $MaxRetries in $delay s."
                Start-Sleep -Seconds $delay
            }
        }
    }

    foreach ($item in $items) {
        if ($results.ContainsKey($item.Id)) { $results[$item.Id] }
        else { [pscustomobject]@{ Id = $item.Id; Status = 0; Body = $null } }
    }
}