Private/Invoke-UTCMGraphRequest.ps1

function Invoke-UTCMGraphRequest {
    <#
    .SYNOPSIS
        Core helper — sends an authenticated request to Microsoft Graph
        and handles pagination and throttling automatically.

    .DESCRIPTION
        Wraps Invoke-RestMethod with:
        - Automatic retry on HTTP 429 (Too Many Requests) and 503/504
          with exponential back-off, honouring the Retry-After header.
        - Automatic pagination via @odata.nextLink.
        - JSON body serialisation.

    .PARAMETER MaxRetries
        Maximum number of retries on throttling errors (default: 3).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Uri,
        [ValidateSet('GET','POST','PATCH','DELETE')][string]$Method = 'GET',
        [object]$Body,
        [switch]$Raw,          # return entire response object, not just .value
        [int]$MaxRetries = 3   # retry limit for 429 / 503 / 504
    )

    $headers = Get-UTCMAuthHeaders
    $params  = @{
        Uri     = $Uri
        Method  = $Method
        Headers = $headers
    }
    if ($Body) {
        $params.Body = if ($Body -is [string]) { $Body } else { $Body | ConvertTo-Json -Depth 20 }
    }

    # ── Execute with retry logic ─────────────────────────────────────
    $attempt  = 0
    $response = $null
    
    Write-Verbose "[UTCM] $Method $Uri"
    if ($Body -and $PSCmdlet.MyInvocation.BoundParameters['Verbose']) {
        Write-Verbose "[UTCM] Request body: $($params.Body)"
    }
    
    while ($true) {
        try {
            $response = Invoke-RestMethod @params
            Write-Verbose "[UTCM] Request completed successfully"
            break   # success — exit retry loop
        }
        catch {
            $statusCode = $null
            $errorMessage = $_.Exception.Message
            $errorDetails = $null
            
            if ($_.Exception.Response) {
                $statusCode = [int]$_.Exception.Response.StatusCode
            }
            
            # Try to extract detailed error from Graph API response
            if ($_.ErrorDetails.Message) {
                try {
                    $errorObj = $_.ErrorDetails.Message | ConvertFrom-Json
                    if ($errorObj.error) {
                        $errorDetails = "$($errorObj.error.code): $($errorObj.error.message)"
                    }
                } catch {
                    $errorDetails = $_.ErrorDetails.Message
                }
            }

            $retryable = $statusCode -in @(429, 503, 504)
            $attempt++

            if ($retryable -and $attempt -le $MaxRetries) {
                # Determine wait time: prefer Retry-After header, fall back to exponential back-off
                $retryAfter = $null
                if ($_.Exception.Response.Headers) {
                    try {
                        $raHeader = $_.Exception.Response.Headers |
                            Where-Object { $_.Key -eq 'Retry-After' } |
                            Select-Object -ExpandProperty Value -First 1
                        if ($raHeader) { $retryAfter = [int]$raHeader }
                    } catch { }
                }
                if (-not $retryAfter -or $retryAfter -le 0) {
                    $retryAfter = [math]::Pow(2, $attempt)   # 2, 4, 8 seconds
                }

                Write-Warning "[UTCM] HTTP $statusCode on $Method $Uri — retrying in ${retryAfter}s (attempt $attempt/$MaxRetries)"
                Start-Sleep -Seconds $retryAfter
                continue
            }

            # Non-retryable or retries exhausted
            $fullError = if ($errorDetails) { $errorDetails } else { $errorMessage }
            $errorMsg = "Graph API request failed [$Method $Uri]"
            if ($statusCode) { $errorMsg += " (HTTP $statusCode)" }
            $errorMsg += ": $fullError"
            
            Write-Error $errorMsg
            throw
        }
    }

    if ($Raw) { return $response }

    # ── Handle paginated collections ─────────────────────────────────
    $results = @()
    if ($null -ne $response.value) {
        $results += $response.value
        while ($response.'@odata.nextLink') {
            # Pagination requests also get retry logic
            $pageAttempt = 0
            while ($true) {
                try {
                    $response = Invoke-RestMethod -Uri $response.'@odata.nextLink' -Headers $headers
                    break
                }
                catch {
                    $statusCode = $null
                    $errorMessage = $_.Exception.Message
                    $errorDetails = $null
                    
                    if ($_.Exception.Response) {
                        $statusCode = [int]$_.Exception.Response.StatusCode
                    }
                    
                    # Try to extract detailed error from Graph API response
                    if ($_.ErrorDetails.Message) {
                        try {
                            $errorObj = $_.ErrorDetails.Message | ConvertFrom-Json
                            if ($errorObj.error) {
                                $errorDetails = "$($errorObj.error.code): $($errorObj.error.message)"
                            }
                        } catch {
                            $errorDetails = $_.ErrorDetails.Message
                        }
                    }
                    
                    $pageAttempt++
                    $retryable = $statusCode -in @(429, 503, 504)

                    if ($retryable -and $pageAttempt -le $MaxRetries) {
                        $retryAfter = $null
                        if ($_.Exception.Response.Headers) {
                            try {
                                $raHeader = $_.Exception.Response.Headers |
                                    Where-Object { $_.Key -eq 'Retry-After' } |
                                    Select-Object -ExpandProperty Value -First 1
                                if ($raHeader) { $retryAfter = [int]$raHeader }
                            } catch { }
                        }
                        if (-not $retryAfter -or $retryAfter -le 0) {
                            $retryAfter = [math]::Pow(2, $pageAttempt)
                        }
                        Write-Warning "[UTCM] HTTP $statusCode during pagination — retrying in ${retryAfter}s (attempt $pageAttempt/$MaxRetries)"
                        Start-Sleep -Seconds $retryAfter
                        continue
                    }

                    $fullError = if ($errorDetails) { $errorDetails } else { $errorMessage }
                    $errorMsg = "Graph API pagination failed"
                    if ($statusCode) { $errorMsg += " (HTTP $statusCode)" }
                    $errorMsg += ": $fullError"
                    
                    Write-Error $errorMsg
                    throw
                }
            }
            $results += $response.value
        }
        return $results
    }

    return $response
}