Public/Invoke-ResilientGraphRest.ps1

<#
.SYNOPSIS
    Execute resilient HTTP requests against Microsoft Graph with exponential backoff and Retry-After parsing.
.DESCRIPTION
    Handles 429 (Too Many Requests), 503 (Service Unavailable), and 504 (Gateway Timeout) using
    exponential jittered backoff. Fully compatible with Windows PowerShell 5.1 and PowerShell 7+.
#>

function Invoke-ResilientGraphRest {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Uri,

        [Parameter()]
        [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')]
        [string]$Method = 'GET',

        [Parameter()]
        [hashtable]$Headers = @{},

        [Parameter()]
        [object]$Body,

        [Parameter()]
        [string]$ContentType = 'application/json',

        [Parameter()]
        [int]$MaxRetries = 5,

        [Parameter()]
        [int]$BaseDelaySeconds = 2
    )

    $retryCount = 0
    $success = $false
    $result = $null

    while (-not $success -and $retryCount -le $MaxRetries) {
        try {
            $requestParams = @{
                Uri         = $Uri
                Method      = $Method
                Headers     = $Headers
                ContentType = $ContentType
                ErrorAction = 'Stop'
            }

            if ($Body) {
                if ($Body -is [string] -or $Body -is [byte[]]) {
                    $requestParams['Body'] = $Body
                } else {
                    $requestParams['Body'] = ($Body | ConvertTo-Json -Depth 20 -Compress)
                }
            }

            $response = Invoke-RestMethod @requestParams
            $success = $true
            $result = $response
        }
        catch {
            $ex = $_.Exception
            $statusCode = 0
            $retryAfterSeconds = 0

            if ($ex.Response) {
                $statusCode = [int]$ex.Response.StatusCode
                
                # Parse Retry-After header
                if ($ex.Response.Headers -and $ex.Response.Headers['Retry-After']) {
                    $retryHeaderVal = $ex.Response.Headers['Retry-After']
                    $parsedSec = 0
                    if ([int]::TryParse($retryHeaderVal, [ref]$parsedSec)) {
                        $retryAfterSeconds = $parsedSec
                    }
                }
            }

            # Transient errors eligible for retry
            if ($statusCode -eq 429 -or $statusCode -ge 500 -or ($null -ne $ex.InnerException -and $ex.InnerException.Message -match 'timed out|connection closed')) {
                $retryCount++
                if ($retryCount -gt $MaxRetries) {
                    Write-Error "Graph API request failed after $MaxRetries retries. Status: $statusCode. Error: $($_.Exception.Message)"
                    throw $_
                }

                if ($retryAfterSeconds -le 0) {
                    $jitter = (Get-Random -Minimum 0 -Maximum 1000) / 1000.0
                    $calculatedDelay = [Math]::Pow(2, $retryCount) * $BaseDelaySeconds + $jitter
                    $retryAfterSeconds = [Math]::Min(30, [int][Math]::Ceiling($calculatedDelay))
                }

                Write-Warning "Microsoft Graph returned HTTP $statusCode (Throttled/Unavailable). Retrying in $retryAfterSeconds seconds (Attempt $retryCount of $MaxRetries)..."
                Start-Sleep -Seconds $retryAfterSeconds
            }
            else {
                # Extract detailed error payload from Graph response
                $errorDetails = $_.Exception.Message
                if ($ex.Response) {
                    try {
                        $stream = $ex.Response.GetResponseStream()
                        if ($stream) {
                            $reader = New-Object System.IO.StreamReader($stream)
                            $bodyText = $reader.ReadToEnd()
                            if ($bodyText) {
                                $errorJson = $bodyText | ConvertFrom-Json -ErrorAction SilentlyContinue
                                if ($errorJson -and $errorJson.error -and $errorJson.error.message) {
                                    $errorDetails = "$($errorJson.error.code): $($errorJson.error.message)"
                                } else {
                                    $errorDetails = $bodyText
                                }
                            }
                        }
                    } catch { }
                }
                Write-Error "Graph API request failed (HTTP $statusCode): $errorDetails"
                throw [System.InvalidOperationException]::new("Graph API Error ($statusCode): $errorDetails", $ex)
            }
        }
    }

    return $result
}