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 {
        $batchUri    = 'https://graph.microsoft.com/v1.0/$batch'
        $allResponses = [System.Collections.Generic.List[object]]::new()
        
        $useMgGraph = $false
        if ([string]::IsNullOrWhiteSpace($AccessToken)) {
            $useMgGraph = $true
        } else {
            $headers = @{
                Authorization  = "Bearer $AccessToken"
                'Content-Type' = 'application/json'
            }
        }
    }

    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
            $currentRequests = [System.Collections.Generic.List[hashtable]]::new()
            foreach ($req in $chunk) {
                # Use caller-supplied id (for correlation) or fall back to sequential
                $reqId = if ($req.ContainsKey('id')) { $req['id'] } else { "$idx" }
                $currentRequests.Add(@{ id = $reqId; method = $req.method; url = $req.url })
                $idx++
            }

            # Inner sub-request retry loop (up to 5 attempts per batch chunk)
            $subAttempt = 0
            $maxAttempts = 5

            while ($currentRequests.Count -gt 0 -and $subAttempt -lt $maxAttempts) {
                $body = @{ requests = @($currentRequests) } | ConvertTo-Json -Depth 5

                # Envelope HTTP retry loop (for outer 429 envelope throttling)
                $envelopeAttempt = 0
                $result = $null

                do {
                    try {
                        if ($useMgGraph) {
                            $result = Invoke-MgGraphRequest -Method POST -Uri $batchUri -Body $body -ContentType "application/json" -ErrorAction Stop
                        } else {
                            $result = Invoke-RestMethod -Uri $batchUri -Method Post -Headers $headers -Body $body -ErrorAction Stop
                        }
                        break # Envelope request succeeded
                    } catch {
                        $statusCode = 0
                        if ($useMgGraph -and $_.Exception.ResponseStatusCode) {
                            $statusCode = [int]$_.Exception.ResponseStatusCode
                        } elseif (-not $useMgGraph -and $_.Exception.Response) {
                            $statusCode = [int]$_.Exception.Response.StatusCode
                        }

                        if ($statusCode -eq 429) {
                            $envelopeAttempt++
                            if ($envelopeAttempt -ge $maxAttempts) {
                                throw "ERR-429: Graph API envelope batch request throttled after $maxAttempts retry attempts: $($_.Exception.Message)"
                            }

                            $retryAfterSec = 0
                            if ($_.Exception.Response -and $_.Exception.Response.Headers) {
                                try {
                                    $retryHeader = $_.Exception.Response.Headers['Retry-After']
                                    if ($retryHeader) { $retryAfterSec = [int]$retryHeader }
                                } catch {}
                            }

                            $wait = if ($retryAfterSec -gt 0) { $retryAfterSec * 1000 } else { [Math]::Min([Math]::Pow(2, $envelopeAttempt) * 1000, 60000) }
                            Write-Verbose "Graph $batchUri envelope throttled (429) — waiting ${wait}ms (attempt $envelopeAttempt)"
                            Start-Sleep -Milliseconds $wait
                        } else {
                            throw
                        }
                    }
                } while ($envelopeAttempt -lt $maxAttempts)

                # Process sub-request responses
                $nextRequests = [System.Collections.Generic.List[hashtable]]::new()
                $maxSubRetryAfterMs = 0

                if ($result -and $result.responses) {
                    foreach ($r in $result.responses) {
                        # Handle inner sub-request 429 status codes
                        $rStatus = if ($r.status) { [int]$r.status } else { 0 }
                        if ($rStatus -eq 429) {
                            # Extract Retry-After header if present in sub-response headers
                            $retryAfterSec = 0
                            if ($r.headers) {
                                if ($r.headers -is [hashtable] -or $r.headers -is [System.Collections.IDictionary]) {
                                    if ($r.headers.ContainsKey('Retry-After')) { [int]::TryParse([string]$r.headers['Retry-After'], [ref]$retryAfterSec) | Out-Null }
                                    elseif ($r.headers.ContainsKey('retry-after')) { [int]::TryParse([string]$r.headers['retry-after'], [ref]$retryAfterSec) | Out-Null }
                                } else {
                                    $p = $r.headers.PSObject.Properties['Retry-After']
                                    if (-not $p) { $p = $r.headers.PSObject.Properties['retry-after'] }
                                    if ($p) { [int]::TryParse([string]$p.Value, [ref]$retryAfterSec) | Out-Null }
                                }
                            }

                            $subWaitMs = if ($retryAfterSec -gt 0) { $retryAfterSec * 1000 } else { [Math]::Min([Math]::Pow(2, $subAttempt + 1) * 1000, 60000) }
                            if ($subWaitMs -gt $maxSubRetryAfterMs) { $maxSubRetryAfterMs = $subWaitMs }

                            # Find matching original request object to retry
                            $matchingReq = $currentRequests | Where-Object { $_.id -eq [string]$r.id } | Select-Object -First 1
                            if ($matchingReq) {
                                $nextRequests.Add($matchingReq)
                            }
                        } else {
                            # Non-429 response — store in final responses
                            $allResponses.Add($r)
                        }
                    }
                }

                if ($nextRequests.Count -eq 0) {
                    # All sub-requests completed
                    break
                }

                $subAttempt++
                if ($subAttempt -ge $maxAttempts) {
                    Write-Verbose "Graph batch sub-requests throttled — max attempts ($maxAttempts) reached for $($nextRequests.Count) requests."
                    # Append remaining 429 responses to output so downstream can process/log status
                    if ($result -and $result.responses) {
                        foreach ($r in $result.responses) {
                            if ([int]$r.status -eq 429) {
                                $allResponses.Add($r)
                            }
                        }
                    }
                    break
                }

                $currentRequests = $nextRequests
                $waitMs = if ($maxSubRetryAfterMs -gt 0) { $maxSubRetryAfterMs } else { [Math]::Min([Math]::Pow(2, $subAttempt) * 1000, 60000) }
                Write-Verbose "Graph batch sub-requests throttled (429) — retrying $($currentRequests.Count) sub-requests in ${waitMs}ms (attempt $subAttempt of $maxAttempts)"
                Start-Sleep -Milliseconds $waitMs
            }
        }
    }

    end {
        $allResponses
    }
}