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
32 changes: 31 additions & 1 deletion docs/reference/cmdlets/Invoke-IdlePlan.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ Executes an IdLE plan.

```
Invoke-IdlePlan [-Plan] <Object> [[-Providers] <Hashtable>] [[-EventSink] <Object>]
[-ProgressAction <ActionPreference>] [-WhatIf] [-Confirm] [<CommonParameters>]
[[-ExecutionOptions] <Hashtable>] [-ProgressAction <ActionPreference>] [-WhatIf] [-Confirm]
[<CommonParameters>]
```

## DESCRIPTION
Expand All @@ -28,6 +29,18 @@ Delegates execution to IdLE.Core.
Invoke-IdlePlan -Plan $plan -Providers $providers
```

### EXAMPLE 2
```
$execOptions = @{
RetryProfiles = @{
Default = @{ MaxAttempts = 3; InitialDelayMilliseconds = 200 }
ExchangeOnline = @{ MaxAttempts = 6; InitialDelayMilliseconds = 500 }
}
DefaultRetryProfile = 'Default'
}
Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $execOptions
```

## PARAMETERS

### -Plan
Expand Down Expand Up @@ -76,6 +89,23 @@ Accept pipeline input: False
Accept wildcard characters: False
```

### -ExecutionOptions
Optional host-owned execution options.
Supports retry profile configuration.
Must be a hashtable with optional keys: RetryProfiles, DefaultRetryProfile.

```yaml
Type: Hashtable
Parameter Sets: (All)
Aliases:

Required: False
Position: 4
Default value: None
Accept pipeline input: False
Accept wildcard characters: False
```

### -WhatIf
Shows what would happen if the cmdlet runs.
The cmdlet is not run.
Expand Down
131 changes: 131 additions & 0 deletions examples/workflows/joiner-with-retry-profiles.psd1
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
@{
# Example workflow demonstrating configurable retry behavior
#
# This workflow shows how to use RetryProfile to configure
# different retry behavior for steps targeting different systems.

Name = 'Joiner - With Retry Profiles'
LifecycleEvent = 'Joiner'
Description = 'Example workflow with custom retry profiles for different target systems'

Steps = @(
@{
Name = 'Resolve identity from HR system'
Type = 'IdLE.Step.EmitEvent'
Description = 'Lookup user in HR database'
# In a real deployment, this would be a system-specific "resolve from HR" step.
# Here we emit an event as a simple example, using the default retry profile.
With = @{
Message = 'Resolve identity for HR record {{Request.Data.HrEmployeeId}}'
}
}

@{
Name = 'Create Entra ID account'
Type = 'IdLE.Step.CreateIdentity'
Description = 'Create user in Entra ID (Microsoft Graph API)'
RetryProfile = 'GraphAPI'
# Microsoft Graph has specific throttling limits - use a profile
# optimized for Graph API retry behavior
}

@{
Name = 'Create mailbox'
Type = 'IdLE.Step.EnsureEntitlement'
Description = 'Provision Exchange Online mailbox'
RetryProfile = 'ExchangeOnline'
With = @{
Kind = 'Mailbox'
MailboxType = 'UserMailbox'
}
# Exchange Online has different throttling characteristics
# than Graph - use a dedicated profile
}

@{
Name = 'Add to security group'
Type = 'IdLE.Step.EnsureEntitlement'
Description = 'Add user to Entra ID security group'
RetryProfile = 'GraphAPI'
With = @{
Kind = 'Group'
Value = 'All_Users'
}
}

@{
Name = 'Set manager attribute'
Type = 'IdLE.Step.EnsureAttribute'
Description = 'Set manager reference in Entra ID'
RetryProfile = 'GraphAPI'
With = @{
AttributeName = 'manager'
Value = '{{Request.Data.ManagerId}}'
}
}
)

OnFailureSteps = @(
@{
Name = 'Emit failure notification'
Type = 'IdLE.Step.EmitEvent'
Description = 'Notify on workflow failure'
RetryProfile = 'Notifications'
# Notification systems may have their own rate limits
With = @{
Message = 'Joiner workflow failed for user {{Request.Data.UserPrincipalName}}'
}
}
)
}

<#
Example ExecutionOptions configuration:

$executionOptions = @{
RetryProfiles = @{
Default = @{
MaxAttempts = 3
InitialDelayMilliseconds = 200
BackoffFactor = 2.0
MaxDelayMilliseconds = 5000
JitterRatio = 0.2
}
GraphAPI = @{
# Microsoft Graph throttling can be aggressive
# Use more retries with longer delays
MaxAttempts = 5
InitialDelayMilliseconds = 1000
BackoffFactor = 2.0
MaxDelayMilliseconds = 16000
JitterRatio = 0.3
}
ExchangeOnline = @{
# Exchange Online often requires patience
MaxAttempts = 6
InitialDelayMilliseconds = 500
BackoffFactor = 2.5
MaxDelayMilliseconds = 30000
JitterRatio = 0.25
}
Notifications = @{
# Notifications should retry but not delay the workflow too much
MaxAttempts = 3
InitialDelayMilliseconds = 100
BackoffFactor = 1.5
MaxDelayMilliseconds = 1000
JitterRatio = 0.1
}
}
DefaultRetryProfile = 'Default'
}

# Invoke the plan with retry configuration
$result = Invoke-IdlePlan -Plan $plan -Providers $providers -ExecutionOptions $executionOptions

# Each step will use its configured retry profile:
# - Steps without RetryProfile use 'Default' (from DefaultRetryProfile)
# - Steps with RetryProfile='GraphAPI' use the GraphAPI profile
# - Steps with RetryProfile='ExchangeOnline' use the ExchangeOnline profile
# - Steps with RetryProfile='Notifications' use the Notifications profile
#>
182 changes: 182 additions & 0 deletions src/IdLE.Core/Private/Assert-IdleExecutionOptions.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# Asserts that ExecutionOptions is valid and rejects ScriptBlocks.
# Validates the structure and constraints for retry profiles.

# Retry parameter limits (hard constraints to prevent misconfiguration)
$script:IDLE_RETRY_MAX_ATTEMPTS_LIMIT = 10
$script:IDLE_RETRY_INITIAL_DELAY_MS_LIMIT = 60000
$script:IDLE_RETRY_MAX_DELAY_MS_LIMIT = 300000

function Assert-IdleExecutionOptions {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[AllowNull()]
[object] $ExecutionOptions
)

if ($null -eq $ExecutionOptions) {
return
}

# ExecutionOptions must be a hashtable or IDictionary
if ($ExecutionOptions -isnot [System.Collections.IDictionary]) {
throw [System.ArgumentException]::new(
'ExecutionOptions must be a hashtable or IDictionary.',
'ExecutionOptions'
)
}

# Reject ScriptBlocks anywhere in ExecutionOptions
Assert-IdleNoScriptBlock -InputObject $ExecutionOptions -Path 'ExecutionOptions'

# Validate RetryProfiles if present
if ($ExecutionOptions.Contains('RetryProfiles')) {
$retryProfiles = $ExecutionOptions['RetryProfiles']

if ($null -ne $retryProfiles -and $retryProfiles -isnot [System.Collections.IDictionary]) {
throw [System.ArgumentException]::new(
'ExecutionOptions.RetryProfiles must be a hashtable or IDictionary.',
'ExecutionOptions'
)
}

if ($null -ne $retryProfiles) {
foreach ($profileKey in $retryProfiles.Keys) {
# Profile key must match pattern: ^[A-Za-z0-9_.-]{1,64}$
if ([string]$profileKey -notmatch '^[A-Za-z0-9_.-]{1,64}$') {
throw [System.ArgumentException]::new(
"RetryProfile key '$profileKey' is invalid. Must match pattern: ^[A-Za-z0-9_.-]{1,64}$",
'ExecutionOptions'
)
}

$profile = $retryProfiles[$profileKey]

if ($null -eq $profile) {
throw [System.ArgumentException]::new(
"RetryProfile '$profileKey' is null. Each profile must be a hashtable with retry parameters.",
'ExecutionOptions'
)
}

if ($profile -isnot [System.Collections.IDictionary]) {
throw [System.ArgumentException]::new(
"RetryProfile '$profileKey' must be a hashtable or IDictionary.",
'ExecutionOptions'
)
}

# Validate individual retry parameters
Assert-IdleRetryProfile -Profile $profile -ProfileKey $profileKey
}
}
}

# Validate DefaultRetryProfile if present
if ($ExecutionOptions.Contains('DefaultRetryProfile')) {
$defaultProfile = $ExecutionOptions['DefaultRetryProfile']

if ($null -ne $defaultProfile -and [string]::IsNullOrWhiteSpace([string]$defaultProfile)) {
throw [System.ArgumentException]::new(
'ExecutionOptions.DefaultRetryProfile must not be an empty string.',
'ExecutionOptions'
)
}

# DefaultRetryProfile must reference a valid profile key
if ($null -ne $defaultProfile -and $ExecutionOptions.Contains('RetryProfiles')) {
$retryProfiles = $ExecutionOptions['RetryProfiles']
if ($null -ne $retryProfiles -and -not $retryProfiles.Contains([string]$defaultProfile)) {
throw [System.ArgumentException]::new(
"DefaultRetryProfile '$defaultProfile' references a profile that does not exist in RetryProfiles.",
'ExecutionOptions'
)
}
}
}
}

function Assert-IdleRetryProfile {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateNotNull()]
[System.Collections.IDictionary] $Profile,

[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $ProfileKey
)

# Validate MaxAttempts (0..10)
if ($Profile.Contains('MaxAttempts')) {
$maxAttempts = $Profile['MaxAttempts']
if ($maxAttempts -isnot [int] -or $maxAttempts -lt 0 -or $maxAttempts -gt $script:IDLE_RETRY_MAX_ATTEMPTS_LIMIT) {
throw [System.ArgumentException]::new(
"RetryProfile '$ProfileKey': MaxAttempts must be an integer between 0 and $script:IDLE_RETRY_MAX_ATTEMPTS_LIMIT (inclusive).",
'ExecutionOptions'
)
}
Comment thread
blindzero marked this conversation as resolved.
}

# Validate InitialDelayMilliseconds (0..60000)
if ($Profile.Contains('InitialDelayMilliseconds')) {
$initialDelay = $Profile['InitialDelayMilliseconds']
if ($initialDelay -isnot [int] -or $initialDelay -lt 0 -or $initialDelay -gt $script:IDLE_RETRY_INITIAL_DELAY_MS_LIMIT) {
throw [System.ArgumentException]::new(
"RetryProfile '$ProfileKey': InitialDelayMilliseconds must be an integer between 0 and $script:IDLE_RETRY_INITIAL_DELAY_MS_LIMIT (inclusive).",
'ExecutionOptions'
)
}
}

# Validate BackoffFactor (>= 1.0)
if ($Profile.Contains('BackoffFactor')) {
$backoffFactor = $Profile['BackoffFactor']
# Accept both int and double
if (($backoffFactor -isnot [double] -and $backoffFactor -isnot [int]) -or ([double]$backoffFactor -lt 1.0)) {
throw [System.ArgumentException]::new(
"RetryProfile '$ProfileKey': BackoffFactor must be a number >= 1.0.",
'ExecutionOptions'
)
}
}

# Validate MaxDelayMilliseconds (0..300000 and >= InitialDelayMilliseconds)
if ($Profile.Contains('MaxDelayMilliseconds')) {
$maxDelay = $Profile['MaxDelayMilliseconds']
if ($maxDelay -isnot [int] -or $maxDelay -lt 0 -or $maxDelay -gt $script:IDLE_RETRY_MAX_DELAY_MS_LIMIT) {
throw [System.ArgumentException]::new(
"RetryProfile '$ProfileKey': MaxDelayMilliseconds must be an integer between 0 and $script:IDLE_RETRY_MAX_DELAY_MS_LIMIT (inclusive).",
'ExecutionOptions'
)
}

# Check that MaxDelayMilliseconds >= InitialDelayMilliseconds
# Use the profile's InitialDelayMilliseconds if present, otherwise use engine default (250ms)
$initialDelay = if ($Profile.Contains('InitialDelayMilliseconds')) {
$Profile['InitialDelayMilliseconds']
} else {
250 # Engine default
}

if ($maxDelay -lt $initialDelay) {
throw [System.ArgumentException]::new(
"RetryProfile '$ProfileKey': MaxDelayMilliseconds ($maxDelay) must be >= InitialDelayMilliseconds ($initialDelay).",
'ExecutionOptions'
)
}
Comment thread
blindzero marked this conversation as resolved.
}

# Validate JitterRatio (0.0..1.0)
if ($Profile.Contains('JitterRatio')) {
$jitterRatio = $Profile['JitterRatio']
# Accept both int and double
if (($jitterRatio -isnot [double] -and $jitterRatio -isnot [int]) -or ([double]$jitterRatio -lt 0.0) -or ([double]$jitterRatio -gt 1.0)) {
throw [System.ArgumentException]::new(
"RetryProfile '$ProfileKey': JitterRatio must be a number between 0.0 and 1.0 (inclusive).",
'ExecutionOptions'
)
}
}
}
12 changes: 11 additions & 1 deletion src/IdLE.Core/Private/Invoke-IdleWithRetry.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ function Invoke-IdleWithRetry {
[scriptblock] $Operation,

[Parameter()]
[ValidateRange(1, 50)]
[ValidateRange(0, 50)]
[int] $MaxAttempts = 3,

[Parameter()]
Expand Down Expand Up @@ -118,6 +118,16 @@ function Invoke-IdleWithRetry {
[string] $DeterministicSeed = ''
)

# Handle MaxAttempts = 0 (no retry): run once and propagate any error
if ($MaxAttempts -eq 0) {
$value = & $Operation
return [pscustomobject]@{
PSTypeName = 'IdLE.RetryResult'
Value = $value
Attempts = 1
}
}

$attempt = 0

while ($attempt -lt $MaxAttempts) {
Expand Down
Loading