Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions src/IdLE.Core/Private/Invoke-IdleWithRetry.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
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
}
}
}
51 changes: 43 additions & 8 deletions src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,25 @@ function Invoke-IdlePlanObject {
$stepRegistry = Get-IdleStepRegistry -Providers $Providers

$context = [pscustomobject]@{
PSTypeName = 'IdLE.ExecutionContext'
Plan = $Plan
Providers = $Providers
PSTypeName = 'IdLE.ExecutionContext'
Plan = $Plan
Providers = $Providers

# Object-based, stable eventing contract.
# Steps and the engine call: $Context.EventSink.WriteEvent(...)
EventSink = $engineEventSink
EventSink = $engineEventSink
}

# Execution retry policy (safe-by-default):
# - Only retry errors explicitly marked transient by trusted code paths (Exception.Data['Idle.IsTransient'] = $true).
# - Fail fast for all other errors.
# NOTE: This is currently engine-owned and not configurable via plan/workflow to keep the surface small in this increment.
$retryPolicy = @{
MaxAttempts = 3
InitialDelayMilliseconds = 250
BackoffFactor = 2.0
MaxDelayMilliseconds = 5000
JitterRatio = 0.2
}

# Emit run start event.
Expand Down Expand Up @@ -108,6 +120,7 @@ function Invoke-IdlePlanObject {
Type = $stepType
Status = 'NotApplicable'
Error = $null
Attempts = 0
}

$context.EventSink.WriteEvent('StepNotApplicable', "Step '$stepName' not applicable (condition not met).", $stepName, @{
Expand Down Expand Up @@ -140,8 +153,25 @@ function Invoke-IdlePlanObject {
throw [System.ArgumentException]::new("Step handler for type '$stepType' is not a valid function name.", 'Providers')
}

# Execute the step via handler.
$result = & $handlerName -Context $context -Step $step
# Execute the step via handler using safe retries for transient failures.
# Retries are only performed if trusted code marks the exception as transient.
$operationName = "Step '$stepName' ($stepType)"
$retrySeed = "Plan:$corr|Step:$stepName|Type:$stepType"

$retryResult = Invoke-IdleWithRetry -Operation {
& $handlerName -Context $context -Step $step
} -MaxAttempts $retryPolicy.MaxAttempts `
-InitialDelayMilliseconds $retryPolicy.InitialDelayMilliseconds `
-BackoffFactor $retryPolicy.BackoffFactor `
-MaxDelayMilliseconds $retryPolicy.MaxDelayMilliseconds `
-JitterRatio $retryPolicy.JitterRatio `
-EventSink $context.EventSink `
-StepName $stepName `
-OperationName $operationName `
-DeterministicSeed $retrySeed

$result = $retryResult.Value
$attempts = [int]$retryResult.Attempts

# Normalize result shape (minimal contract).
$stepResults += [pscustomobject]@{
Expand All @@ -150,23 +180,28 @@ function Invoke-IdlePlanObject {
Type = $stepType
Status = if ($null -ne $result -and $result.PSObject.Properties.Name -contains 'Status') { [string]$result.Status } else { 'Completed' }
Error = if ($null -ne $result -and $result.PSObject.Properties.Name -contains 'Error') { $result.Error } else { $null }
Attempts = $attempts
}

$context.EventSink.WriteEvent('StepCompleted', "Step '$stepName' completed.", $stepName, @{
StepType = $stepType
Index = $i
StepType = $stepType
Index = $i
Attempts = $attempts
})
}
catch {
$failed = $true
$err = $_

# We cannot reliably know the number of attempts on failure without wrapping errors.
# For this increment, we keep the output stable and report a minimum of 1 attempt.
$stepResults += [pscustomobject]@{
PSTypeName = 'IdLE.StepResult'
Name = $stepName
Type = $stepType
Status = 'Failed'
Error = $err.Exception.Message
Attempts = 1
}

$context.EventSink.WriteEvent('StepFailed', "Step '$stepName' failed.", $stepName, @{
Expand Down
Loading