Private/Graph/Invoke-GraphBatchRequest.ps1

# Copyright (c) 2026 Sandy Zeng. All rights reserved.
# Source-available. All rights reserved. See LICENSE file.

<#
    Invoke-GraphBatchRequest.ps1 — Sends batched Microsoft Graph API requests
    (up to 20 per call) via Invoke-RestMethod with 429 + 401 handling.
 
    Author: Sandy Zeng
    Project: IntuneDiff
 
    Version History:
    1.0.0 Initial release.
    2.0.0 Rewritten to use Invoke-RestMethod with a Bearer token — no Microsoft.Graph SDK.
#>


function Invoke-GraphBatchRequest {
    <#
    .SYNOPSIS
        Sends a batch of up to 20 Graph API requests in a single POST /$batch call.
 
    .OUTPUTS
        Hashtable keyed by request id. Each value is the response body or $null on failure.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object[]]$Requests,

        [int]$MaxRetries = 3
    )

    $batchSize = 20
    $results   = @{}

    $chunks = [System.Collections.Generic.List[object[]]]::new()
    for ($i = 0; $i -lt $Requests.Count; $i += $batchSize) {
        $end = [Math]::Min($i + $batchSize, $Requests.Count)
        $chunks.Add($Requests[$i..($end - 1)])
    }

    foreach ($chunk in $chunks) {
        $pendingRequests = @($chunk)
        $retryCount      = 0
        $tokenRefreshed  = $false

        while ($pendingRequests.Count -gt 0 -and $retryCount -le $MaxRetries) {
            $batchBody = @{
                requests = @($pendingRequests | ForEach-Object {
                    @{ id = [string]$_.id; method = $_.method; url = $_.url }
                })
            }

            Write-IDLog "Graph BATCH: $($pendingRequests.Count) requests (attempt $($retryCount + 1))"

            try {
                $jsonBody = $batchBody | ConvertTo-Json -Depth 10 -Compress
                $headers  = @{
                    Authorization = "Bearer $(Get-IDAccessToken)"
                    Accept        = 'application/json'
                }
                $response = Invoke-RestMethod -Method POST `
                    -Uri 'https://graph.microsoft.com/beta/$batch' `
                    -Headers $headers `
                    -Body $jsonBody `
                    -ContentType 'application/json' `
                    -TimeoutSec 60 `
                    -ErrorAction Stop
            } catch {
                $statusCode = $null
                if ($_.Exception.Response) { $statusCode = [int]$_.Exception.Response.StatusCode }
                Write-IDLog "Batch POST failed (status $statusCode): $($_.Exception.Message)"

                if ($statusCode -eq 401 -and -not $tokenRefreshed) {
                    Write-IDLog 'Batch 401 — refreshing token and retrying'
                    try {
                        Get-IDAccessToken -ForceRefresh | Out-Null
                        $tokenRefreshed = $true
                        $retryCount++
                        continue
                    } catch {
                        Write-IDLog "Token refresh failed: $($_.Exception.Message)"
                    }
                }

                foreach ($req in $pendingRequests) { $results[[string]$req.id] = $null }
                break
            }

            $throttled     = [System.Collections.Generic.List[object]]::new()
            $maxRetryAfter = 0

            # Invoke-RestMethod with JSON yields PSCustomObject; old code also accepted IDictionary
            $responses = $null
            if ($response -is [System.Collections.IDictionary]) {
                $responses = $response['responses']
            } elseif ($response.PSObject.Properties['responses']) {
                $responses = $response.responses
            }

            if (-not $responses -or @($responses).Count -eq 0) {
                Write-IDLog "Batch response had no 'responses' array. Marking all as failed."
                foreach ($req in $pendingRequests) { $results[[string]$req.id] = $null }
                break
            }

            foreach ($resp in $responses) {
                if ($resp -is [System.Collections.IDictionary]) {
                    $id     = [string]$resp['id']
                    $status = [int]$resp['status']
                    $body   = $resp['body']
                    $hdrs   = $resp['headers']
                } else {
                    $id     = [string]$resp.id
                    $status = [int]$resp.status
                    $body   = $resp.body
                    $hdrs   = $resp.headers
                }

                if ($status -ge 200 -and $status -lt 300) {
                    $results[$id] = $body
                } elseif ($status -eq 429) {
                    $retryAfter = 2
                    if ($hdrs) {
                        $ra = if ($hdrs -is [System.Collections.IDictionary]) { $hdrs['Retry-After'] } else { $hdrs.'Retry-After' }
                        if ($ra) { try { $retryAfter = [int]$ra } catch {} }
                    }
                    if ($retryAfter -gt $maxRetryAfter) { $maxRetryAfter = $retryAfter }
                    $original = $pendingRequests | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
                    if ($original) { $throttled.Add($original) }
                } elseif ($status -eq 401 -and -not $tokenRefreshed) {
                    # Per-item 401: refresh token, retry whole batch once
                    Write-IDLog "Batch item $id returned 401 — refreshing token"
                    try {
                        Get-IDAccessToken -ForceRefresh | Out-Null
                        $tokenRefreshed = $true
                    } catch {
                        Write-IDLog "Token refresh failed: $($_.Exception.Message)"
                    }
                    $original = $pendingRequests | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
                    if ($original) { $throttled.Add($original) }
                } else {
                    Write-IDLog "Batch item $id failed with status $status"
                    $results[$id] = $null
                }
            }

            if ($throttled.Count -gt 0 -and $retryCount -lt $MaxRetries) {
                if ($maxRetryAfter -gt 0) {
                    Write-IDLog "Batch: $($throttled.Count) items retrying after ${maxRetryAfter}s..."
                    Start-Sleep -Seconds ([Math]::Max($maxRetryAfter, 1))
                }
                $pendingRequests = @($throttled.ToArray())
                $retryCount++
            } else {
                foreach ($req in $throttled) { $results[[string]$req.id] = $null }
                break
            }
        }
    }

    return $results
}