Public/Retry/Invoke-WithExitCodeRetry.ps1

<#
.NOTES
    Dot-sourced by Common.PowerShell.psm1. Exit-code sibling of
    Invoke-WithRetry: the same backoff-strategy machinery, but the retry
    decision keys off a native command's exit code rather than a thrown
    exception. Lives alongside Invoke-WithRetry under Public\Retry\.
#>


function Invoke-WithExitCodeRetry {
    <#
    .SYNOPSIS
        Runs a script block wrapping a native command and retries while its
        exit code is non-zero (or in a caller-supplied retryable set),
        pacing attempts with a backoff strategy.
 
    .DESCRIPTION
        The exit-code counterpart to Invoke-WithRetry. Native commands
        (netsh, git, docker, wsl, ...) signal failure through
        $LASTEXITCODE, not exceptions, so Invoke-WithRetry's ShouldRetry
        predicates - which inspect a caught error - cannot classify them.
        This loop reads $LASTEXITCODE after each attempt instead, and
        reuses the same backoff strategies (@{ Name; GetDelay }) so a
        single backoff family covers both loops.
 
        Contract: the script block's FINAL statement must be the native
        command whose exit code matters - $LASTEXITCODE is read
        immediately after the block returns. A block whose last statement
        is a cmdlet leaves $LASTEXITCODE reflecting some earlier native
        call (or unset), which this loop cannot detect. Thrown exceptions
        are NOT retried here; they propagate (use Invoke-WithRetry for
        exception-classified retry).
 
    .PARAMETER ScriptBlock
        The work to attempt. Its pipeline output is the function's return
        value on success (exit 0).
 
    .PARAMETER BackoffStrategy
        A single backoff hashtable @{ Name; GetDelay }. Defaults to
        New-ExponentialBackoffStrategy. GetDelay is invoked with
        (attempt, $null): an exit-code failure has no ErrorRecord to pass,
        and the shared backoff strategies type their second parameter as
        [ErrorRecord] and key off the attempt number only - so the very
        same strategies that pair with Invoke-WithRetry work here too.
 
    .PARAMETER MaxAttempts
        Total attempts including the first. Defaults to 3. Pass 1 to
        disable retry.
 
    .PARAMETER RetryableExitCode
        Exit codes that count as retryable. Empty (the default) means any
        non-zero exit is retryable - the common case. Pass an explicit set
        to retry only known-transient codes and fail fast on the rest.
 
        This is deliberately a data-driven code set, not a predicate: an
        exit-code set is still "retry on the exit code". For retriability a
        set cannot express - "retry all except these permanent codes",
        ranges, classification over stderr - do not generalise this
        function; throw inside the script block and use Invoke-WithRetry,
        whose ShouldRetry strategies are the home for predicate-based
        classification.
 
    .PARAMETER OperationName
        Label surfaced in the per-retry warning and the failure message.
        Defaults to 'native command'.
 
    .EXAMPLE
        Invoke-WithExitCodeRetry `
            -OperationName 'netsh portproxy add' `
            -ScriptBlock {
                & netsh interface portproxy add v4tov4 `
                    listenaddress=0.0.0.0 listenport=2222 `
                    connectaddress=192.168.137.10 connectport=22 | Out-Null
            }
    #>

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

        [hashtable] $BackoffStrategy,

        [int] $MaxAttempts = 3,

        [int[]] $RetryableExitCode = @(),

        [string] $OperationName = 'native command'
    )

    # Default the backoff lazily (mirrors Invoke-WithRetry) so callers that
    # pass an explicit strategy do not pay for the factory call, and the
    # parameter default stays free of an executable expression evaluated at
    # parameter-binding time.
    if (-not $BackoffStrategy) {
        $BackoffStrategy = New-ExponentialBackoffStrategy
    }
    Assert-RetryStrategyShape -Strategy $BackoffStrategy `
        -Kind 'Backoff' -ActionKey 'GetDelay'

    for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
        $output   = & $ScriptBlock
        $exitCode = $LASTEXITCODE

        if ($exitCode -eq 0) {
            return $output
        }

        # Empty RetryableExitCode => any non-zero is retryable. Otherwise
        # only the listed codes are; anything else fails fast so a genuine
        # error is not retried pointlessly.
        $isRetryable = ($RetryableExitCode.Count -eq 0) -or
                       ($RetryableExitCode -contains $exitCode)
        if (-not $isRetryable) {
            throw "$OperationName failed with non-retryable exit ${exitCode} on attempt $attempt of $MaxAttempts."
        }

        # Last attempt - surface the exit code rather than sleeping then
        # throwing; the underlying failure is what the caller needs to act on.
        if ($attempt -ge $MaxAttempts) {
            throw "$OperationName failed with exit ${exitCode} after $MaxAttempts attempts."
        }

        # GetDelay's second parameter is the ErrorRecord that caused the
        # retry; an exit-code failure has none, so pass $null. The shared
        # backoff strategies type that parameter as [ErrorRecord] and use
        # only $Attempt - passing the int exit code here would fail their
        # type coercion.
        $delay = & $BackoffStrategy.GetDelay $attempt $null

        Write-Warning (
            "$OperationName failed (attempt $attempt/$MaxAttempts, " +
            "exit ${exitCode}). Retrying in ${delay}s ..."
        )
        Start-Sleep -Seconds $delay
    }
}