Private/Invoke-GraphBatchOperation.ps1
|
function Invoke-GraphBatchOperation { <# .SYNOPSIS Executes Graph API batch requests with retry logic and standardized result handling .DESCRIPTION Internal helper that handles batched Graph API requests with exponential backoff retry for transient server errors. Returns results in standardized New-HydrationResult format. .PARAMETER Items Array of items to process. Each item must be a hashtable with at least 'Name' key. For DELETE operations: must include 'Id' key (or 'Url' for the full batch URL path). For POST operations: must include 'BodyJson' key with JSON string payload. Optional keys: 'Url' (overrides BaseUrl for per-item endpoints), 'Type' (overrides ResultType), 'Path' (source file path), 'State' (e.g., CA policy state). .PARAMETER Operation The HTTP operation: 'POST' for creation or 'DELETE' for removal. .PARAMETER BaseUrl The Graph API URL path (without /beta prefix), e.g., '/deviceAppManagement/mobileApps'. Optional if each item provides its own 'Url' key. .PARAMETER ResultType The Type value to use in New-HydrationResult (e.g., 'MobileApp', 'AppProtection') .PARAMETER MaxBatchSize Maximum items per batch request. Defaults to 10. .PARAMETER MaxRetries Maximum retry attempts for failed batches. Defaults to 3. .PARAMETER RetryDelaySeconds Base delay between retries (doubles with each retry). Defaults to 2. .EXAMPLE $items = @(@{ Name = 'App1'; Id = 'guid-1' }, @{ Name = 'App2'; Id = 'guid-2' }) Invoke-GraphBatchOperation -Items $items -Operation 'DELETE' -BaseUrl '/deviceAppManagement/mobileApps' -ResultType 'MobileApp' #> [CmdletBinding()] param( [Parameter(Mandatory)] [array]$Items, [Parameter(Mandatory)] [ValidateSet('POST', 'DELETE')] [string]$Operation, [Parameter()] [string]$BaseUrl, [Parameter(Mandatory)] [string]$ResultType, [Parameter()] [int]$MaxBatchSize = $(if ($script:MaxBatchSize) { $script:MaxBatchSize } else { 10 }), [Parameter()] [int]$MaxRetries = 3, [Parameter()] [int]$RetryDelaySeconds = 2 ) $results = @() if ($Items.Count -eq 0) { return $results } $actionVerb = if ($Operation -eq 'DELETE') { 'Deleting' } else { 'Creating' } Write-Verbose "$actionVerb $($Items.Count) $ResultType items in batches..." for ($batchStart = 0; $batchStart -lt $Items.Count; $batchStart += $MaxBatchSize) { $batchEnd = [Math]::Min($batchStart + $MaxBatchSize, $Items.Count) - 1 $currentBatch = $Items[$batchStart..$batchEnd] $pendingItems = @($currentBatch) $retryCount = 0 while ($pendingItems.Count -gt 0 -and $retryCount -le $MaxRetries) { if ($retryCount -gt 0) { $delay = $RetryDelaySeconds * [Math]::Pow(2, $retryCount - 1) Write-Verbose "Retrying $($pendingItems.Count) failed $($Operation.ToLower())(s) after ${delay}s delay (attempt $retryCount of $MaxRetries)..." Start-Sleep -Seconds $delay } $batchResponse = $null $itemsToRetry = @() if ($Operation -eq 'DELETE') { $batchRequests = @() for ($i = 0; $i -lt $pendingItems.Count; $i++) { $item = $pendingItems[$i] $url = if ($item.Url) { $item.Url } else { "$BaseUrl/$($item.Id)" } $batchRequests += @{ id = ($i + 1).ToString() method = "DELETE" url = $url } } $batchBody = @{ requests = $batchRequests } try { $batchResponse = Invoke-MgGraphRequest -Method POST -Uri "beta/`$batch" -Body $batchBody -ErrorAction Stop Write-Verbose "Batch DELETE response received with $($batchResponse.responses.Count) responses" } catch { if ($retryCount -lt $MaxRetries) { Write-Verbose "Batch delete failed, will retry: $_" $itemsToRetry = $pendingItems } else { Write-Warning "Batch delete failed: $_" foreach ($item in $pendingItems) { $results += New-HydrationResult -Name $item.Name -Type $ResultType -Action 'Failed' -Status "Batch delete failed: $_" } } $pendingItems = $itemsToRetry $retryCount++ continue } } else { # POST operation - build JSON manually to avoid serialization issues # Filter out items with missing URLs before building the batch $validItems = @() $invalidItems = @() foreach ($item in $pendingItems) { $url = if ($item.Url) { $item.Url } else { $BaseUrl } if ([string]::IsNullOrWhiteSpace($url)) { $invalidItems += $item } else { $validItems += $item } } if ($invalidItems.Count -gt 0) { foreach ($item in $invalidItems) { Write-HydrationLog -Message " [!] Failed: $($item.Name) - No API endpoint URL resolved" -Level Warning $resultParams = @{ Name = $item.Name; Type = if ($item.Type) { $item.Type } else { $ResultType } } if ($item.Path) { $resultParams.Path = $item.Path } $results += New-HydrationResult @resultParams -Action 'Failed' -Status 'No API endpoint URL resolved' } } if ($validItems.Count -eq 0) { $pendingItems = @() continue } $pendingItems = $validItems $batchRequestsJson = @() for ($i = 0; $i -lt $pendingItems.Count; $i++) { $item = $pendingItems[$i] $url = if ($item.Url) { $item.Url } else { $BaseUrl } $requestJson = "{`"id`":`"$(($i + 1).ToString())`",`"method`":`"POST`",`"url`":`"$url`",`"headers`":{`"Content-Type`":`"application/json`"},`"body`":$($item.BodyJson)}" $batchRequestsJson += $requestJson } $batchBodyJson = "{`"requests`":[" + ($batchRequestsJson -join ",") + "]}" try { $batchResponse = Invoke-MgGraphRequest -Method POST -Uri "beta/`$batch" -Body $batchBodyJson -ContentType 'application/json' -ErrorAction Stop } catch { if ($retryCount -lt $MaxRetries) { Write-Verbose "Batch create failed, will retry: $_" $itemsToRetry = $pendingItems } else { Write-Warning "Batch create failed: $_" foreach ($item in $pendingItems) { $resultParams = @{ Name = $item.Name Type = $ResultType Action = 'Failed' Status = "Batch create failed: $_" } if ($item.Path) { $resultParams.Path = $item.Path } $results += New-HydrationResult @resultParams } } $pendingItems = $itemsToRetry $retryCount++ continue } } # Process batch response if (-not $batchResponse.responses -or $batchResponse.responses.Count -eq 0) { Write-Warning "Batch response has no responses array for $($pendingItems.Count) items - marking as Failed" foreach ($item in $pendingItems) { $resultParams = @{ Name = $item.Name; Type = $ResultType } if ($item.Path) { $resultParams.Path = $item.Path } $statusMessage = 'Batch response missing responses array' Write-HydrationLog -Message " [!] Failed: $($item.Name) - $statusMessage" -Level Warning $results += New-HydrationResult @resultParams -Action 'Failed' -Status "$Operation failed: $statusMessage" } $pendingItems = @() continue } $seenIndices = @{} foreach ($resp in $batchResponse.responses) { # Safely map batch response ID to pending item with bounds check $requestIndex = $null $item = $null if ([int]::TryParse([string]$resp.id, [ref]$requestIndex)) { $requestIndex = $requestIndex - 1 if ($requestIndex -ge 0 -and $requestIndex -lt $pendingItems.Count) { $item = $pendingItems[$requestIndex] $seenIndices[$requestIndex] = $true } } if (-not $item) { $fallbackName = "Unknown item (batch id $($resp.id))" Write-HydrationLog -Message " [!] Failed: $fallbackName - Unmatched batch response" -Level Warning $results += New-HydrationResult -Name $fallbackName -Type $ResultType -Action 'Failed' -Status "$Operation failed: Unmatched batch response id='$($resp.id)'" continue } Write-Verbose "Batch response for '$($item.Name)': status=$($resp.status)" $resultParams = @{ Name = $item.Name Type = if ($item.Type) { $item.Type } else { $ResultType } } if ($item.Path) { $resultParams.Path = $item.Path } if ($item.State) { $resultParams.State = $item.State } if ($item.Platform) { $resultParams.Platform = $item.Platform } if ($Operation -eq 'DELETE') { if ($resp.status -in @(200, 202, 204)) { Write-HydrationLog -Message " Deleted: $($item.Name)" -Level Info $results += New-HydrationResult @resultParams -Action 'Deleted' -Status 'Success' } elseif ($resp.status -eq 404) { Write-HydrationLog -Message " Skipped: $($item.Name) (already deleted)" -Level Info $results += New-HydrationResult @resultParams -Action 'Skipped' -Status 'Already deleted' } elseif (($resp.status -in @(429, 503) -or $resp.status -ge 500) -and $retryCount -lt $MaxRetries) { Write-Verbose "Retryable error ($($resp.status)) for '$($item.Name)' - will retry" $itemsToRetry += $item } else { $errorMessage = if ($resp.body.error.message) { $resp.body.error.message } else { "HTTP $($resp.status)" } Write-HydrationLog -Message " [!] Failed: $($item.Name) - $errorMessage" -Level Warning $results += New-HydrationResult @resultParams -Action 'Failed' -Status "Delete failed: $errorMessage" } } else { # POST response handling if ($resp.status -in @(200, 201)) { Write-HydrationLog -Message " Created: $($item.Name)" -Level Info $resultParams.Id = $resp.body.id $results += New-HydrationResult @resultParams -Action 'Created' -Status 'Success' } elseif ($resp.status -eq 409) { Write-HydrationLog -Message " Skipped: $($item.Name) (race condition)" -Level Info $results += New-HydrationResult @resultParams -Action 'Skipped' -Status 'Already exists (race condition)' } elseif (($resp.status -in @(429, 503) -or $resp.status -ge 500) -and $retryCount -lt $MaxRetries) { Write-Verbose "Retryable error ($($resp.status)) for '$($item.Name)' - will retry" $itemsToRetry += $item } else { $errorMessage = if ($resp.body.error.message) { $resp.body.error.message } else { "HTTP $($resp.status)" } Write-HydrationLog -Message " [!] Failed: $($item.Name) - $errorMessage" -Level Warning $results += New-HydrationResult @resultParams -Action 'Failed' -Status $errorMessage } } } # Retry or fail any pending items missing from the batch response for ($i = 0; $i -lt $pendingItems.Count; $i++) { if (-not $seenIndices.ContainsKey($i)) { $missingItem = $pendingItems[$i] if ($retryCount -lt $MaxRetries) { Write-Verbose "Missing response for '$($missingItem.Name)' - will retry" $itemsToRetry += $missingItem } else { $resultParams = @{ Name = $missingItem.Name; Type = if ($missingItem.Type) { $missingItem.Type } else { $ResultType } } if ($missingItem.Path) { $resultParams.Path = $missingItem.Path } Write-HydrationLog -Message " [!] Failed: $($missingItem.Name) - Missing from batch response" -Level Warning $results += New-HydrationResult @resultParams -Action 'Failed' -Status "$Operation failed: No response received from Graph API" } } } $pendingItems = $itemsToRetry $retryCount++ } } return $results } |