Common/Invoke-GraphReadBatch.ps1
|
function Invoke-GraphReadBatch { <# .SYNOPSIS Resolves independent single-object Graph GETs in batches of at most 20. .DESCRIPTION Returns responses keyed by request ID, irrespective of response order. Retries only throttled/transient subrequests. Permanent failures remain explicit HTTP responses so callers can retain unresolved values. Rejects missing/duplicate responses and paginated bodies rather than losing data. .PARAMETER Requests Objects with unique id and relative url (without /v1.0); GET only. .PARAMETER MaxRetries Maximum retries for each transient subresponse, in addition to retries handled by Invoke-SafeGraphRequest for the outer batch request. .EXAMPLE Invoke-GraphReadBatch -Requests @(@{ id='user1'; url='/users/user1?$select=id' }) #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Requests, [ValidateRange(0, 8)][int]$MaxRetries = 4 ) $result = @{} $ids = @{} foreach ($request in $Requests) { if (-not $request.id -or $ids.ContainsKey([string]$request.id)) { throw 'Graph batch request IDs must be nonempty and unique.' } if ($request.url -notmatch '^/[^/]' -or $request.url -match '^/(v1\.0|beta)/' -or ($request.method -and $request.method -ne 'GET')) { throw 'Graph read batches accept only GETs with relative unversioned URLs.' } $ids[[string]$request.id] = $true } for ($offset = 0; $offset -lt $Requests.Count; $offset += 20) { $last = [Math]::Min($offset + 19, $Requests.Count - 1) $pending = @($Requests[$offset..$last] | ForEach-Object { @{ id = [string]$_.id; method = 'GET'; url = $_.url } }) $attempt = 0 while ($pending.Count -gt 0) { $payload = @{ requests = @($pending) } | ConvertTo-Json -Depth 8 -Compress $batch = Invoke-SafeGraphRequest -Uri '/v1.0/$batch' -Method POST -Body $payload -Headers @{ 'Content-Type' = 'application/json' } $responses = @{} foreach ($response in $batch.responses) { $id = [string]$response.id if (-not $id -or $responses.ContainsKey($id) -or $id -notin $pending.id -or -not $response.status -or [int]$response.status -lt 100 -or [int]$response.status -gt 599) { throw 'GraphBatchIncomplete: invalid or duplicate subresponse.' } $responses[$id] = $response } if ($responses.Count -ne $pending.Count) { throw 'GraphBatchIncomplete: missing subresponse.' } $retry = [System.Collections.Generic.List[object]]::new() $delay = [Math]::Pow(2, $attempt + 1) foreach ($request in $pending) { $response = $responses[$request.id] if ([int]$response.status -in @(429, 503, 504) -and $attempt -lt $MaxRetries) { $retry.Add($request) $seconds = 0 $retryAfter = [string]$response.headers.'Retry-After' if ([int]::TryParse($retryAfter, [ref]$seconds) -and $seconds -ge 0) { $delay = [Math]::Max($delay, $seconds) } elseif ($retryAfter) { $retryDate = [datetimeoffset]::MinValue if ([datetimeoffset]::TryParse($retryAfter, [ref]$retryDate)) { $delay = [Math]::Max($delay, [Math]::Ceiling(($retryDate - [datetimeoffset]::UtcNow).TotalSeconds)) } } } else { if ([int]$response.status -ge 200 -and [int]$response.status -lt 300 -and ($null -eq $response.body -or $response.body.'@odata.nextLink')) { throw 'GraphBatchIncomplete: expected a complete single-object response.' } $result[$request.id] = $response } } $pending = @($retry.ToArray()) if ($pending.Count -gt 0) { Start-Sleep -Seconds $delay } $attempt++ } } return $result } |