Public/Retry/Invoke-WithRetry.ps1

<#
.NOTES
    Dot-sourced by Infrastructure.Common.psm1. Generic retry loop that
    consumes hashtable-shaped retry and backoff strategies (see the
    factories under Public/Retry/TransientErrorStrategies/ and
    Public/Retry/BackoffStrategies/). Strategy-shape validation lives in
    the sibling Assert-RetryStrategyShape.ps1 (file-private helper).
#>


function Invoke-WithRetry {
    <#
    .SYNOPSIS
        Runs a script block and retries on failures matched by one or more
        retry strategies, sleeping between attempts according to a backoff
        strategy.
 
    .DESCRIPTION
        Generic retry primitive. The classification of "what counts as
        retryable" is supplied by hashtable-shaped retry strategies
        (see New-TransientNetworkRetryStrategy, New-FileLockRetryStrategy)
        and the inter-attempt pacing by a backoff strategy
        (see New-ExponentialBackoffStrategy and friends).
 
        Multiple retry strategies are OR-composed: if any of their
        ShouldRetry predicates returns $true the loop retries; if none
        match the failure propagates immediately. This lets a single call
        site cover several legitimately-transient failure classes (e.g.
        network plus file-lock) without authoring a bespoke classifier.
 
        -BackoffStrategy defaults to New-ExponentialBackoffStrategy
        (2s -> 4s -> 8s, capped at 30s) because that policy fits both
        currently known call sites (HTTP + file-lock). Callers wanting
        a different curve pass one explicitly.
 
    .PARAMETER ScriptBlock
        The work to attempt. Its return value is the function's return
        value on success.
 
    .PARAMETER RetryStrategy
        One or more strategy hashtables of shape
        @{ Name = <string>; ShouldRetry = <scriptblock> }. Mandatory so a
        missing argument cannot silently mean "never retry".
 
    .PARAMETER BackoffStrategy
        A single backoff hashtable of shape
        @{ Name = <string>; GetDelay = <scriptblock> }. Defaults to
        New-ExponentialBackoffStrategy.
 
    .PARAMETER MaxAttempts
        Total attempts including the first. Defaults to 3. Pass 1 to
        disable retry entirely (handy in tests with deterministic
        failures).
 
    .PARAMETER OperationName
        Label surfaced in the per-retry warning. Defaults to 'operation'.
 
    .EXAMPLE
        Invoke-WithRetry `
            -OperationName 'Adoptium release lookup' `
            -RetryStrategy (New-TransientNetworkRetryStrategy) `
            -ScriptBlock { Invoke-RestMethod $uri }
 
    .EXAMPLE
        Invoke-WithRetry `
            -OperationName 'delete VHDX' `
            -RetryStrategy (New-FileLockRetryStrategy) `
            -MaxAttempts 5 `
            -ScriptBlock { Remove-Item $vhdxPath -Force -ErrorAction Stop }
    #>

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

        [Parameter(Mandatory)]
        [hashtable[]] $RetryStrategy,

        [hashtable] $BackoffStrategy,

        [int] $MaxAttempts = 3,

        [string] $OperationName = 'operation'
    )

    # Default the backoff lazily so callers do not pay for the factory call
    # when they pass an explicit strategy (also keeps the parameter default
    # free of an executable expression, which PowerShell evaluates at
    # parameter-binding time in an unpredictable scope).
    if (-not $BackoffStrategy) {
        $BackoffStrategy = New-ExponentialBackoffStrategy
    }

    # Validate all strategies up front so a malformed hashtable fails fast
    # rather than mid-retry. Each retry-strategy item is checked
    # individually so the error names the offending one.
    foreach ($rs in $RetryStrategy) {
        Assert-RetryStrategyShape -Strategy $rs `
            -Kind 'Retry' -ActionKey 'ShouldRetry'
    }
    Assert-RetryStrategyShape -Strategy $BackoffStrategy `
        -Kind 'Backoff' -ActionKey 'GetDelay'

    for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
        try {
            return & $ScriptBlock
        }
        catch {
            $err = $_

            # OR-composition: the first matching strategy wins. Its Name is
            # surfaced in the warning so the operator can tell which policy
            # fired when several are composed.
            $matched = $RetryStrategy |
                Where-Object { & $_.ShouldRetry $err } |
                Select-Object -First 1

            # No policy matched - failure is permanent, propagate the
            # original error so the caller sees the underlying cause.
            if (-not $matched) { throw }

            # Last attempt - propagate rather than wrapping in a generic
            # "gave up" error; the underlying failure is what operators
            # need to act on.
            if ($attempt -ge $MaxAttempts) { throw }

            $delay = & $BackoffStrategy.GetDelay $attempt $err

            Write-Warning (
                "$OperationName failed (attempt $attempt/$MaxAttempts, " +
                "strategy=$($matched.Name)): " +
                "$($err.Exception.Message). Retrying in ${delay}s ..."
            )
            Start-Sleep -Seconds $delay
        }
    }
}