Public/Psrunner/Invoke-RetriableCommand.ps1

using namespace System.Management.Automation
using namespace System.Collections.ObjectModel

#Requires -Psedition Core
function Invoke-RetriableCommand {
  <#
  .SYNOPSIS
    Runs Retriable Commands
  .DESCRIPTION
    Retries a script or command for a number of times (3 times by default) or until it succeeds.
    This is handy if you know a command or script will fail sometimes and you want to retry it a few times.
    In the return value, example: $result.HasErrors tells you if there were any errors during retries.
    See: Get-Help Invoke-RetriableCommand -Examples
  .EXAMPLE
   # Run a simple ScriptBlock with Attempts
   $result = Invoke-RetriableCommand -ScriptBlock {
       Write-Output "Running retry test..."
       Start-Sleep -Seconds 1

       # Simulate success after a few retries using current second
       if ([DateTime]::Now.Second % 5 -eq 0) {
          return [PSCustomObject]@{ StatusCode = 200; Message = "Success" }
       } else {
          throw "Simulated failure. Reason: $([DateTime]::Now.Second)%5 -eq 0 is false"
        }
    } -MaxAttempts 4 -Message "Simulate success after a few attempts"

  .EXAMPLE
   # Retry a remote command on a target machine
   $scriptBlock = {
       param($arg1)
       Write-Output "Processing on remote computer with argument: $arg1"
       # Simulate conditional success
       if ($arg1 -eq 'SuccessValue') {
           return 0 # Success return code
       }
       throw "Execution failed on remote machine"
   }
   $result = Invoke-RetriableCommand -ComputerName 'Server01' -ScriptBlock $scriptBlock -ArgumentList 'SuccessValue' -MaxAttempts 3 -Message "Executing remote retry"

  .EXAMPLE
   # Retry execution of a local file/script
   $result = Invoke-RetriableCommand -FilePath "C:\Scripts\TestScript.ps1" -ArgumentList "Param1", "Param2" -MaxAttempts 4 -Timeout 60 -Message "Retrying file execution"

  .EXAMPLE
   # Customizing success return codes
   $result = Invoke-RetriableCommand -ScriptBlock {
       # Simulate success with custom return code 200
       Write-Output "Custom success return code"
       Start-Sleep -Seconds 1
       return 200
   } -MaxAttempts 3 -SuccessReturnCodes @(0, 200) -Message "Custom success code retry"

  .EXAMPLE
   # Retry with a CancellationToken
   $cancellationTokenSource = New-Object System.Threading.CancellationTokenSource
   Start-Job -ScriptBlock {
       Start-Sleep -Seconds 3
       $cancellationTokenSource.Cancel()
   }
   $result = Invoke-RetriableCommand -ScriptBlock {
       Write-Output "Attempting long-running command execution"
       Start-Sleep -Seconds 10 # Simulated long-running task
   } -MaxAttempts 5 -Timeout 15 -CancellationToken $cancellationTokenSource.Token -Message "Retry with cancellation token"

  .NOTES
    - Requires Core Psedition due to ternary-operator
      https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_if?view=powershell-7.4#using-the-ternary-operator-syntax

    - All Unnamed arguments will be passed as arguments to the script or command
  .LINK
    Online Version: https://github.com/alainQtec/cliHelper.Core/blob/main/Public/Psrunner/Invoke-RetriableCommand.ps1
  #>

  [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'ScriptBlock')]
  [OutputType([Results])][Alias('Invoke-RtCommand')]
  param (
    [Parameter(Mandatory = $false, Position = 0, ParameterSetName = '__AllParameterSets')]
    [ValidateNotNullOrWhiteSpace()]
    [string]$ComputerName,

    [Parameter(Mandatory = $false, Position = 1, ParameterSetName = 'ScriptBlock')]
    [Alias('Script')][ValidateNotNullOrEmpty()]
    [ScriptBlock]$ScriptBlock,

    [Parameter(Mandatory = $false, Position = 1, ParameterSetName = 'Command')]
    [Alias('Command')][ValidateNotNullOrWhiteSpace()]
    [string]$FilePath,

    [Parameter(Mandatory = $false, Position = 2, ParameterSetName = '__AllParameterSets')]
    [Object[]]$ArgumentList,


    [Parameter(Mandatory = $false, Position = 3, ParameterSetName = '__AllParameterSets')]
    [Alias('Retries', 'MaxRetries')][ValidateNotNullOrEmpty()]
    [int]$MaxAttempts = 3,

    [Parameter(Mandatory = $false, Position = 4, ParameterSetName = '__AllParameterSets')]
    [uint32[]]$SuccessReturnCodes = @(0, 3010),

    [Parameter(Mandatory = $false, Position = 5, ParameterSetName = '__AllParameterSets')]
    [ValidateNotNullOrWhiteSpace()]
    [string]$WorkingDirectory,

    # Timeout in milliseconds
    [Parameter(Mandatory = $false, Position = 6, ParameterSetName = '__AllParameterSets')]
    [Alias('t')][ValidateNotNullOrEmpty()]
    [int]$Timeout = 500,

    [Parameter(Mandatory = $false, Position = 7, ParameterSetName = '__AllParameterSets')]
    [System.Threading.CancellationToken]$CancellationToken = [System.Threading.CancellationToken]::None,

    # The message to display in the verbose stream
    [Parameter(Mandatory = $false, Position = 8, ParameterSetName = '__AllParameterSets')]
    [Alias('msg')][ValidateNotNullOrWhiteSpace()]
    [string]$Message,

    [Parameter(Mandatory = $false, Position = 9, ParameterSetName = 'Command')]
    [switch]$ExpandStrings
  )
  DynamicParam {
    $DynamicParams = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
    $attributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
    $attributes = [System.Management.Automation.ParameterAttribute]::new(); $attHash = @{
      Position                        = 10
      ParameterSetName                = '__AllParameterSets'
      Mandatory                       = $false
      ValueFromPipeline               = $false
      ValueFromPipelineByPropertyName = $false
      ValueFromRemainingArguments     = $true
      HelpMessage                     = 'Allows splatting with arguments that do not apply. Do not use directly.'
      DontShow                        = $False
    }; $attHash.Keys | ForEach-Object { $attributes.$_ = $attHash.$_ }
    $attributeCollection.Add($attributes)
    $RuntimeParam = [System.Management.Automation.RuntimeDefinedParameter]::new("IgnoredArguments", [Object[]], $attributeCollection)
    $DynamicParams.Add("IgnoredArguments", $RuntimeParam)
    return $DynamicParams
  }

  begin {
    [ActionPreference]$eap = $ErrorActionPreference; $ErrorActionPreference = "SilentlyContinue"
    $fxn = '[PsRunner]'; $PsBoundParameters.GetEnumerator() | ForEach-Object { New-Variable -Name $_.Key -Value $_.Value -ea 'SilentlyContinue' }
    $cmdColors = [PsCustomObject]@{
      Verbose     = 'LemonChiffon'
      Debug       = 'Lavender'
      Error       = 'Salmon'
      Warning     = 'Yellow'
      Progress    = 'SpringGreen'
      Information = 'LawnGreen'
    }
    class Result {
      [PSDataCollection[PsObject]]$Output = [PSDataCollection[PsObject]]::new()
      [bool]$IsSuccess = $false
      [ErrorRecord]$ErrorRecord = $null
      hidden [double]$ET = 0 # ElapsedTime
    }
    class Results {
      hidden [Collection[Result]]$Items = [Collection[Result]]::new()
      hidden [ErrorRecord[]]$Errors = @()
      Results() {
        $this.PsObject.Properties.Add([PSScriptProperty]::new('ElapsedTime', { if ($this.Items.Count -gt 0) { return [double](($this.Items.ET | Measure-Object -Sum).Sum) }; return [double]0 }))
        $this.PsObject.Properties.Add([PSScriptProperty]::new('IsSuccess', { $r = [bool]($this.Items.Count -gt 0 -and $this.Items.Where({ $_.IsSuccess }).Count -gt 0); $this.Errors = $this.Items.ErrorRecord; return $r }))
        $this.PsObject.Properties.Add([PSScriptProperty]::new('HasErrors', { return  $this.Errors.Count -gt 0 }))
        $this.PsObject.Properties.Add([PSScriptProperty]::new('Output', { return $this.Items.Output }))
        $this.PsObject.Properties.Add([PSScriptProperty]::new('Count', { return $this.Items.Count }))
      }
      [void] Add([Result]$item) { $this.Items.Add($item) }
    }
  }

  process {
    $Attempts = 1; $Results = [Results]::new()
    if ($PsBoundParameters.ContainsKey("Message") -and $verbose) {
      Write-Console "$fxn $Message" -f $cmdColors.Information
    }
    while (($Attempts -le $MaxAttempts) -and !$Results.IsSuccess) {
      $CommandStartTime = Get-Date; $Retries = $MaxAttempts - $Attempts
      if ($cancellationToken.IsCancellationRequested) { $verbose ? (Write-Console "$fxn CancellationRequested when $Retries retries were left." -f $cmdColors.Verbose) : $null; throw }
      $Result = [Result]::new()
      try {
        $AttemptStartTime = Get-Date
        $verbose ? (Write-Console "$fxn Attempt # $Attempts/$MaxAttempts ..." -f $cmdColors.Progress) : $null
        if ($PSCmdlet.ParameterSetName -eq 'Command') {
          try {
            $verbose ? (Write-Console "Running command line [$FilePath $ArgumentList] on $ComputerName" -f LemonChiffon) : $null
            $Result.Output += Invoke-Command -ComputerName $ComputerName -ScriptBlock {
              $VerbosePreference = $using:VerbosePreference
              $WhatIfPreference = $using:WhatIfPreference
              $ps = [System.Diagnostics.Process]::new()
              $ps_startInfo = [System.Diagnostics.ProcessStartInfo]::new()
              $ps_startInfo.FileName = $Using:FilePath;
              if ($Using:ArgumentList) {
                $ps_startInfo.Arguments = $Using:ArgumentList;
                if ($Using:ExpandStrings) {
                  $ps_startInfo.Arguments = $ExecutionContext.InvokeCommandWithCred.ExpandString($Using:ArgumentList);
                }
              }
              if ($Using:WorkingDirectory) {
                $ps_startInfo.WorkingDirectory = $Using:WorkingDirectory;
                if ($Using:ExpandStrings) {
                  $ps_startInfo.WorkingDirectory = $ExecutionContext.InvokeCommandWithCred.ExpandString($Using:WorkingDirectory);
                }
              }
              $ps_startInfo.UseShellExecute = $false; # This is critical for installs to function on core servers
              $ps.StartInfo = $ps_startInfo;
              $verbose ? (Write-Console "Starting Process path [$($ps_startInfo.FileName)] - Args: [$($ps_startInfo.Arguments)] - Working dir: [$($Using:WorkingDirectory)]" -f LemonChiffon) : $null
              $null = $ps.Start();
              if (!$ps) {
                throw "Error running program: $($ps.ExitCode)"
              } else {
                $ps.WaitForExit()
              }
              # Check the exit code of the process to see if it succeeded.
              if ($ps.ExitCode -notin $Using:SuccessReturnCodes) {
                throw "Error running program: $($ps.ExitCode)"
              }
            }
            $Result.IsSuccess = [bool]$?
          } catch {
            $Result.IsSuccess = $false
            $Result.ErrorRecord = $_.Exception.ErrorRecord
            $verbose ? (Write-Console "$fxn Errored: $($_.CategoryInfo.Category) : $($_.CategoryInfo.Reason) : $($_.Exception.Message)" -f LemonChiffon) : $null
          }
        } else {
          $Result.Output += Invoke-Command -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList
          $Result.IsSuccess = [bool]$?
        }
      } catch {
        $Result.IsSuccess = $false
        $Result.ErrorRecord = [System.Management.Automation.ErrorRecord]$_
        $verbose ? (Write-Console "$fxn Error after $([math]::Round(($(Get-Date) - $AttemptStartTime).TotalSeconds, 2)) seconds:" -f $cmdColors.Verbose) : $null
        $verbose ? (Write-Console " $($_.CategoryInfo.Category) : $($_.CategoryInfo.Reason) : $($_.Exception.Message)" -f $cmdColors.Error) : $null
      } finally {
        $Result.ET = [math]::Round(($(Get-Date) - $CommandStartTime).TotalSeconds, 2)
        [void]$Results.Add($Result)
        if (!$cancellationToken.IsCancellationRequested -and ($Retries -ne 0) -and !$Result.IsSuccess) {
          $verbose ? (Write-Console "$fxn Waiting $Timeout ms before retrying. Retries left: $Retries" -f $cmdColors.Verbose) : $null
          [System.Threading.Thread]::Sleep($Timeout);
        }
        $Attempts++
      }
    }
  }

  end {
    if ($verbose) {
      $e = @{
        0 = @{
          c = "Error"
          m = "$Message Completed With Errors. Total time elapsed $($Results.ElapsedTime). Check the log file `$LogPath".Trim()
        };
        1 = @{
          c = "Information"
          m = "$Message Completed Successfully. Total time elapsed $($Results.ElapsedTime)".Trim()
        }
      }[[int]$Results.IsSuccess]
      Write-Console "$fxn $($e.m)" -f $cmdColors.($e.c)
    }
    $ErrorActionPreference = $eap;
    return $Results
  }
}