Private/Invoke-SPCGraphBatch.ps1

function Invoke-SPCGraphBatch {
    <#
    .SYNOPSIS
        Sends Microsoft Graph JSON batch requests per SRS 5.1 (max 20/batch, 429 retry).
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Generic.List[object]])]
    param(
        [Parameter(Mandatory)]
        [System.Collections.Generic.List[hashtable]] $Requests,

        # If omitted, retrieved from the active PnP connection via Get-PnPGraphAccessToken.
        [Parameter()]
        [string] $AccessToken
    )

    begin {
        if ([string]::IsNullOrWhiteSpace($AccessToken)) {
            $AccessToken = Get-PnPGraphAccessToken
        }
        $batchUri    = 'https://graph.microsoft.com/v1.0/$batch'
        $headers     = @{
            Authorization  = "Bearer $AccessToken"
            'Content-Type' = 'application/json'
        }
        $allResponses = [System.Collections.Generic.List[object]]::new()
    }

    process {
        if ($Requests.Count -eq 0) { return }

        # SRS 5.1: max 20 requests per batch call
        $batchSize = 20
        for ($offset = 0; $offset -lt $Requests.Count; $offset += $batchSize) {
            $chunk = [System.Linq.Enumerable]::Skip($Requests, $offset) |
                     Select-Object -First $batchSize

            $idx = 1
            $batchRequests = foreach ($req in $chunk) {
                # Use caller-supplied id (for correlation) or fall back to sequential
                $reqId = if ($req.ContainsKey('id')) { $req['id'] } else { "$idx" }
                @{ id = $reqId; method = $req.method; url = $req.url }
                $idx++
            }

            $body = @{ requests = @($batchRequests) } | ConvertTo-Json -Depth 5

            # Throttling pattern — SRS 5.1 / powershell-standards.md
            $attempt = 0
            do {
                try {
                    $result = Invoke-RestMethod -Uri $batchUri -Method Post `
                        -Headers $headers -Body $body -ErrorAction Stop
                    foreach ($r in $result.responses) { $allResponses.Add($r) }
                    break
                } catch {
                    if ($_.Exception.Response.StatusCode -eq 429) {
                        $wait = [Math]::Min([Math]::Pow(2, $attempt) * 1000, 60000)
                        Write-Verbose "Graph $batchUri throttled — waiting ${wait}ms (attempt $($attempt + 1))"
                        Start-Sleep -Milliseconds $wait
                        $attempt++
                    } else { throw }
                }
            } while ($attempt -lt 5)
        }
    }

    end {
        $allResponses
    }
}