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
}