Private/Invoke-WithRetry.ps1

function Invoke-WithRetry {
    <#
    .SYNOPSIS
        Executes a script block with exponential backoff retry logic.
    .DESCRIPTION
        Wraps Azure API calls to handle transient failures and throttling (HTTP 429).
        Retries up to MaxRetries times with exponential backoff between attempts.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [scriptblock]$ScriptBlock,

        [Parameter()]
        [int]$MaxRetries = $(if ($script:CISConfig.MaxRetries) { $script:CISConfig.MaxRetries } else { 3 }),

        [Parameter()]
        [int]$BaseDelayMs = $(if ($script:CISConfig.RetryBaseDelayMs) { $script:CISConfig.RetryBaseDelayMs } else { 1000 }),

        [Parameter()]
        [int]$MaxDelayMs = 30000,

        [Parameter()]
        [string]$OperationName = 'Azure API call'
    )

    $attempt = 1
    $lastError = $null

    while ($attempt -le $MaxRetries) {
        try {
            return (& $ScriptBlock)
        }
        catch {
            $lastError = $_

            if ($attempt -ge $MaxRetries) {
                break
            }

            # Check if this is a retryable error (throttling, transient)
            $isRetryable = $false
            $errorMsg = $_.Exception.Message

            if ($errorMsg -match '\b429\b|throttl|too many requests|service unavailable|\b503\b|\b504\b|\btimeout\b|(?<!non-)\btransient\b') {
                $isRetryable = $true
            }
            # Retry on generic network/HTTP errors
            if ($_.Exception.GetType().Name -match 'HttpRequestException|WebException|TaskCanceledException') {
                $isRetryable = $true
            }

            if (-not $isRetryable) {
                # Non-retryable error — throw immediately
                throw
            }

            $baseDelay = $BaseDelayMs * [math]::Pow(2, ($attempt - 1))
            $jitter = Get-Random -Maximum ([int]($baseDelay * 0.3))
            $delayMs = [math]::Min($baseDelay + $jitter, $MaxDelayMs)
            Write-Verbose "$OperationName failed (attempt $attempt/$MaxRetries): $errorMsg. Retrying in $($delayMs)ms..."
            Start-Sleep -Milliseconds $delayMs
            $attempt++
        }
    }

    # All retries exhausted
    throw $lastError
}