Private/Invoke-HygieneGraphRequest.ps1
|
<# .SYNOPSIS The single, read-only gateway every Graph call in this module goes through. .DESCRIPTION This function is the module's read-only guarantee, expressed as code rather than as a promise in a README. There is deliberately no -Method parameter and no -Body parameter. The HTTP verb is hardcoded to GET at the only place where an HTTP request is issued, so no check -- present or future -- can perform a write even by mistake. Adding a write would require editing this file, which the read-only test in tests/ReadOnly.Tests.ps1 is designed to catch. It also centralises the two things every real Graph client needs and most sample scripts omit: @odata.nextLink paging, and honouring Retry-After on HTTP 429/503. .PARAMETER Uri Either a Graph-relative path ('users?$select=id') or an absolute URL, which is what @odata.nextLink hands back. .PARAMETER ApiVersion Graph endpoint version. Every production check in this module uses v1.0. .PARAMETER MaxPages Safety valve for very large tenants. Paging stops after this many pages and the caller is told through -ResultInfo that the collection was truncated. .PARAMETER ConsistencyLevel Set to 'eventual' for advanced queries ($count, $search, some $filter forms). .PARAMETER ResultInfo Name of a variable, in the caller's scope, that receives a summary of the call: page count, whether the result was truncated, and the final HTTP status. .OUTPUTS The elements of the 'value' array for collection responses; the response object itself for single-entity responses. #> function Invoke-HygieneGraphRequest { [CmdletBinding()] [OutputType([object])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Uri, [ValidateSet('v1.0', 'beta')] [string] $ApiVersion = 'v1.0', [ValidateRange(1, [int]::MaxValue)] [int] $MaxPages = 200, [ValidateSet('eventual')] [string] $ConsistencyLevel, [ValidateRange(0, 10)] [int] $MaxRetry = 4, [string] $ResultInfo ) $headers = @{} if ($ConsistencyLevel) { $headers['ConsistencyLevel'] = $ConsistencyLevel } $next = $Uri $page = 0 $items = [System.Collections.Generic.List[object]]::new() $status = 0 $truncated = $false while ($next) { if ($page -ge $MaxPages) { $truncated = $true; break } $attempt = 0 $response = $null while ($true) { $params = @{ # Hardcoded on purpose. See the .DESCRIPTION above. Method = 'GET' Uri = $next OutputType = 'PSObject' SkipHttpErrorCheck = $true StatusCodeVariable = 'httpStatus' ResponseHeadersVariable = 'httpHeaders' ErrorAction = 'Stop' } if ($headers.Count -gt 0) { $params['Headers'] = $headers } # Relative paths need the version prefix; an @odata.nextLink is already absolute. if ($params['Uri'] -notmatch '^https?://') { $params['Uri'] = "https://graph.microsoft.com/$ApiVersion/" + $params['Uri'].TrimStart('/') } $response = Invoke-MgGraphRequest @params $status = $httpStatus if ($status -eq 429 -or $status -eq 503 -or $status -eq 504) { if ($attempt -ge $MaxRetry) { throw [System.InvalidOperationException]::new( "Microsoft Graph kept throttling '$($params['Uri'])' (HTTP $status) after $MaxRetry retries.") } $wait = Get-HygieneRetryDelay -Headers $httpHeaders -Attempt $attempt Write-HygieneLog -Level Warning -Message "Graph returned HTTP $status; waiting $wait s before retry $($attempt + 1)/$MaxRetry." Start-Sleep -Seconds $wait $attempt++ continue } break } if ($status -ge 400) { throw (New-HygieneGraphError -StatusCode $status -Response $response -Uri $params['Uri']) } if ($null -ne $response -and $response.PSObject.Properties.Name -contains 'value') { foreach ($item in $response.value) { $items.Add($item) } # Absent on the final page, so it is read defensively rather than assumed. $next = $null if ($response.PSObject.Properties.Name -contains '@odata.nextLink') { $next = $response.'@odata.nextLink' } } else { # Single entity, not a collection. Emit as-is and stop. $items.Add($response) $next = $null } $page++ } if ($ResultInfo) { $info = [pscustomobject]@{ Pages = $page Count = $items.Count Truncated = $truncated StatusCode = $status } Set-Variable -Name $ResultInfo -Value $info -Scope 1 } if ($truncated) { Write-HygieneLog -Level Warning -Message "Stopped paging '$Uri' after $MaxPages pages. Results are incomplete; raise -MaxPages if you need the full set." } return $items.ToArray() } <# .SYNOPSIS Chooses how long to wait after a throttled Graph response. .DESCRIPTION Prefers the server's own Retry-After header. Falls back to capped exponential backoff when the header is missing, which some Graph endpoints do omit. #> function Get-HygieneRetryDelay { [CmdletBinding()] [OutputType([int])] param( [System.Collections.IDictionary] $Headers, [int] $Attempt = 0 ) if ($Headers -and $Headers.Contains('Retry-After')) { $raw = @($Headers['Retry-After'])[0] $parsed = 0 if ([int]::TryParse($raw, [ref] $parsed) -and $parsed -gt 0) { return [Math]::Min($parsed, 120) } } return [Math]::Min([Math]::Pow(2, $Attempt) * 2, 60) } <# .SYNOPSIS Turns a failed Graph response into an exception carrying the detail a caller needs. .DESCRIPTION Checks catch this to tell apart "you lack the permission" (403/401, which downgrades the check to Skipped) from "something genuinely broke" (which fails the check). #> function New-HygieneGraphError { [CmdletBinding()] [OutputType([System.Exception])] param( [int] $StatusCode, [object] $Response, [string] $Uri ) $detail = $null if ($Response -and $Response.PSObject.Properties.Name -contains 'error') { $detail = $Response.error.message if (-not $detail) { $detail = $Response.error.code } } if (-not $detail) { $detail = 'no error body returned' } $kind = switch ($StatusCode) { 401 { 'Authentication' } 403 { 'Permission' } 404 { 'NotFound' } default { 'Request' } } $message = "[$kind] Graph GET $Uri failed with HTTP $StatusCode : $detail" $ex = [System.InvalidOperationException]::new($message) $ex.Data['StatusCode'] = $StatusCode $ex.Data['Kind'] = $kind $ex.Data['Uri'] = $Uri return $ex } |