Private/Invoke-GraphRequestWithRetry.ps1

function Invoke-GraphRequestWithRetry {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][ValidateSet('GET','POST','PATCH','DELETE')][string]$Method,
        [Parameter(Mandatory)][string]$Uri,
        $Body = $null,
        [int]$MaxRetries = 5
    )

    $attempt = 0
    while ($attempt -lt $MaxRetries) {
        try {
            $attempt++
            if ($null -ne $Body) {
                # Pass the body as a hashtable — let Invoke-MgGraphRequest handle JSON
                # serialisation. Pre-serialising with ConvertTo-Json can cause double-
                # encoding in some SDK versions.
                return Invoke-MgGraphRequest -Method $Method -Uri $Uri `
                    -Body $Body -ContentType 'application/json'
            } else {
                return Invoke-MgGraphRequest -Method $Method -Uri $Uri
            }
        } catch {
            $ex = $_.Exception
            $message = $ex.Message

            # ── Try to extract the Graph API error detail ──
            # Invoke-MgGraphRequest puts the response body in multiple places
            # depending on SDK version. We check them all.
            $graphErrorDetail = $null
            try {
                # 1. PowerShell ErrorRecord.ErrorDetails.Message (most reliable)
                if (-not $graphErrorDetail -and $_.ErrorDetails -and $_.ErrorDetails.Message) {
                    $graphErrorDetail = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue
                }
                # 2. Exception.Response (HttpResponseMessage) — read body stream
                if (-not $graphErrorDetail -and $ex.Response) {
                    $httpResp = $ex.Response
                    if ($httpResp.Content) {
                        $bodyText = $httpResp.Content.ReadAsStringAsync().GetAwaiter().GetResult()
                        if ($bodyText) {
                            $graphErrorDetail = $bodyText | ConvertFrom-Json -ErrorAction SilentlyContinue
                        }
                    }
                }
                # 3. SDK v2+ may expose .ResponseBody directly
                if (-not $graphErrorDetail -and $ex.PSObject.Properties.Name -contains 'ResponseBody') {
                    $graphErrorDetail = $ex.ResponseBody | ConvertFrom-Json -ErrorAction SilentlyContinue
                }
                # 4. InnerException chain
                if (-not $graphErrorDetail -and $ex.InnerException) {
                    $inner = $ex.InnerException
                    if ($inner.PSObject.Properties.Name -contains 'ResponseBody') {
                        $graphErrorDetail = $inner.ResponseBody | ConvertFrom-Json -ErrorAction SilentlyContinue
                    }
                }
            } catch { <# ignore extraction failures #> }

            if ($graphErrorDetail) {
                $detailMsg  = $graphErrorDetail.error.message  ?? ($graphErrorDetail | ConvertTo-Json -Depth 5 -Compress)
                $detailCode = $graphErrorDetail.error.code     ?? 'unknown'
                $message = "$message [Graph error code=$detailCode detail=$detailMsg]"
            } else {
                # Last resort: dump raw ErrorDetails string if it exists but wasn't valid JSON
                if ($_.ErrorDetails -and $_.ErrorDetails.Message) {
                    $message = "$message [Raw error detail: $($_.ErrorDetails.Message)]"
                }
            }

            # ── Determine HTTP status code ──
            $statusCode = 0
            try {
                if ($ex.PSObject.Properties.Name -contains 'ResponseStatusCode') {
                    $statusCode = [int]$ex.ResponseStatusCode
                } elseif ($_.Exception.Response -and $_.Exception.Response.StatusCode) {
                    $statusCode = [int]$_.Exception.Response.StatusCode
                }
            } catch { <# ignore #> }

            # ── Check for throttling (429/503) ──
            if ($statusCode -in @(429, 503)) {
                $retryAfter = 0
                try {
                    $retryAfter = [int]$_.Exception.Response.Headers['Retry-After']
                } catch { $retryAfter = 0 }

                if ($retryAfter -le 0) {
                    $retryAfter = [math]::Min(60, [math]::Pow(2, $attempt))
                }

                Write-Log -Message "Graph throttled/temporarily unavailable (attempt $attempt). Retrying in $retryAfter sec..." -Color Yellow
                Start-Sleep -Seconds $retryAfter
                continue
            }

            # ── Client errors (4xx except 429) are NOT transient — fail immediately ──
            if ($statusCode -ge 400 -and $statusCode -lt 500) {
                throw "Graph request failed with HTTP $statusCode. Method=$Method Uri=$Uri Error: $message"
            }

            # ── Exhausted retries ──
            if ($attempt -ge $MaxRetries) {
                throw "Graph request failed after $MaxRetries attempts. Method=$Method Uri=$Uri Error: $message"
            }

            # ── Server/transient error — retry with exponential backoff ──
            $delay = [math]::Min(30, [math]::Pow(2, $attempt))
            Write-Log -Message "Transient error (attempt $attempt): $message. Retrying in $delay sec..." -Color Yellow
            Start-Sleep -Seconds $delay
        }
    }
}