Private/Invoke-TLGraphRequest.ps1

function Invoke-TLGraphRequest {
    <#
    .SYNOPSIS
        Wrapper around Invoke-MgGraphRequest with full paging and throttling support.
    .DESCRIPTION
        Central Graph request function used by every collector. Follows
        @odata.nextLink when -All is set, honors Retry-After on HTTP 429 and
        retries transient 5xx responses with exponential backoff.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Uri,

        [ValidateSet('GET', 'POST')]
        [string]$Method = 'GET',

        [object]$Body,

        # Follow @odata.nextLink and return the merged item list.
        [switch]$All,

        [ValidateRange(0, 10)]
        [int]$MaxRetries = 5
    )

    $items = [System.Collections.Generic.List[object]]::new()
    $nextUri = $Uri

    while ($nextUri) {
        $attempt = 0
        $response = $null

        while ($true) {
            try {
                $request = @{
                    Method      = $Method
                    Uri         = $nextUri
                    OutputType  = 'PSObject'
                    ErrorAction = 'Stop'
                }
                if ($null -ne $Body) {
                    $request['Body'] = ConvertTo-Json -InputObject $Body -Depth 20
                    $request['ContentType'] = 'application/json'
                }
                $response = Invoke-MgGraphRequest @request
                break
            }
            catch {
                $exception = $_.Exception
                $statusCode = $null
                if ($exception.PSObject.Properties['ResponseStatusCode'] -and $exception.ResponseStatusCode) {
                    $statusCode = [int]$exception.ResponseStatusCode
                }
                elseif ($exception.PSObject.Properties['Response'] -and $exception.Response -and
                    $exception.Response.PSObject.Properties['StatusCode']) {
                    $statusCode = [int]$exception.Response.StatusCode
                }

                $retryable = ($statusCode -eq 429) -or ($statusCode -ge 500 -and $statusCode -le 599)
                if (-not $retryable -or $attempt -ge $MaxRetries) { throw }

                # Exponential backoff, overridden by an explicit Retry-After header.
                $delaySeconds = [Math]::Min(60, [Math]::Pow(2, $attempt + 1))
                $candidates = @($exception)
                if ($exception.PSObject.Properties['Response'] -and $exception.Response) {
                    $candidates += $exception.Response
                }
                foreach ($candidate in $candidates) {
                    if (-not ($candidate.PSObject.Properties['Headers'] -and $candidate.Headers)) { continue }
                    try {
                        $values = @($candidate.Headers.GetValues('Retry-After'))
                        if ($values.Count -gt 0) { $delaySeconds = [double]$values[0]; break }
                    }
                    catch {
                        Write-Debug 'No Retry-After header on this candidate.'
                    }
                }

                Write-Verbose ("Graph returned HTTP {0} for '{1}'; retrying in {2}s (attempt {3}/{4})." -f `
                        $statusCode, $nextUri, $delaySeconds, ($attempt + 1), $MaxRetries)
                Start-Sleep -Seconds $delaySeconds
                $attempt++
            }
        }

        if ($null -ne $response -and $response.PSObject.Properties['value']) {
            foreach ($item in @($response.value)) { $items.Add($item) }
            $nextUri = $null
            if ($All -and $response.PSObject.Properties['@odata.nextLink'] -and $response.'@odata.nextLink') {
                $nextUri = $response.'@odata.nextLink'
            }
        }
        else {
            if (-not $All) { return $response }
            if ($null -ne $response) { $items.Add($response) }
            $nextUri = $null
        }
    }

    # Emit items to the pipeline; callers collect with @(...).
    return $items
}