Private/Graph/Invoke-IntuneDiffRequest.ps1
|
# Copyright (c) 2026 Sandy Zeng. All rights reserved. # Source-available. All rights reserved. See LICENSE file. <# Invoke-IntuneDiffRequest.ps1 — Thin wrapper around Invoke-MgGraphRequest with pagination and 429 retry. Author: Sandy Zeng Project: IntuneDiff Version History: 1.0.0 Initial release. #> function Invoke-IntuneDiffRequest { <# .SYNOPSIS Thin wrapper around Invoke-MgGraphRequest with pagination + 429 retry. .DESCRIPTION - Calls Invoke-MgGraphRequest with the configured Graph context. - For GET requests, follows @odata.nextLink and concatenates `value` arrays. - Retries automatically on HTTP 429 honoring the Retry-After header. - Returns the raw response object for POST or the merged result for GET. .PARAMETER Method HTTP method. Defaults to GET. .PARAMETER Uri Absolute or relative Graph URI (e.g. /beta/deviceManagement/configurationPolicies). .PARAMETER Body Optional request body (hashtable) for POST/PATCH. .PARAMETER All For GET requests, follow @odata.nextLink until exhausted and return a hashtable whose `value` property contains every page concatenated. #> [CmdletBinding()] param( [ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE')] [string]$Method = 'GET', [Parameter(Mandatory)] [string]$Uri, [object]$Body, [switch]$All, [int]$TimeoutSec = 30 ) Write-IDLog "Graph $Method $Uri" $maxRetries = 5 $attempt = 0 $currentUri = $Uri $tokenRefreshed = $false # Helper: detect 401 from various exception formats $isUnauthorized = { param($err) if ($err.Exception.Response -and [int]$err.Exception.Response.StatusCode -eq 401) { return $true } if ($err.Exception.Message -match 'Unauthorized') { return $true } return $false } # Helper: silently refresh token using MSAL cache $refreshToken = { Write-IDLog 'Token expired, attempting silent refresh...' $app = $script:MSALApp if (-not $app) { throw 'MSAL app not initialized. Please sign in again.' } $accounts = $app.GetAccountsAsync().GetAwaiter().GetResult() $account = $accounts | Select-Object -First 1 if (-not $account) { throw 'No cached account found. Please sign in again.' } $scopes = Get-IntuneDiffRequiredScopes $silentBuilder = $app.AcquireTokenSilent($scopes, $account) $authResult = $silentBuilder.ExecuteAsync().GetAwaiter().GetResult() if (-not $authResult -or [string]::IsNullOrEmpty($authResult.AccessToken)) { throw 'Token refresh failed. Please sign in again.' } Write-IDLog "Token refreshed for $($authResult.Account.Username)" Write-MSALCache $secureToken = ConvertTo-SecureString $authResult.AccessToken -AsPlainText -Force try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {} try { Connect-MgGraph -AccessToken $secureToken -NoWelcome | Out-Null } catch { Connect-MgGraph -AccessToken $secureToken | Out-Null } } # Helper: invoke Graph request with timeout using thread job (shares session state) $invokeWithTimeout = { param([hashtable]$Params, [int]$Timeout) $job = Start-ThreadJob -ScriptBlock { param($p) Invoke-MgGraphRequest @p } -ArgumentList $Params $completed = $job | Wait-Job -Timeout $Timeout if (-not $completed) { $job | Stop-Job $job | Remove-Job -Force throw "Graph request timed out after ${Timeout}s: $($Params.Uri)" } $result = $job | Receive-Job $job | Remove-Job -Force return $result } if ($Method -eq 'GET' -and $All) { $aggregated = @() $firstResponse = $null while ($currentUri) { $attempt = 0 $response = $null while ($true) { try { $params = @{ Method = 'GET' Uri = $currentUri } $response = & $invokeWithTimeout $params $TimeoutSec break } catch { $statusCode = $null if ($_.Exception.Response) { $statusCode = [int]$_.Exception.Response.StatusCode } if ($statusCode -eq 429 -and $attempt -lt $maxRetries) { $retryAfter = 2 try { $headerValue = $_.Exception.Response.Headers['Retry-After'] if ($headerValue) { $retryAfter = [int]$headerValue } } catch { } Start-Sleep -Seconds $retryAfter $attempt++ continue } if ((& $isUnauthorized $_) -and -not $tokenRefreshed) { & $refreshToken $tokenRefreshed = $true continue } throw } } if (-not $firstResponse) { $firstResponse = $response } if ($response.value) { $aggregated += $response.value } $nextLink = $response.'@odata.nextLink' if ($nextLink) { if ($nextLink -notmatch '^https://graph\.microsoft\.com/') { Write-Warning "IntuneDiff: Ignoring untrusted @odata.nextLink: $nextLink" $nextLink = $null } } $currentUri = $nextLink } # Return shape mirroring a single Graph response with all rows. if ($firstResponse -is [hashtable]) { $firstResponse['value'] = $aggregated return $firstResponse } return @{ value = $aggregated } } # Single-shot (non-paginated) request while ($true) { try { $params = @{ Method = $Method Uri = $currentUri } if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) { $params['Body'] = ($Body | ConvertTo-Json -Depth 20 -Compress) $params['ContentType'] = 'application/json' } return (& $invokeWithTimeout $params $TimeoutSec) } catch { $statusCode = $null if ($_.Exception.Response) { $statusCode = [int]$_.Exception.Response.StatusCode } if ($statusCode -eq 429 -and $attempt -lt $maxRetries) { $retryAfter = 2 try { $headerValue = $_.Exception.Response.Headers['Retry-After'] if ($headerValue) { $retryAfter = [int]$headerValue } } catch { } Start-Sleep -Seconds $retryAfter $attempt++ continue } if ((& $isUnauthorized $_) -and -not $tokenRefreshed) { & $refreshToken $tokenRefreshed = $true continue } throw } } } |