Modules/IdLE.Core/Private/Invoke-IdleWithRetry.ps1
|
function Test-IdleTransientError { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] [System.Exception] $Exception ) # Retries must be safe-by-default: # We only retry when a trusted code path explicitly marks an exception as transient. # # Supported markers: # - Exception.Data['Idle.IsTransient'] = $true # - Exception.Data['IdleIsTransient'] = $true # # We accept common "truthy" representations to avoid fragile integrations: # - $true # - 'true' (case-insensitive) # - 1 $markerKeys = @( 'Idle.IsTransient', 'IdleIsTransient' ) foreach ($key in $markerKeys) { if (-not $Exception.Data.Contains($key)) { continue } $value = $Exception.Data[$key] if ($value -is [bool] -and $value) { return $true } if ($value -is [int] -and $value -eq 1) { return $true } if ($value -is [string] -and $value.Trim().ToLowerInvariant() -eq 'true') { return $true } } if ($null -ne $Exception.InnerException) { return Test-IdleTransientError -Exception $Exception.InnerException } return $false } function Get-IdleDeterministicJitter { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateRange(0.0, 1.0)] [double] $JitterRatio, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Seed ) if ($JitterRatio -le 0.0) { return 0.0 } $bytes = [System.Text.Encoding]::UTF8.GetBytes($Seed) $hash = [System.Security.Cryptography.SHA256]::HashData($bytes) $u64 = [System.BitConverter]::ToUInt64($hash, 0) $unit = $u64 / [double][UInt64]::MaxValue return (($unit * 2.0) - 1.0) * $JitterRatio } function Invoke-IdleWithRetry { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] [scriptblock] $Operation, [Parameter()] [ValidateRange(1, 50)] [int] $MaxAttempts = 3, [Parameter()] [ValidateRange(0, 600000)] [int] $InitialDelayMilliseconds = 250, [Parameter()] [ValidateRange(1.0, 100.0)] [double] $BackoffFactor = 2.0, [Parameter()] [ValidateRange(0, 600000)] [int] $MaxDelayMilliseconds = 5000, [Parameter()] [ValidateRange(0.0, 1.0)] [double] $JitterRatio = 0.2, [Parameter()] [AllowNull()] [object] $EventSink, [Parameter()] [AllowEmptyString()] [string] $StepName = '', [Parameter()] [AllowEmptyString()] [string] $OperationName = 'Operation', [Parameter()] [AllowEmptyString()] [string] $DeterministicSeed = '' ) $attempt = 0 while ($attempt -lt $MaxAttempts) { $attempt++ try { $value = & $Operation return [pscustomobject]@{ PSTypeName = 'IdLE.RetryResult' Value = $value Attempts = $attempt } } catch { $exception = $_.Exception if (-not (Test-IdleTransientError -Exception $exception)) { # Fail fast for non-transient errors. throw } if ($attempt -ge $MaxAttempts) { throw } $baseDelay = [math]::Min( $MaxDelayMilliseconds, [math]::Round($InitialDelayMilliseconds * [math]::Pow($BackoffFactor, ($attempt - 1))) ) $seed = if ([string]::IsNullOrWhiteSpace($DeterministicSeed)) { "$OperationName|$StepName|$attempt" } else { "$DeterministicSeed|$attempt" } $jitterFactor = Get-IdleDeterministicJitter -JitterRatio $JitterRatio -Seed $seed $delay = [math]::Round($baseDelay * (1.0 + $jitterFactor)) if ($delay -lt 0) { $delay = 0 } if ($null -ne $EventSink -and $EventSink.PSObject.Methods.Name -contains 'WriteEvent') { try { $EventSink.WriteEvent( 'StepRetrying', "Transient failure in '$OperationName' (attempt $attempt/$MaxAttempts). Retrying.", $StepName, @{ attempt = $attempt maxAttempts = $MaxAttempts delayMs = $delay errorType = $exception.GetType().FullName message = $exception.Message } ) } catch { # Intentionally ignored. } } if ($delay -gt 0) { Start-Sleep -Milliseconds $delay } continue } } } |