Repository navigation
fix(graph): a group is judged by its member counts, and a throttled request is sent again #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,11 +5,19 @@ function Invoke-IslGraphRequest { | |
|
|
||
| .DESCRIPTION | ||
| A thin wrapper over Invoke-MgGraphRequest: one place that checks the Graph module is loaded | ||
| and connected, follows @odata.nextLink when -All is given, and is the single seam the unit | ||
| tests mock. The module does not depend on Microsoft.Graph.Authentication; the caller | ||
| installs it and runs Connect-MgGraph with DeviceManagementConfiguration.Read.All, | ||
| DeviceManagementApps.Read.All and DeviceManagementScripts.Read.All (plus GroupMember.Read.All | ||
| when assignments are inspected) before Test-IntuneDeployedScript is used. | ||
| and connected, follows @odata.nextLink when -All is given, retries a throttled or | ||
| unavailable answer, and is the single seam the unit tests mock. The module does not depend | ||
| on Microsoft.Graph.Authentication; the caller installs it and runs Connect-MgGraph with | ||
| DeviceManagementConfiguration.Read.All, DeviceManagementApps.Read.All and | ||
| DeviceManagementScripts.Read.All (plus GroupMember.Read.All when assignments are inspected) | ||
| before Test-IntuneDeployedScript is used. | ||
|
|
||
| Graph answers 429 when a client is throttled and 503 or 504 when a service is briefly | ||
| unavailable, with a Retry-After header on the first. A request that gets one of those is | ||
| sent again up to three times, after the Retry-After seconds when the header is there and | ||
| after 2, 4 and 8 seconds when it is not; the fourth failure is thrown as it came. A tenant | ||
| command makes two requests per policy, so a pre-flight over a few hundred policies meets | ||
| the throttle without this. | ||
|
|
||
| .PARAMETER Uri | ||
| The request URI, absolute or relative to the Graph root, e.g. | ||
|
|
@@ -21,13 +29,22 @@ function Invoke-IslGraphRequest { | |
| .PARAMETER Body | ||
| The request body for a POST. | ||
|
|
||
| .PARAMETER Headers | ||
| Request headers, e.g. @{ ConsistencyLevel = 'eventual' } for a $count query. | ||
|
|
||
| .PARAMETER All | ||
| Follow @odata.nextLink and return every item of the collection instead of the first page. | ||
|
|
||
| .EXAMPLE | ||
| Invoke-IslGraphRequest -Uri '/beta/deviceManagement/deviceHealthScripts?$select=id,displayName' -All | ||
|
|
||
| Every remediation in the tenant, id and name only. | ||
|
|
||
| .EXAMPLE | ||
| $countSplat = @{ Headers = @{ ConsistencyLevel = 'eventual' } } | ||
| Invoke-IslGraphRequest -Uri '/v1.0/groups/{id}/members/microsoft.graph.device/$count' @countSplat | ||
|
|
||
| The number of devices among the group's members, as text. | ||
| #> | ||
| [CmdletBinding()] | ||
| param( | ||
|
|
@@ -39,6 +56,8 @@ function Invoke-IslGraphRequest { | |
|
|
||
| [hashtable]$Body, | ||
|
|
||
| [hashtable]$Headers, | ||
|
|
||
| [switch]$All | ||
| ) | ||
|
|
||
|
|
@@ -50,14 +69,59 @@ function Invoke-IslGraphRequest { | |
| } | ||
| $requestSplat = @{ Method = $Method; Uri = $Uri; ErrorAction = 'Stop' } | ||
| if ($Body) { $requestSplat.Body = $Body } | ||
| if ($Headers) { $requestSplat.Headers = $Headers } | ||
|
|
||
| # How long to wait before sending a failed request again, or nothing when the failure is not | ||
| # one that passes. Invoke-MgGraphRequest's exception carries the status code and the response; | ||
| # both are read by name so a stand-in exception with the same members serves in the tests | ||
| function Get-RetryDelay { | ||
| param($ErrorRecord, [int]$Attempt) | ||
| $exception = $ErrorRecord.Exception | ||
| $status = 0 | ||
| if ($exception.PSObject.Properties['StatusCode'] -and $exception.StatusCode) { | ||
| $status = [int]$exception.StatusCode | ||
| } | ||
| $response = if ($exception.PSObject.Properties['Response']) { $exception.Response } else { $null } | ||
| if (-not $status -and $response) { $status = [int]$response.StatusCode } | ||
| if ($status -notin 429, 503, 504) { return $null } | ||
|
|
||
| $delay = 0 | ||
| $retryAfter = if ($response) { $response.Headers.RetryAfter } else { $null } | ||
| if ($retryAfter) { | ||
| if ($null -ne $retryAfter.Delta) { $delay = [Math]::Ceiling($retryAfter.Delta.TotalSeconds) } | ||
| elseif ($null -ne $retryAfter.Date) { | ||
| $delay = [Math]::Ceiling(($retryAfter.Date.UtcDateTime - [datetime]::UtcNow).TotalSeconds) | ||
| } | ||
| } | ||
| if ($delay -lt 1) { $delay = [Math]::Pow(2, $Attempt) } | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A Retry-After of 0, or a date already past, falls back to the exponential wait rather than retrying at once, so a server that says "now" still gets a pause. |
||
| [int][Math]::Min($delay, 60) | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Capped at a minute: Graph has been seen to send Retry-After values of several minutes for a tenant-wide throttle, and a pre-flight that sits that long should fail and say so rather than appear hung. |
||
| } | ||
|
|
||
| function Invoke-Once { | ||
| param([hashtable]$Splat) | ||
| $attempt = 0 | ||
| while ($true) { | ||
| try { | ||
| return Invoke-MgGraphRequest @Splat | ||
| } | ||
| catch { | ||
| $attempt++ | ||
| $delay = if ($attempt -le 3) { Get-RetryDelay -ErrorRecord $_ -Attempt $attempt } else { $null } | ||
| if ($null -eq $delay) { throw } | ||
| Write-Verbose ("Graph answered $($_.Exception.Message); retry $attempt of 3 in $delay s: " + | ||
| $Splat.Uri) | ||
| Start-Sleep -Seconds $delay | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (-not $All) { return Invoke-MgGraphRequest @requestSplat } | ||
| if (-not $All) { return Invoke-Once -Splat $requestSplat } | ||
|
|
||
| $items = [System.Collections.Generic.List[object]]::new() | ||
| $next = $Uri | ||
| while ($next) { | ||
| $requestSplat.Uri = $next | ||
| $page = Invoke-MgGraphRequest @requestSplat | ||
| $page = Invoke-Once -Splat $requestSplat | ||
| if ($page.value) { $items.AddRange([object[]]$page.value) } | ||
| $next = $page.'@odata.nextLink' | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -129,10 +129,15 @@ function Test-IntuneDeployedScript { | |
| $isDeviceGroup = $false | ||
| if (-not $SkipGroupLookup -and -not $groupState.Blocked) { | ||
| try { | ||
| $members = Invoke-IslGraphRequest -Uri "/v1.0/groups/$GroupId/members?`$select=id&`$top=20" | ||
| $types = @($members.value | ForEach-Object { "$($_.'@odata.type')" }) | ||
| $others = @($types | Where-Object { $_ -ne '#microsoft.graph.device' }) | ||
| $isDeviceGroup = $types.Count -gt 0 -and $others.Count -eq 0 | ||
| # Two counts, every member and the devices among them, rather than a page of | ||
| # members: a page of 20 decided for the whole group, and a mixed group whose first | ||
| # 20 members were devices passed as a device group. $count needs the eventual | ||
| # consistency header and answers with the number as text (dev tenant, 2026-10-06) | ||
| $countSplat = @{ Headers = @{ ConsistencyLevel = 'eventual' } } | ||
| $total = [int](Invoke-IslGraphRequest -Uri "/v1.0/groups/$GroupId/members/`$count" @countSplat) | ||
| $deviceUri = "/v1.0/groups/$GroupId/members/microsoft.graph.device/`$count" | ||
| $devices = [int](Invoke-IslGraphRequest -Uri $deviceUri @countSplat) | ||
| $isDeviceGroup = $total -gt 0 -and $devices -eq $total | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Direct members only, as before: a nested group counts as a non-device member, so a group of groups is not a device group here even when every nested group holds devices. transitiveMembers would change what the finding means and was not measured. |
||
| } | ||
| catch { | ||
| $groupState.Blocked = $true | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,11 +12,34 @@ BeforeAll { | |
| if (-not (Get-Command Invoke-MgGraphRequest -ErrorAction SilentlyContinue)) { | ||
| # The parameters exist so the mocks' parameter filters can bind to them | ||
| function global:Invoke-MgGraphRequest { | ||
| param($Method, $Uri, $Body, $ErrorAction) | ||
| $null = $Method, $Uri, $Body, $ErrorAction | ||
| param($Method, $Uri, $Body, $Headers, $ErrorAction) | ||
| $null = $Method, $Uri, $Body, $Headers, $ErrorAction | ||
| } | ||
| $script:StubCreated = $true | ||
| } | ||
| # What Invoke-MgGraphRequest throws, as far as the seam reads it: a StatusCode and a Response | ||
| # whose headers may carry Retry-After. Windows PowerShell 5.1 loads System.Net.Http on demand | ||
| Add-Type -AssemblyName System.Net.Http -ErrorAction SilentlyContinue | ||
| function script:New-GraphFailure { | ||
| param([int]$StatusCode, [int]$RetryAfterSeconds) | ||
| # .NET Framework's HttpStatusCode has no name for 429; the enum takes the number all the same | ||
| $code = [System.Enum]::ToObject([System.Net.HttpStatusCode], $StatusCode) | ||
| $response = [System.Net.Http.HttpResponseMessage]::new($code) | ||
| if ($RetryAfterSeconds) { | ||
| $delta = [timespan]::FromSeconds($RetryAfterSeconds) | ||
| $response.Headers.RetryAfter = [System.Net.Http.Headers.RetryConditionHeaderValue]::new($delta) | ||
| } | ||
| $failure = [FakeGraphException]::new("Response status code does not indicate success: $StatusCode") | ||
| $failure.StatusCode = $code | ||
| $failure.Response = $response | ||
| $failure | ||
| } | ||
| } | ||
|
|
||
| class FakeGraphException : System.Exception { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Untyped properties on purpose: a [System.Net.Http.HttpResponseMessage] type on the property would have to resolve when the file is parsed, and on Windows PowerShell 5.1 System.Net.Http is not loaded until Add-Type runs in BeforeAll. |
||
| $StatusCode | ||
| $Response | ||
| FakeGraphException([string]$message) : base($message) { } | ||
| } | ||
|
|
||
| AfterAll { | ||
|
|
@@ -58,4 +81,83 @@ Describe 'Invoke-IslGraphRequest' -Tag 'Unit', 'Private' { | |
| Mock Invoke-MgGraphRequest -ModuleName IntuneScriptLab { @{ value = @() } } | ||
| @(InModuleScope IntuneScriptLab { Invoke-IslGraphRequest -Uri '/beta/things' -All }).Count | Should-Be 0 | ||
| } | ||
|
|
||
| It 'passes request headers through' { | ||
| Mock Invoke-MgGraphRequest -ModuleName IntuneScriptLab { '17' } | ||
| $reply = InModuleScope IntuneScriptLab { | ||
| Invoke-IslGraphRequest -Uri '/v1.0/groups/g/members/$count' -Headers @{ ConsistencyLevel = 'eventual' } | ||
| } | ||
| $reply | Should-Be '17' | ||
| Should-Invoke Invoke-MgGraphRequest -ModuleName IntuneScriptLab -Times 1 -Exactly -ParameterFilter { | ||
| $Headers.ConsistencyLevel -eq 'eventual' | ||
| } | ||
| } | ||
|
|
||
| Context 'Retry' { | ||
| BeforeEach { | ||
| Mock Start-Sleep -ModuleName IntuneScriptLab { } | ||
| $script:Failures = [System.Collections.Generic.List[object]]::new() | ||
| # Throws the next queued failure, or returns the answer given | ||
| function script:Invoke-NextFailure { | ||
| param($Answer) | ||
| if ($script:Failures.Count) { | ||
| $next = $script:Failures[0] | ||
| $script:Failures.RemoveAt(0) | ||
| throw $next | ||
| } | ||
| $Answer | ||
| } | ||
| } | ||
|
|
||
| It 'sends a throttled request again after the Retry-After seconds, then returns the answer' { | ||
| $script:Failures.Add((New-GraphFailure -StatusCode 429 -RetryAfterSeconds 7)) | ||
| $script:Failures.Add((New-GraphFailure -StatusCode 429 -RetryAfterSeconds 3)) | ||
| Mock Invoke-MgGraphRequest -ModuleName IntuneScriptLab { Invoke-NextFailure -Answer @{ id = 'x' } } | ||
| $reply = InModuleScope IntuneScriptLab { Invoke-IslGraphRequest -Uri '/beta/things/x' } | ||
| $reply.id | Should-Be 'x' | ||
| Should-Invoke Invoke-MgGraphRequest -ModuleName IntuneScriptLab -Times 3 -Exactly | ||
| $sleepSplat = @{ ModuleName = 'IntuneScriptLab'; Times = 1; Exactly = $true } | ||
| Should-Invoke Start-Sleep @sleepSplat -ParameterFilter { $Seconds -eq 7 } | ||
| Should-Invoke Start-Sleep @sleepSplat -ParameterFilter { $Seconds -eq 3 } | ||
| } | ||
|
|
||
| It 'backs off 2, 4 and 8 seconds for <Status> without Retry-After, then throws the fourth' -ForEach @( | ||
| @{ Status = 503 } | ||
| @{ Status = 504 } | ||
| ) { | ||
| 1..4 | ForEach-Object { $script:Failures.Add((New-GraphFailure -StatusCode $Status)) } | ||
| Mock Invoke-MgGraphRequest -ModuleName IntuneScriptLab { Invoke-NextFailure } | ||
| { InModuleScope IntuneScriptLab { Invoke-IslGraphRequest -Uri '/beta/things' } } | | ||
| Should-Throw -ExceptionMessage "*$Status*" | ||
| Should-Invoke Invoke-MgGraphRequest -ModuleName IntuneScriptLab -Times 4 -Exactly | ||
| foreach ($seconds in 2, 4, 8) { | ||
| $expected = $seconds | ||
| Should-Invoke Start-Sleep -ModuleName IntuneScriptLab -Times 1 -Exactly -ParameterFilter { | ||
| $Seconds -eq $expected | ||
| } | ||
| } | ||
| } | ||
|
|
||
| It 'throws any other failure at once' { | ||
| $script:Failures.Add((New-GraphFailure -StatusCode 404)) | ||
| Mock Invoke-MgGraphRequest -ModuleName IntuneScriptLab { Invoke-NextFailure } | ||
| { InModuleScope IntuneScriptLab { Invoke-IslGraphRequest -Uri '/beta/things/missing' } } | | ||
| Should-Throw -ExceptionMessage '*404*' | ||
| Should-Invoke Invoke-MgGraphRequest -ModuleName IntuneScriptLab -Times 1 -Exactly | ||
| Should-Invoke Start-Sleep -ModuleName IntuneScriptLab -Times 0 -Exactly | ||
| } | ||
|
|
||
| It 'retries a page in the middle of -All and keeps every item' { | ||
| $script:Failures.Add((New-GraphFailure -StatusCode 429 -RetryAfterSeconds 1)) | ||
| Mock Invoke-MgGraphRequest -ModuleName IntuneScriptLab { | ||
| if ($Uri -like '*skiptoken*') { Invoke-NextFailure -Answer @{ value = @(@{ id = 3 }) } } | ||
| else { @{ value = @(@{ id = 1 }, @{ id = 2 }); '@odata.nextLink' = "$Uri&`$skiptoken=abc" } } | ||
| } | ||
| $items = @(InModuleScope IntuneScriptLab { | ||
| Invoke-IslGraphRequest -Uri '/beta/things?$select=id' -All | ||
| }) | ||
| $items.id | Should-BeCollection @(1, 2, 3) | ||
| Should-Invoke Invoke-MgGraphRequest -ModuleName IntuneScriptLab -Times 3 -Exactly | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
502 is left out on purpose: Graph documents 429, 503 and 504 as the codes to retry, and a 502 from the gateway has not been seen to clear on a retry. 401 and 403 are never retried; a new token is Connect-MgGraph's job.