Private/Invoke-MSGraphOperation.ps1
|
function Invoke-MSGraphOperation { <# .SYNOPSIS Perform a specific call to Intune Graph API, either as GET, POST, PATCH or DELETE methods. .DESCRIPTION Perform a specific call to Intune Graph API, either as GET, POST, PATCH or DELETE methods. This function handles nextLink objects including throttling based on retry-after value from Graph response. .PARAMETER Get Switch parameter used to specify the method operation as 'GET'. .PARAMETER Post Switch parameter used to specify the method operation as 'POST'. .PARAMETER Patch Switch parameter used to specify the method operation as 'PATCH'. .PARAMETER Put Switch parameter used to specify the method operation as 'PUT'. .PARAMETER Delete Switch parameter used to specify the method operation as 'DELETE'. .PARAMETER Resource Specify the full resource path, e.g. deviceManagement/auditEvents. .PARAMETER Body Specify the body construct. .PARAMETER APIVersion Specify to use either 'Beta' or 'v1.0' API version. .PARAMETER ContentType Specify the content type for the graph request. .NOTES Author: Nickolaj Andersen & Jan Ketil Skanke Contact: @JankeSkanke @NickolajA Created: 2020-10-11 Updated: 2026-01-18 Version history: 1.0.0 - (2020-10-11) Function created 1.0.1 - (2020-11-11) Tested and verified for rate-limit and nextLink 1.0.2 - (2021-04-12) Adjusted for usage in MSGraphRequest module 1.0.3 - (2021-08-19) Fixed bug to handle single result 1.0.4 - (2021-09-08) Added cross platform support for error details and fixed an error where StreamReader was used but not supported on newer PS versions. Fixed bug to handle empty results when using GET operation. 1.0.5 - (2026-01-04) Added sophisticated retry logic with exponential backoff, Retry-After header support, and transient error handling. 1.0.6 - (2026-01-04) Implemented automatic token refresh using Update-AccessTokenFromRefreshToken when token expires. Supports up to 10 retry attempts with intelligent delay calculation based on Graph API throttling responses. 1.0.7 - (2026-01-18) Fixed Issue #208: Ensured offline_access scope is included in automatic token refresh to maintain refresh token continuity #> param( [parameter(Mandatory = $true, ParameterSetName = "GET", HelpMessage = "Switch parameter used to specify the method operation as 'GET'.")] [switch]$Get, [parameter(Mandatory = $true, ParameterSetName = "POST", HelpMessage = "Switch parameter used to specify the method operation as 'POST'.")] [switch]$Post, [parameter(Mandatory = $true, ParameterSetName = "PATCH", HelpMessage = "Switch parameter used to specify the method operation as 'PATCH'.")] [switch]$Patch, [parameter(Mandatory = $true, ParameterSetName = "PUT", HelpMessage = "Switch parameter used to specify the method operation as 'PUT'.")] [switch]$Put, [parameter(Mandatory = $true, ParameterSetName = "DELETE", HelpMessage = "Switch parameter used to specify the method operation as 'DELETE'.")] [switch]$Delete, [parameter(Mandatory = $true, ParameterSetName = "GET", HelpMessage = "Specify the full resource path, e.g. deviceManagement/auditEvents.")] [parameter(Mandatory = $true, ParameterSetName = "POST")] [parameter(Mandatory = $true, ParameterSetName = "PATCH")] [parameter(Mandatory = $true, ParameterSetName = "PUT")] [parameter(Mandatory = $true, ParameterSetName = "DELETE")] [ValidateNotNullOrEmpty()] [string]$Resource, [parameter(Mandatory = $true, ParameterSetName = "POST", HelpMessage = "Specify the body construct.")] [parameter(Mandatory = $true, ParameterSetName = "PATCH")] [parameter(Mandatory = $true, ParameterSetName = "PUT")] [ValidateNotNullOrEmpty()] [System.Object]$Body, [parameter(Mandatory = $false, ParameterSetName = "GET", HelpMessage = "Specify to use either 'Beta' or 'v1.0' API version.")] [parameter(Mandatory = $false, ParameterSetName = "POST")] [parameter(Mandatory = $false, ParameterSetName = "PATCH")] [parameter(Mandatory = $false, ParameterSetName = "PUT")] [parameter(Mandatory = $false, ParameterSetName = "DELETE")] [ValidateNotNullOrEmpty()] [ValidateSet("Beta", "v1.0")] [string]$APIVersion = "v1.0", [parameter(Mandatory = $false, ParameterSetName = "GET", HelpMessage = "Specify the content type for the graph request.")] [parameter(Mandatory = $false, ParameterSetName = "POST")] [parameter(Mandatory = $false, ParameterSetName = "PATCH")] [parameter(Mandatory = $false, ParameterSetName = "PUT")] [parameter(Mandatory = $false, ParameterSetName = "DELETE")] [ValidateNotNullOrEmpty()] [ValidateSet("application/json", "image/png")] [string]$ContentType = "application/json" ) Begin { # Check if authentication header exists if ($Global:AuthenticationHeader -eq $null) { Write-Warning -Message "Unable to find authentication header, use Connect-MSIntuneGraph function before running this function"; break } # Check if access token needs refresh if (-not (Test-AccessToken)) { Write-Verbose -Message "Access token requires renewal" # Attempt silent refresh if refresh token is available if ($null -ne $Global:AccessToken -and $Global:AccessToken.PSObject.Properties["RefreshToken"] -and -not [string]::IsNullOrEmpty($Global:AccessToken.RefreshToken)) { Write-Verbose -Message "Attempting silent token refresh" try { $Scopes = if ($Global:AccessToken.PSObject.Properties["Scopes"]) { $Global:AccessToken.Scopes } else { @("DeviceManagementApps.ReadWrite.All", "offline_access") } Update-AccessTokenFromRefreshToken -TenantID $Global:AccessTokenTenantID -ClientID $Global:AccessToken.client_id -RefreshToken $Global:AccessToken.RefreshToken -Scopes $Scopes # Update authentication header with new token $Global:AuthenticationHeader = New-AuthenticationHeader -AccessToken $Global:AccessToken Write-Verbose -Message "Successfully refreshed access token silently" } catch { Write-Warning -Message "Silent token refresh failed: $($_). Please re-authenticate using Connect-MSIntuneGraph" break } } else { Write-Warning -Message "Access token has expired and no refresh token is available. Please re-authenticate using Connect-MSIntuneGraph" break } } # Retry parameters for transient error handling $MaxRetryAttempts = 10 $RetryDelayRange = @{ Min = 5; Max = 30 } } Process { # Construct list as return value for handling both single and multiple instances in response from call $GraphResponseList = New-Object -TypeName "System.Collections.ArrayList" # Construct full URI $GraphURI = "https://graph.microsoft.com/$($APIVersion)/$($Resource)" Write-Verbose -Message "$($PSCmdlet.ParameterSetName) $($GraphURI)" # Call Graph API and get JSON response with pagination and retry support $GraphResponseProcess = $true do { $RetryAttempt = 0 $RequestSucceeded = $false while (-not $RequestSucceeded -and $RetryAttempt -lt $MaxRetryAttempts) { try { # Construct table of default request parameters $RequestParams = @{ "Uri" = $GraphURI "Headers" = $Global:AuthenticationHeader "Method" = $PSCmdlet.ParameterSetName "ErrorAction" = "Stop" "Verbose" = $false } switch ($PSCmdlet.ParameterSetName) { "POST" { $RequestParams.Add("Body", $Body) $RequestParams.Add("ContentType", $ContentType) } "PATCH" { $RequestParams.Add("Body", $Body) $RequestParams.Add("ContentType", $ContentType) } "PUT" { $RequestParams.Add("Body", $Body) $RequestParams.Add("ContentType", $ContentType) } } # Invoke Graph request $GraphResponse = Invoke-RestMethod @RequestParams # Mark request as successful $RequestSucceeded = $true # Handle paging in response if ($null -ne $GraphResponse.'@odata.nextLink') { $GraphResponseList.AddRange($GraphResponse.value) | Out-Null $GraphURI = $GraphResponse.'@odata.nextLink' Write-Verbose -Message "NextLink: $($GraphURI)" } else { # NextLink from response was null, assuming last page but also handle if a single instance is returned if ($GraphResponse.value) { $GraphResponseList.AddRange($GraphResponse.value) | Out-Null } elseif ($GraphResponse.'@odata.count' -eq 0) { # Do nothing to return empty } else { $GraphResponseList.Add($GraphResponse) | Out-Null } # Set graph response as handled and stop processing loop $GraphResponseProcess = $false } } catch [System.Exception] { # Capture current error $ExceptionItem = $PSItem $RetryAttempt++ # Determine if this is a retryable error $IsRetryable = $false $RetryDelay = 0 $UseExponentialBackoff = $false # Construct response error custom object for cross platform support $ResponseBody = [PSCustomObject]@{ "ErrorMessage" = [string]::Empty "ErrorCode" = [string]::Empty } # Read response error details differently depending PSVersion switch ($PSVersionTable.PSVersion.Major) { "5" { # Read the response stream if ($ExceptionItem.Exception.Response) { $StreamReader = New-Object -TypeName "System.IO.StreamReader" -ArgumentList @($ExceptionItem.Exception.Response.GetResponseStream()) $StreamReader.BaseStream.Position = 0 $StreamReader.DiscardBufferedData() $ResponseContent = $StreamReader.ReadToEnd() # Attempt to parse response as JSON try { $ResponseReader = $ResponseContent | ConvertFrom-Json $ResponseBody.ErrorMessage = if ($ResponseReader.error.message) { $ResponseReader.error.message } else { $ResponseContent } $ResponseBody.ErrorCode = if ($ResponseReader.error.code) { $ResponseReader.error.code } else { "Unknown" } } catch { # Fallback for non-JSON responses Write-Verbose -Message "Failed to parse error response as JSON, using raw content" $ResponseBody.ErrorMessage = $ResponseContent $ResponseBody.ErrorCode = "JsonParseError" } } else { $ResponseBody.ErrorMessage = $ExceptionItem.Exception.Message $ResponseBody.ErrorCode = "Unknown" } } default { # Validate and parse error details for PowerShell 6+ if ($ExceptionItem.ErrorDetails.Message -and (Test-Json -Json $ExceptionItem.ErrorDetails.Message -ErrorAction SilentlyContinue)) { try { $ErrorDetails = $ExceptionItem.ErrorDetails.Message | ConvertFrom-Json $ResponseBody.ErrorMessage = if ($ErrorDetails.error.message) { $ErrorDetails.error.message } else { $ExceptionItem.ErrorDetails.Message } $ResponseBody.ErrorCode = if ($ErrorDetails.error.code) { $ErrorDetails.error.code } else { "Unknown" } } catch { # Fallback if JSON parsing fails despite validation Write-Verbose -Message "Failed to parse error response as JSON, using raw content" $ResponseBody.ErrorMessage = $ExceptionItem.ErrorDetails.Message $ResponseBody.ErrorCode = "JsonParseError" } } else { # Not valid JSON or null/empty, use raw error message Write-Verbose -Message "Error response is not valid JSON or is empty" $ResponseBody.ErrorMessage = if ($ExceptionItem.ErrorDetails.Message) { $ExceptionItem.ErrorDetails.Message } else { $ExceptionItem.Exception.Message } $ResponseBody.ErrorCode = "NonJsonError" } } } # Check for HTTP status code based retries (throttling, service issues) if ($ExceptionItem.Exception.Response.StatusCode) { $StatusCode = $ExceptionItem.Exception.Response.StatusCode switch ($StatusCode) { "TooManyRequests" { # 429 - Use Retry-After header from Graph API (required by API specification) $RetryAfterHeader = $ExceptionItem.Exception.Response.Headers["Retry-After"] if ($RetryAfterHeader) { $IsRetryable = $true # Retry-After can be seconds (integer) or HTTP date $RetryAfterValue = $null if ([int]::TryParse($RetryAfterHeader, [ref]$RetryAfterValue)) { $RetryDelay = $RetryAfterValue Write-Verbose -Message "Graph API provided Retry-After value: $($RetryDelay) seconds" } else { # Try parsing as HTTP date $RetryAfterDate = $null if ([DateTime]::TryParse($RetryAfterHeader, [ref]$RetryAfterDate)) { $RetryDelay = [Math]::Max(1, [int](($RetryAfterDate - [DateTime]::UtcNow).TotalSeconds)) Write-Verbose -Message "Graph API provided Retry-After date, calculated delay: $($RetryDelay) seconds" } else { # Could not parse Retry-After header, do not retry Write-Warning -Message "Graph throttling (429) detected but Retry-After header could not be parsed: $($RetryAfterHeader)" $IsRetryable = $false } } if ($IsRetryable -and $RetryAttempt -lt $MaxRetryAttempts) { Write-Warning -Message "Graph throttling (429) detected. Retrying in $($RetryDelay) seconds (Attempt $($RetryAttempt) of $($MaxRetryAttempts))" } } else { # No Retry-After header provided by Graph API, cannot retry safely Write-Warning -Message "Graph throttling (429) detected but no Retry-After header provided" $IsRetryable = $false } } "ServiceUnavailable" { # 503 - Service temporarily unavailable $IsRetryable = $true $UseExponentialBackoff = $true } "GatewayTimeout" { # 504 - Gateway timeout $IsRetryable = $true $UseExponentialBackoff = $true } "BadGateway" { # 502 - Bad gateway $IsRetryable = $true $UseExponentialBackoff = $true } default { # Non-retryable HTTP status code $IsRetryable = $false } } # Apply exponential backoff with jitter for service errors if ($UseExponentialBackoff -and $IsRetryable) { $BaseDelay = [Math]::Min(120, [Math]::Pow(2, $RetryAttempt)) $Jitter = Get-Random -Minimum $RetryDelayRange.Min -Maximum $RetryDelayRange.Max $RetryDelay = $BaseDelay + $Jitter if ($RetryAttempt -lt $MaxRetryAttempts) { Write-Warning -Message "Graph service error ($($StatusCode)) detected. Retrying in $($RetryDelay) seconds (Attempt $($RetryAttempt) of $($MaxRetryAttempts))" } } } # If retryable and haven't exceeded max attempts, wait and retry if ($IsRetryable -and $RetryAttempt -lt $MaxRetryAttempts) { Start-Sleep -Seconds $RetryDelay continue } # If not retryable or max retries exceeded, handle final error if ($RetryAttempt -ge $MaxRetryAttempts) { Write-Warning -Message "Graph request failed after $($MaxRetryAttempts) retry attempts" } # Convert status code to integer for output if available $HttpStatusCodeInteger = 0 if ($ExceptionItem.Exception.Response.StatusCode) { $HttpStatusCodeInteger = ([int][System.Net.HttpStatusCode]$ExceptionItem.Exception.Response.StatusCode) } # Handle error based on operation type switch ($PSCmdlet.ParameterSetName) { "GET" { # Output warning message for GET operations if ($HttpStatusCodeInteger -gt 0) { Write-Warning -Message "Graph request failed with status code '$($HttpStatusCodeInteger) ($($ExceptionItem.Exception.Response.StatusCode))'. Error details: $($ResponseBody.ErrorCode) - $($ResponseBody.ErrorMessage)" } else { Write-Warning -Message "Graph request failed. Error details: $($ResponseBody.ErrorCode) - $($ResponseBody.ErrorMessage)" } # Set graph response as handled and stop processing loop $GraphResponseProcess = $false $RequestSucceeded = $true } default { # Throw terminating error for POST/PATCH/DELETE operations $SystemException = New-Object -TypeName "System.Management.Automation.RuntimeException" -ArgumentList ("{0}: {1}" -f $ResponseBody.ErrorCode, $ResponseBody.ErrorMessage) $ErrorRecord = New-Object -TypeName "System.Management.Automation.ErrorRecord" -ArgumentList @($SystemException, $ErrorID, [System.Management.Automation.ErrorCategory]::NotImplemented, [string]::Empty) # Throw a terminating custom error record $PSCmdlet.ThrowTerminatingError($ErrorRecord) } } } } } until ($GraphResponseProcess -eq $false) # Handle return value return $GraphResponseList } } |