Private/Invoke-IOGraphRequest.ps1
|
function Invoke-IOGraphRequest { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Uri, [ValidateSet('GET', 'POST', 'PATCH', 'DELETE')] [string]$Method = 'GET', [object]$Body, [int]$MaxRetries = 3, [switch]$NoPagination, [switch]$SingleResult, [int]$Top = 999, [switch]$SkipConnectionCheck ) if (-not $SkipConnectionCheck) { Test-IOConnection } $allResults = [System.Collections.Generic.List[object]]::new() $currentUri = $Uri # Append $top for GET requests unless caller opted out or already specified if ($Method -eq 'GET' -and -not $NoPagination -and $currentUri -notmatch '[\?&]\$top=') { $sep = if ($currentUri.Contains('?')) { '&' } else { '?' } $currentUri = "${currentUri}${sep}`$top=$Top" } do { $response = $null $retryCount = 0 $succeeded = $false while (-not $succeeded -and $retryCount -le $MaxRetries) { try { $splat = @{ Method = $Method Uri = $currentUri ErrorAction = 'Stop' OutputType = 'PSObject' } if ($Body) { $splat['Body'] = ($Body | ConvertTo-Json -Depth 10 -Compress) $splat['ContentType'] = 'application/json' } Write-Verbose "IO Graph -> $Method $currentUri (attempt $($retryCount + 1))" $response = Invoke-MgGraphRequest @splat $succeeded = $true } catch { $httpStatus = $null if ($_.Exception.PSObject.Properties['Response'] -and $_.Exception.Response) { $httpStatus = [int]$_.Exception.Response.StatusCode } # Also try extracting from the message (Graph SDK sometimes wraps) if (-not $httpStatus -and $_.Exception.Message -match 'Status:\s*(\d{3})') { $httpStatus = [int]$Matches[1] } # ── Throttled (429) ──────────────────────────────────────────── if ($httpStatus -eq 429 -and $retryCount -lt $MaxRetries) { $retryAfter = 5 if ($_.Exception.Response.Headers -and $_.Exception.Response.Headers['Retry-After']) { $retryAfter = [int]$_.Exception.Response.Headers['Retry-After'] } $backoff = [math]::Min($retryAfter * [math]::Pow(2, $retryCount), 120) Write-IOLog "Throttled (429). Waiting $backoff s before retry $($retryCount + 1)/$MaxRetries..." -Level Warning Start-Sleep -Seconds $backoff $retryCount++ continue } # ── Transient server errors ──────────────────────────────────── if ($httpStatus -in @(500, 502, 503, 504) -and $retryCount -lt $MaxRetries) { $backoff = [math]::Min(2 * [math]::Pow(2, $retryCount), 60) Write-IOLog "Server error ($httpStatus). Waiting $backoff s before retry $($retryCount + 1)/$MaxRetries..." -Level Warning Start-Sleep -Seconds $backoff $retryCount++ continue } # ── Permission denied ────────────────────────────────────────── if ($httpStatus -eq 403) { $msg = "Insufficient permissions for: $currentUri. Check required Graph API scopes." throw [System.Management.Automation.ErrorRecord]::new( [System.UnauthorizedAccessException]::new($msg), 'IO_InsufficientPermissions', [System.Management.Automation.ErrorCategory]::PermissionDenied, $currentUri ) } # ── Not found ────────────────────────────────────────────────── if ($httpStatus -eq 404) { $msg = "Resource not found: $currentUri" throw [System.Management.Automation.ErrorRecord]::new( [System.Management.Automation.ItemNotFoundException]::new($msg), 'IO_NotFound', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $currentUri ) } # ── All other errors ─────────────────────────────────────────── $errMsg = "Graph API request failed: $($_.Exception.Message)" if ($httpStatus) { $errMsg += " (HTTP $httpStatus)" } throw [System.Management.Automation.ErrorRecord]::new( [System.InvalidOperationException]::new($errMsg), 'IO_GraphRequestFailed', [System.Management.Automation.ErrorCategory]::ConnectionError, $currentUri ) } } # ── Collect results ─────────────────────────────────────────────────── if ($response) { if ($SingleResult) { $allResults.Add($response) return $allResults } if ($response.PSObject.Properties['value']) { foreach ($item in $response.value) { $allResults.Add($item) } } else { $allResults.Add($response) } # Follow pagination (validate nextLink to prevent SSRF) $currentUri = $null if (-not $NoPagination -and $response.PSObject.Properties['@odata.nextLink']) { $nextLink = $response.'@odata.nextLink' if ($nextLink -match '^https://graph\.(microsoft\.com|microsoft\.us|microsoftazure\.de|microsoft\.cn)/') { $currentUri = $nextLink } elseif ($nextLink -match '^(v1\.0|beta)/') { $currentUri = $nextLink } else { Write-IOLog "Ignoring untrusted nextLink: $($nextLink.Substring(0, [math]::Min(80, $nextLink.Length)))" -Level Warning } } } else { $currentUri = $null } } while ($currentUri) return $allResults } |