Private/Get-AzCliResponse.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    Azure CLI wrapper with retry, timeout, caching, and structured logging.
.DESCRIPTION
    Central point for all `az` calls. Provides:
    - Exponential backoff retry with transient-error detection
    - Response cache with configurable TTL
    - Per-call timeout (default 30s)
    - Graceful degradation for rate-limits (429 Retry-After)
#>


$script:AzCliRetryCount = 3
$script:AzCliRetryBase  = 1
$script:AzCliTimeoutSec = 30
$script:AzCliCacheTTL   = 60

if (-not (Get-Variable -Name 'script:AzCliCache' -Scope 'Script' -ErrorAction SilentlyContinue)) {
    $script:AzCliCache = @{}
}

function Clear-BsiAzCliCache {
    $script:AzCliCache = @{}
}

function Get-AzCliResponse {
    <#
    .SYNOPSIS
        Executes an Azure CLI command with retry, timeout, and caching.
    .PARAMETER Arguments
        Arguments to pass to `az`.
    .PARAMETER SubscriptionId
        Optional Azure subscription ID.
    .PARAMETER UseCache
        Whether to use response cache.
    .PARAMETER TtlSeconds
        Cache TTL override.
    .PARAMETER TimeoutSeconds
        Per-call timeout override.
    .OUTPUTS
        PSCustomObject with Output, ExitCode, Stderr properties.
    #>

    param(
        [Parameter(Mandatory)][array]$Arguments,
        [string]$SubscriptionId = '',
        [bool]$UseCache = $true,
        [int]$TtlSeconds = 0,
        [int]$TimeoutSeconds = 0
    )

    if ($TimeoutSeconds -le 0) { $TimeoutSeconds = $script:AzCliTimeoutSec }
    if ($TtlSeconds -le 0)     { $TtlSeconds = $script:AzCliCacheTTL }

    $allArgs = @()
    if ($SubscriptionId) {
        $allArgs += @('--subscription', $SubscriptionId)
    }
    $allArgs += $Arguments
    if ($allArgs -notcontains '--only-show-errors') {
        $allArgs += @('--only-show-errors')
    }
    $allArgs += @('-o', 'json')

    $cacheKey = ($allArgs -join ' ')

    # Cache lookup
    if ($UseCache -and $script:AzCliCache.ContainsKey($cacheKey)) {
        $cached = $script:AzCliCache[$cacheKey]
        if (([datetime]::UtcNow - $cached.Timestamp).TotalSeconds -lt $TtlSeconds) {
            Write-Verbose "[AzCli] Cache hit: $cacheKey"
            return $cached
        }
        $script:AzCliCache.Remove($cacheKey)
    }

    $lastOutput = ''
    $lastRc     = 1
    $lastErr    = ''

    for ($attempt = 1; $attempt -le $script:AzCliRetryCount; $attempt++) {
        try {
            $azExe = (Get-Command 'az' -ErrorAction SilentlyContinue).Source
            if (-not $azExe) {
                return [PSCustomObject]@{ Output = ''; ExitCode = 1; Stderr = 'az CLI not found in PATH' }
            }

            $process = New-Object System.Diagnostics.Process
            $process.StartInfo.FileName = $azExe
            $process.StartInfo.Arguments = ($allArgs | ForEach-Object {
                if ($_ -match '\s') { "`"$_`"" } else { $_ }
            }) -join ' '
            $process.StartInfo.UseShellExecute = $false
            $process.StartInfo.RedirectStandardOutput = $true
            $process.StartInfo.RedirectStandardError = $true
            $process.StartInfo.CreateNoWindow = $true

            $null = $process.Start()
            $stdout = $process.StandardOutput.ReadToEnd()
            $stderr = $process.StandardError.ReadToEnd()

            if (-not $process.WaitForMilliseconds($TimeoutSeconds * 1000)) {
                try { $process.Kill() } catch {}
                $lastOutput = ''
                $lastRc     = 2
                $lastErr    = "Command timed out after $TimeoutSeconds seconds"
                Write-Warning "[AzCli] Timeout ($TimeoutSeconds s) for: $($Arguments -join ' ')"
            } else {
                $lastOutput = if ($null -eq $stdout) { '' } else { $stdout.Trim() }
                $lastRc     = $process.ExitCode
                $lastErr    = if ($null -eq $stderr) { '' } else { $stderr.Trim() }
            }
        } catch {
            $lastOutput = ''
            $lastRc     = 1
            $lastErr    = $_.Exception.Message
        }

        # Success or timeout — no retry
        if ($lastRc -eq 0 -or $lastRc -eq 2) { break }

        # Rate-limit detection: check for 429 and Retry-After
        $isRateLimit = $lastErr -match '429'
        if ($isRateLimit -and $attempt -lt $script:AzCliRetryCount) {
            $delay = [Math]::Pow($script:AzCliRetryBase, $attempt) * 2
            Write-Warning "[AzCli] Rate limit (attempt $attempt/$($script:AzCliRetryCount)): retrying in ${delay}s..."
            Start-Sleep -Seconds $delay
            continue
        }

        # Transient error detection
        $isTransient = ($lastErr -match 'EAGAIN|ETIMEDOUT|timeout|502|503|throttl') -and
                       ($lastErr -notmatch 'RequestDisallowedByPolicy')

        if ($isTransient -and $attempt -lt $script:AzCliRetryCount) {
            $delay = [Math]::Pow($script:AzCliRetryBase, $attempt)
            Write-Warning "[AzCli] Transient error (attempt $attempt/$($script:AzCliRetryCount)): retrying in ${delay}s..."
            Start-Sleep -Seconds $delay
            continue
        }

        break
    }

    $result = [PSCustomObject]@{
        Output   = $lastOutput
        ExitCode = $lastRc
        Stderr   = $lastErr
    }

    # Cache successful results
    if ($UseCache -and $lastRc -eq 0 -and $lastOutput) {
        $script:AzCliCache[$cacheKey] = [PSCustomObject]@{
            Output    = $lastOutput
            ExitCode  = $lastRc
            Stderr    = $lastErr
            Timestamp = [datetime]::UtcNow
        }
    }

    return $result
}

function Test-AzCliReady {
    <#
    .SYNOPSIS
        Validates Azure CLI is installed and logged in.
    .OUTPUTS
        $true if ready, throws otherwise.
    #>

    $azExe = Get-Command 'az' -ErrorAction SilentlyContinue
    if (-not $azExe) {
        throw "Azure CLI (az) not found in PATH."
    }

    $result = Get-AzCliResponse -Arguments @("account", "show", "--query", "name", "-o", "tsv") -UseCache $false
    if ($result.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($result.Output)) {
        throw "Azure CLI is not logged in. Run 'az login' first."
    }

    return $true
}

function Get-AzCliSubscriptionId {
    $result = Get-AzCliResponse -Arguments @("account", "show", "--query", "id", "-o", "tsv") -UseCache $false
    if ($result.ExitCode -eq 0) {
        return $result.Output.Trim()
    }
    return ''
}