Providers/Entra/Private/Invoke-EntraRequest.ps1
|
function Invoke-EntraRequest { <# .SYNOPSIS Single entry point for every Microsoft Graph call this module makes .DESCRIPTION Wraps Invoke-WebRequest with the things Graph needs and PowerShell does not do by default: - Token lifecycle. The bearer token is fetched on demand and renewed before expiry, so no caller has to think about it. A 401 mid-run forces one renewal and retries once, which covers a token revoked or a clock that drifted during a long seed. - UTF-8 on both sides. The seed data carries accented names on purpose, and Windows PowerShell will otherwise send them as question marks and decode responses using the wrong code page. - @odata.nextLink pagination. Graph pages with an absolute URL rather than a cursor or an offset, and -Paginate follows it until it stops appearing. The link is compared against the URL just fetched, because a nextLink pointing at the current page is an infinite loop that looks like a hang. - Throttling. Graph answers 429 with a Retry-After in seconds. That value is honoured when present, with exponential backoff as the fallback, and it is capped so a bad header cannot park a run indefinitely. - Replication lag. An object created through Graph is not immediately addressable on every replica: a DELETE or PATCH issued seconds after the POST that created it can return 404. This is real and reproducible, so -RetryOnNotFound turns that specific case into a bounded retry rather than a spurious failure. It is opt-in, because for a read a 404 usually means what it says. - Readable errors. Graph puts the diagnosis and the request id in the response body, which PowerShell discards on both editions. Invoke-WebRequest is used rather than Invoke-RestMethod because Retry-After and the response status are only reachable through the response object, and Windows PowerShell's Invoke-RestMethod does not expose them at all. .PARAMETER Method HTTP method .PARAMETER Path Graph path below the API version, beginning with a slash, for example /users .PARAMETER Body Request body. A string is sent as-is, anything else is serialised to JSON. .PARAMETER Query Query string parameters. Values are URL-encoded. OData parameters keep their $ sign. .PARAMETER ApiVersion v1.0 or beta. Defaults to v1.0; several objects this module seeds only exist on beta. .PARAMETER Paginate Follow @odata.nextLink and return every item across all pages .PARAMETER ConsistencyLevel Sends ConsistencyLevel: eventual, which Graph requires for $count, $search and the advanced filter operators. Without it those queries fail rather than degrade. .PARAMETER RetryOnNotFound Treat a 404 as retryable. Use immediately after a create, where a 404 means the replica has not caught up rather than that the object is absent. .PARAMETER RetryOnErrorMatch Treat any 4xx whose error text matches this regular expression as retryable. Graph reports some replication failures as 400 rather than 404 - creating a service principal for an application that exists returns "does not reference a valid application object" with HTTP 400 - and those are indistinguishable from a genuinely malformed request by status code alone. Matching on the message is narrow enough to be safe and is the only signal available. .PARAMETER Connection Connection to use instead of the module's active one .PARAMETER MaxRetry Attempts made against a throttle, a server error, or a retryable 404 .OUTPUTS The deserialised response body, the collected value array with -Paginate, or $null for a 204. .EXAMPLE PS> Invoke-EntraRequest -Method GET -Path '/users' -Query @{ '$select' = 'displayName' } -Paginate DESCRIPTION: Reads every user, following pagination OUTPUT: An array of user objects USE CASE: The read half of almost everything this module does .EXAMPLE PS> Invoke-EntraRequest -Method DELETE -Path "/groups/$id" -RetryOnNotFound DESCRIPTION: Deletes a group that may have been created moments ago OUTPUT: $null USE CASE: Teardown, where replication lag would otherwise look like a missing object .NOTES Author: Jeffrey Stuhr Blog: https://www.techbyjeff.net LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ #> [CmdletBinding()] [OutputType([object])] param( [Parameter(Mandatory = $true)] [ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE')] [string]$Method, [Parameter(Mandatory = $true)] [ValidatePattern('^/')] [string]$Path, [Parameter()] [object]$Body, [Parameter()] [hashtable]$Query, [Parameter()] [ValidateSet('v1.0', 'beta')] [string]$ApiVersion = 'v1.0', [Parameter()] [switch]$Paginate, [Parameter()] [switch]$ConsistencyLevel, [Parameter()] [switch]$RetryOnNotFound, [Parameter()] [ValidateNotNullOrEmpty()] [string]$RetryOnErrorMatch, [Parameter()] [hashtable]$Connection, [Parameter()] [ValidateRange(1, 10)] [int]$MaxRetry = 5 ) if (-not $Connection) { $Connection = Get-EntraConnection } # Windows PowerShell defaults to TLS 1.0, which Entra refuses outright. Only ever add to # the enabled set: clearing it would change behaviour for everything else in the session. if ($PSVersionTable.PSEdition -eq 'Desktop') { $tls12 = [System.Net.SecurityProtocolType]::Tls12 if (([System.Net.ServicePointManager]::SecurityProtocol -band $tls12) -ne $tls12) { [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor $tls12 } } $uri = '{0}/{1}{2}' -f $Connection.GraphBaseUri.TrimEnd('/'), $ApiVersion, $Path if ($Query -and $Query.Count -gt 0) { $pairs = foreach ($key in $Query.Keys) { if ($null -eq $Query[$key] -or '' -eq $Query[$key]) { continue } # The key is not escaped: OData parameters lead with a literal dollar sign, and # escaping it to %24 is accepted by Graph but makes every verbose trace and every # captured request unreadable. '{0}={1}' -f $key, [uri]::EscapeDataString([string]$Query[$key]) } if ($pairs) { $uri = '{0}?{1}' -f $uri, ($pairs -join '&') } } $bodyBytes = $null if ($null -ne $Body) { $bodyText = if ($Body -is [string]) { $Body } else { $Body | ConvertTo-Json -Depth 20 -Compress } $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($bodyText) } # Invoke-WebRequest paints a progress bar per call on Windows PowerShell, which across a # few hundred calls costs more wall clock than the calls do. $previousProgress = $ProgressPreference $ProgressPreference = 'SilentlyContinue' $collected = [System.Collections.Generic.List[object]]::new() $result = $null try { $nextUri = $uri while ($nextUri) { $attempt = 0 $renewed = $false $response = $null while ($true) { $attempt++ $token = Get-EntraAccessToken -AsPlainText -Connection $Connection $headers = @{ 'Authorization' = "Bearer $token" 'Accept' = 'application/json' } if ($ConsistencyLevel) { $headers['ConsistencyLevel'] = 'eventual' } try { $requestArgs = @{ Uri = $nextUri Method = $Method Headers = $headers UseBasicParsing = $true ErrorAction = 'Stop' } if ($null -ne $bodyBytes) { $requestArgs.Body = $bodyBytes $requestArgs.ContentType = 'application/json; charset=utf-8' } Write-Verbose "Graph $Method $nextUri (attempt $attempt)" $response = Invoke-WebRequest @requestArgs break } catch { $statusCode = 0 if ($_.Exception.PSObject.Properties['Response'] -and $_.Exception.Response) { try { $statusCode = [int]$_.Exception.Response.StatusCode } catch { $statusCode = 0 } } # One forced token renewal on a 401. Beyond that it is a real # authorisation problem, and retrying only delays the message. if ($statusCode -eq 401 -and -not $renewed) { Write-Verbose "Graph returned 401; renewing the access token and retrying once." $renewed = $true $null = Get-EntraAccessToken -Force -Connection $Connection continue } # Resolved once and reused: the message is needed both to decide whether # this is retryable and to build the exception if it is not, and reading # the response stream twice on Windows PowerShell yields nothing the # second time. $detail = Get-EntraErrorDetail -ErrorRecord $_ $retryable = ($statusCode -eq 429 -or $statusCode -ge 500 -or ($RetryOnNotFound -and $statusCode -eq 404) -or ($RetryOnErrorMatch -and $statusCode -ge 400 -and $statusCode -lt 500 -and $detail -match $RetryOnErrorMatch)) if (-not $retryable -or $attempt -ge $MaxRetry) { # The message is built into its own variable rather than inline. A # concatenation written directly inside New-Object's argument list # binds as ONE array argument, so ("message" + $detail), $exception # is joined into a single string and matched against # Exception(string). The result is a message with the inner # exception's whole stack trace stringified into it, and no # InnerException set at all. $statusText = if ($statusCode) { " with HTTP $statusCode" } else { '' } $message = "Graph $Method $ApiVersion$Path failed$statusText`: $detail" throw (New-Object System.Exception($message, $_.Exception)) } # Graph sends Retry-After in seconds on a 429. Prefer it over guessing, # but cap it so a malformed header cannot park the run for an hour. $waitSeconds = [Math]::Min(60, [Math]::Pow(2, $attempt)) try { $retryAfter = $_.Exception.Response.Headers['Retry-After'] if ($retryAfter) { $parsed = 0 if ([int]::TryParse((@($retryAfter)[0]), [ref]$parsed) -and $parsed -gt 0) { $waitSeconds = [Math]::Min(60, $parsed) } } } catch { Write-Verbose "No usable Retry-After header; backing off instead." } Write-Warning ("Graph returned HTTP $statusCode for $Method $Path. " + "Retrying in $waitSeconds second(s) (attempt $attempt of $MaxRetry).") Start-Sleep -Seconds $waitSeconds } } # Decode from the raw bytes rather than $response.Content. Windows PowerShell # decodes using the response's declared charset and falls back to Latin-1, which # turns every accented character in the directory into mojibake. $content = $null if ($response.RawContentStream -and $response.RawContentStream.Length -gt 0) { $content = [System.Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray()) } elseif ($response.Content -is [byte[]]) { $content = [System.Text.Encoding]::UTF8.GetString($response.Content) } else { $content = [string]$response.Content } $page = $null if (-not [string]::IsNullOrWhiteSpace($content)) { $page = $content | ConvertFrom-Json } if (-not $Paginate) { $result = $page break } if ($page -and $page.PSObject.Properties['value']) { $items = @($page.value) if ($items.Count -gt 0) { $collected.AddRange($items) } } # Graph pages with an absolute URL carrying an opaque skiptoken. The # self-comparison is the loop guard. $currentUri = $nextUri $nextUri = $null if ($page -and $page.PSObject.Properties['@odata.nextLink']) { $link = [string]$page.'@odata.nextLink' if (-not [string]::IsNullOrWhiteSpace($link) -and $link -ne $currentUri) { $nextUri = $link } } } } finally { $ProgressPreference = $previousProgress } if ($Paginate) { return $collected.ToArray() } return $result } |